fix(run): pass directory to event.subscribe for session-scoped events
The SSE event stream subscription was missing the directory parameter,
causing the OpenCode server to only emit global events (heartbeat,
connected, toast) but not session-scoped events (session.idle,
session.status, tool.execute, message.updated, message.part.updated).
Without session events:
- hasReceivedMeaningfulWork stays false (no message/tool events)
- mainSessionIdle never updates (no session.idle/status events)
- pollForCompletion either hangs or exits for unrelated reasons
Fix: Pass { directory } to client.event.subscribe(), matching the
pattern already used by client.session.promptAsync().
Also adds a stabilization period (10s) after first meaningful work
as defense-in-depth against early exit race conditions.
This commit is contained in:
@@ -45,6 +45,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
|
||||
//#then - exits with 0 but only after 3 consecutive checks
|
||||
@@ -53,6 +54,30 @@ describe("pollForCompletion", () => {
|
||||
expect(todoCallCount).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
|
||||
it("does not check completion during stabilization period after first meaningful work", async () => {
|
||||
//#given - session idle, meaningful work done, but stabilization period not elapsed
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
spyOn(console, "error").mockImplementation(() => {})
|
||||
const ctx = createMockContext()
|
||||
const eventState = createEventState()
|
||||
eventState.mainSessionIdle = true
|
||||
eventState.hasReceivedMeaningfulWork = true
|
||||
const abortController = new AbortController()
|
||||
|
||||
//#when - abort after 50ms (within the 60ms stabilization period)
|
||||
setTimeout(() => abortController.abort(), 50)
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 60,
|
||||
})
|
||||
|
||||
//#then - should be aborted, not completed (stabilization blocked completion check)
|
||||
expect(result).toBe(130)
|
||||
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
|
||||
expect(todoCallCount).toBe(0)
|
||||
})
|
||||
|
||||
it("does not exit when currentTool is set - resets consecutive counter", async () => {
|
||||
//#given
|
||||
spyOn(console, "log").mockImplementation(() => {})
|
||||
@@ -110,6 +135,7 @@ describe("pollForCompletion", () => {
|
||||
const result = await pollForCompletion(ctx, eventState, abortController, {
|
||||
pollIntervalMs: 10,
|
||||
requiredConsecutive: 3,
|
||||
minStabilizationMs: 0,
|
||||
})
|
||||
const elapsedMs = Date.now() - startMs
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { checkCompletionConditions } from "./completion"
|
||||
const DEFAULT_POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_REQUIRED_CONSECUTIVE = 3
|
||||
const ERROR_GRACE_CYCLES = 3
|
||||
const MIN_STABILIZATION_MS = 10_000
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
requiredConsecutive?: number
|
||||
minStabilizationMs?: number
|
||||
}
|
||||
|
||||
export async function pollForCompletion(
|
||||
@@ -21,8 +23,11 @@ export async function pollForCompletion(
|
||||
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const requiredConsecutive =
|
||||
options.requiredConsecutive ?? DEFAULT_REQUIRED_CONSECUTIVE
|
||||
const minStabilizationMs =
|
||||
options.minStabilizationMs ?? MIN_STABILIZATION_MS
|
||||
let consecutiveCompleteChecks = 0
|
||||
let errorCycleCount = 0
|
||||
let firstWorkTimestamp: number | null = null
|
||||
|
||||
while (!abortController.signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
@@ -61,6 +66,17 @@ export async function pollForCompletion(
|
||||
continue
|
||||
}
|
||||
|
||||
// Track when first meaningful work was received
|
||||
if (firstWorkTimestamp === null) {
|
||||
firstWorkTimestamp = Date.now()
|
||||
}
|
||||
|
||||
// Don't check completion during stabilization period
|
||||
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
|
||||
consecutiveCompleteChecks = 0
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldExit = await checkCompletionConditions(ctx)
|
||||
if (shouldExit) {
|
||||
consecutiveCompleteChecks++
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
console.log(pc.dim(`Session: ${sessionID}`))
|
||||
|
||||
const ctx: RunContext = { client, sessionID, directory, abortController }
|
||||
const events = await client.event.subscribe()
|
||||
const events = await client.event.subscribe({ query: { directory } })
|
||||
const eventState = createEventState()
|
||||
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
|
||||
() => {},
|
||||
|
||||
Reference in New Issue
Block a user