fix(delegate-task): Wave 1 - fix polling timeout, resource cleanup, tool restrictions, idle dedup, auth-plugins JSONC, CLI runner hang
- fix(delegate-task): return error on poll timeout instead of silent null - fix(delegate-task): ensure toast and session cleanup on all error paths with try/finally - fix(delegate-task): apply agent tool restrictions in sync-prompt-sender - fix(plugin): add symmetric idle dedup to prevent double hook triggers - fix(cli): replace regex-based JSONC editing with jsonc-parser in auth-plugins - fix(cli): abort event stream after completion and restore no-timeout default All changes verified with tests and typecheck.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, test, mock } from "bun:test"
|
||||
import { pollSessionUntilIdle } from "./session-poller"
|
||||
|
||||
type SessionStatusResult = {
|
||||
data?: Record<string, { type: string; attempt?: number; message?: string; next?: number }>
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function createMockClient(statusSequence: SessionStatusResult[]) {
|
||||
let callIndex = 0
|
||||
return {
|
||||
session: {
|
||||
status: mock(async () => {
|
||||
const result = statusSequence[callIndex] ?? statusSequence[statusSequence.length - 1]
|
||||
callIndex++
|
||||
return result
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("pollSessionUntilIdle", () => {
|
||||
// given session transitions from busy to idle
|
||||
// when polling for completion
|
||||
// then resolves successfully
|
||||
test("resolves when session becomes idle", async () => {
|
||||
const client = createMockClient([
|
||||
{ data: { ses_test: { type: "busy" } } },
|
||||
{ data: { ses_test: { type: "busy" } } },
|
||||
{ data: { ses_test: { type: "idle" } } },
|
||||
])
|
||||
|
||||
await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 })
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
// given session is already idle (not in status map)
|
||||
// when polling for completion
|
||||
// then resolves immediately
|
||||
test("resolves when session not found in status (idle by default)", async () => {
|
||||
const client = createMockClient([
|
||||
{ data: {} },
|
||||
])
|
||||
|
||||
await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 })
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// given session never becomes idle
|
||||
// when polling exceeds timeout
|
||||
// then rejects with timeout error
|
||||
test("rejects with timeout when session stays busy", async () => {
|
||||
const client = createMockClient([
|
||||
{ data: { ses_test: { type: "busy" } } },
|
||||
])
|
||||
|
||||
await expect(
|
||||
pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 50 })
|
||||
).rejects.toThrow("timed out")
|
||||
})
|
||||
|
||||
// given session status API returns error
|
||||
// when polling for completion
|
||||
// then treats as idle (graceful degradation)
|
||||
test("resolves on status API error (graceful degradation)", async () => {
|
||||
const client = createMockClient([
|
||||
{ error: new Error("API error") },
|
||||
])
|
||||
|
||||
await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 })
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// given session is in retry state
|
||||
// when polling for completion
|
||||
// then keeps polling until idle
|
||||
test("keeps polling through retry state", async () => {
|
||||
const client = createMockClient([
|
||||
{ data: { ses_test: { type: "busy" } } },
|
||||
{ data: { ses_test: { type: "retry", attempt: 1, message: "retrying", next: 1000 } } },
|
||||
{ data: { ses_test: { type: "busy" } } },
|
||||
{ data: {} },
|
||||
])
|
||||
|
||||
await pollSessionUntilIdle(client as any, "ses_test", { pollIntervalMs: 10, timeoutMs: 5000 })
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
// given default options
|
||||
// when polling
|
||||
// then uses sensible defaults
|
||||
test("uses default options when none provided", async () => {
|
||||
const client = createMockClient([
|
||||
{ data: {} },
|
||||
])
|
||||
|
||||
await pollSessionUntilIdle(client as any, "ses_test")
|
||||
|
||||
expect(client.session.status).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { log } from "../../shared"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
export interface PollOptions {
|
||||
pollIntervalMs?: number
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 1000
|
||||
const DEFAULT_TIMEOUT_MS = 120_000
|
||||
|
||||
export async function pollSessionUntilIdle(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
options?: PollOptions,
|
||||
): Promise<void> {
|
||||
const pollInterval = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
||||
const timeout = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const statusResult = await client.session.status().catch((error) => {
|
||||
log(`[look_at] session.status error (treating as idle):`, error)
|
||||
return { data: undefined, error }
|
||||
})
|
||||
|
||||
if (statusResult.error || !statusResult.data) {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionStatus = statusResult.data[sessionID]
|
||||
if (!sessionStatus || sessionStatus.type === "idle") {
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval))
|
||||
}
|
||||
|
||||
throw new Error(`[look_at] Polling timed out after ${timeout}ms waiting for session ${sessionID} to become idle`)
|
||||
}
|
||||
@@ -111,63 +111,16 @@ describe("look-at tool", () => {
|
||||
})
|
||||
|
||||
describe("createLookAt error handling", () => {
|
||||
// given JSON parse error occurs in session.prompt
|
||||
// given promptAsync throws error
|
||||
// when LookAt tool executed
|
||||
// then error is caught and messages are still fetched
|
||||
test("catches JSON parse error and returns assistant message if available", async () => {
|
||||
const throwingMock = async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
}
|
||||
// then returns error string immediately (no message fetch)
|
||||
test("returns error immediately when promptAsync fails", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_json_error" } }),
|
||||
prompt: throwingMock,
|
||||
promptAsync: throwingMock,
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analysis result" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze image" },
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toBe("analysis result")
|
||||
})
|
||||
|
||||
// given JSON parse error occurs and no messages available
|
||||
// when LookAt tool executed
|
||||
// then returns error string (not throw)
|
||||
test("catches JSON parse error and returns error when no messages", async () => {
|
||||
const throwingMock = async () => {
|
||||
throw new Error("JSON Parse error: Unexpected EOF")
|
||||
}
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_json_no_msg" } }),
|
||||
prompt: throwingMock,
|
||||
promptAsync: throwingMock,
|
||||
create: async () => ({ data: { id: "ses_test_prompt_fail" } }),
|
||||
promptAsync: async () => { throw new Error("Network connection failed") },
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
@@ -193,25 +146,22 @@ describe("look-at tool", () => {
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("multimodal-looker")
|
||||
expect(result).toContain("Network connection failed")
|
||||
})
|
||||
|
||||
// given empty object error {} thrown (the actual production bug)
|
||||
// given promptAsync succeeds but status API fails (polling degrades gracefully)
|
||||
// when LookAt tool executed
|
||||
// then error is caught gracefully, not re-thrown
|
||||
test("catches empty object error from session.prompt", async () => {
|
||||
const throwingMock = async () => {
|
||||
throw {}
|
||||
}
|
||||
// then still attempts to fetch messages (graceful degradation)
|
||||
test("fetches messages even when status API fails", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_empty_obj" } }),
|
||||
prompt: throwingMock,
|
||||
promptAsync: throwingMock,
|
||||
create: async () => ({ data: { id: "ses_test_poll_timeout" } }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ error: new Error("status unavailable") }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "got it" }] },
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "partial result" }] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -237,22 +187,19 @@ describe("look-at tool", () => {
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toBe("got it")
|
||||
expect(result).toBe("partial result")
|
||||
})
|
||||
|
||||
// given generic network error
|
||||
// when LookAt tool executed
|
||||
// then error is caught and returns error string when no messages
|
||||
test("catches generic prompt error and returns error string", async () => {
|
||||
const throwingMock = async () => {
|
||||
throw new Error("Network connection failed")
|
||||
}
|
||||
// given promptAsync succeeds and session becomes idle
|
||||
// when LookAt tool executed and no assistant message found
|
||||
// then returns error about no response
|
||||
test("returns error when no assistant message after successful prompt", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_test_generic_error" } }),
|
||||
prompt: throwingMock,
|
||||
promptAsync: throwingMock,
|
||||
create: async () => ({ data: { id: "ses_test_no_msg" } }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
@@ -280,13 +227,51 @@ describe("look-at tool", () => {
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("multimodal-looker")
|
||||
})
|
||||
|
||||
// given session creation fails
|
||||
// when LookAt tool executed
|
||||
// then returns error about session creation
|
||||
test("returns error when session creation fails", async () => {
|
||||
const mockClient = {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ error: "Internal server error" }),
|
||||
promptAsync: async () => ({}),
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
|
||||
const tool = createLookAt({
|
||||
client: mockClient,
|
||||
directory: "/project",
|
||||
} as any)
|
||||
|
||||
const toolContext: ToolContext = {
|
||||
sessionID: "parent-session",
|
||||
messageID: "parent-message",
|
||||
agent: "sisyphus",
|
||||
directory: "/project",
|
||||
worktree: "/project",
|
||||
abort: new AbortController().signal,
|
||||
metadata: () => {},
|
||||
ask: async () => {},
|
||||
}
|
||||
|
||||
const result = await tool.execute(
|
||||
{ file_path: "/test/file.png", goal: "analyze" },
|
||||
toolContext,
|
||||
)
|
||||
expect(result).toContain("Error")
|
||||
expect(result).toContain("session")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createLookAt model passthrough", () => {
|
||||
// given multimodal-looker agent has resolved model info
|
||||
// when LookAt tool executed
|
||||
// then model info should be passed to session.prompt
|
||||
test("passes multimodal-looker model to session.prompt when available", async () => {
|
||||
// then model info should be passed to promptAsync
|
||||
test("passes multimodal-looker model to promptAsync when available", async () => {
|
||||
let promptBody: any
|
||||
|
||||
const mockClient = {
|
||||
@@ -304,14 +289,11 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_model_passthrough" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "done" }] },
|
||||
@@ -351,7 +333,7 @@ describe("look-at tool", () => {
|
||||
describe("createLookAt with image_data", () => {
|
||||
// given base64 image data is provided
|
||||
// when LookAt tool executed
|
||||
// then should send data URL to session.prompt
|
||||
// then should send data URL to promptAsync
|
||||
test("sends data URL when image_data provided", async () => {
|
||||
let promptBody: any
|
||||
|
||||
@@ -362,14 +344,11 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_image_data_test" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
|
||||
@@ -419,14 +398,11 @@ describe("look-at tool", () => {
|
||||
session: {
|
||||
get: async () => ({ data: { directory: "/project" } }),
|
||||
create: async () => ({ data: { id: "ses_raw_base64_test" } }),
|
||||
prompt: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
promptAsync: async (input: any) => {
|
||||
promptBody = input.body
|
||||
return { data: {} }
|
||||
},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({
|
||||
data: [
|
||||
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
|
||||
|
||||
@@ -3,7 +3,8 @@ import { pathToFileURL } from "node:url"
|
||||
import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log, promptSyncWithModelSuggestionRetry } from "../../shared"
|
||||
import { log, promptWithModelSuggestionRetry } from "../../shared"
|
||||
import { pollSessionUntilIdle } from "./session-poller"
|
||||
import { extractLatestAssistantText } from "./assistant-message-extractor"
|
||||
import type { LookAtArgsWithAlias } from "./look-at-arguments"
|
||||
import { normalizeArgs, validateArgs } from "./look-at-arguments"
|
||||
@@ -105,9 +106,9 @@ Original error: ${createResult.error}`
|
||||
|
||||
const { agentModel, agentVariant } = await resolveMultimodalLookerAgentMetadata(ctx)
|
||||
|
||||
log(`[look_at] Sending prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
log(`[look_at] Sending async prompt with ${isBase64Input ? "base64 image" : "file"} to session ${sessionID}`)
|
||||
try {
|
||||
await promptSyncWithModelSuggestionRetry(ctx.client, {
|
||||
await promptWithModelSuggestionRetry(ctx.client, {
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: MULTIMODAL_LOOKER_AGENT,
|
||||
@@ -126,7 +127,15 @@ Original error: ${createResult.error}`
|
||||
},
|
||||
})
|
||||
} catch (promptError) {
|
||||
log(`[look_at] Prompt error (ignored, will still fetch messages):`, promptError)
|
||||
log(`[look_at] promptAsync error:`, promptError)
|
||||
return `Error: Failed to send prompt to multimodal-looker agent: ${promptError instanceof Error ? promptError.message : String(promptError)}`
|
||||
}
|
||||
|
||||
log(`[look_at] Polling session ${sessionID} until idle...`)
|
||||
try {
|
||||
await pollSessionUntilIdle(ctx.client, sessionID, { pollIntervalMs: 500, timeoutMs: 120_000 })
|
||||
} catch (pollError) {
|
||||
log(`[look_at] Polling error (will still try to fetch messages):`, pollError)
|
||||
}
|
||||
|
||||
log(`[look_at] Fetching messages from session ${sessionID}...`)
|
||||
|
||||
Reference in New Issue
Block a user