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:
YeonGyu-Kim
2026-02-10 19:09:22 +09:00
parent 5759e28eba
commit 47595f21a6
17 changed files with 1397 additions and 163 deletions
@@ -0,0 +1,123 @@
const { describe, test, expect, mock } = require("bun:test")
describe("sendSyncPrompt", () => {
test("applies agent tool restrictions for explore agent", async () => {
//#given
const mockPromptWithModelSuggestionRetry = mock(async () => {})
mock.module("../../shared/model-suggestion-retry", () => ({
promptWithModelSuggestionRetry: mockPromptWithModelSuggestionRetry,
}))
const { sendSyncPrompt } = require("./sync-prompt-sender")
const mockClient = {
session: {
prompt: mock(async () => ({ data: {} })),
},
}
const input = {
sessionID: "test-session",
agentToUse: "explore",
args: {
description: "test task",
prompt: "test prompt",
category: "quick",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: undefined,
toastManager: null,
taskId: undefined,
}
//#when
await sendSyncPrompt(mockClient as any, input)
//#then
expect(mockPromptWithModelSuggestionRetry).toHaveBeenCalled()
const callArgs = mockPromptWithModelSuggestionRetry.mock.calls[0][1]
expect(callArgs.body.tools.call_omo_agent).toBe(false)
})
test("applies agent tool restrictions for librarian agent", async () => {
//#given
const mockPromptWithModelSuggestionRetry = mock(async () => {})
mock.module("../../shared/model-suggestion-retry", () => ({
promptWithModelSuggestionRetry: mockPromptWithModelSuggestionRetry,
}))
const { sendSyncPrompt } = require("./sync-prompt-sender")
const mockClient = {
session: {
prompt: mock(async () => ({ data: {} })),
},
}
const input = {
sessionID: "test-session",
agentToUse: "librarian",
args: {
description: "test task",
prompt: "test prompt",
category: "quick",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: undefined,
toastManager: null,
taskId: undefined,
}
//#when
await sendSyncPrompt(mockClient as any, input)
//#then
expect(mockPromptWithModelSuggestionRetry).toHaveBeenCalled()
const callArgs = mockPromptWithModelSuggestionRetry.mock.calls[0][1]
expect(callArgs.body.tools.call_omo_agent).toBe(false)
})
test("does not restrict call_omo_agent for sisyphus agent", async () => {
//#given
const mockPromptWithModelSuggestionRetry = mock(async () => {})
mock.module("../../shared/model-suggestion-retry", () => ({
promptWithModelSuggestionRetry: mockPromptWithModelSuggestionRetry,
}))
const { sendSyncPrompt } = require("./sync-prompt-sender")
const mockClient = {
session: {
prompt: mock(async () => ({ data: {} })),
},
}
const input = {
sessionID: "test-session",
agentToUse: "sisyphus",
args: {
description: "test task",
prompt: "test prompt",
category: "quick",
run_in_background: false,
load_skills: [],
},
systemContent: undefined,
categoryModel: undefined,
toastManager: null,
taskId: undefined,
}
//#when
await sendSyncPrompt(mockClient as any, input)
//#then
expect(mockPromptWithModelSuggestionRetry).toHaveBeenCalled()
const callArgs = mockPromptWithModelSuggestionRetry.mock.calls[0][1]
expect(callArgs.body.tools.call_omo_agent).toBe(true)
})
})
@@ -2,6 +2,7 @@ import type { DelegateTaskArgs, OpencodeClient } from "./types"
import { isPlanFamily } from "./constants"
import { promptWithModelSuggestionRetry } from "../../shared/model-suggestion-retry"
import { formatDetailedError } from "./error-formatting"
import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions"
export async function sendSyncPrompt(
client: OpencodeClient,
@@ -26,6 +27,7 @@ export async function sendSyncPrompt(
task: allowTask,
call_omo_agent: true,
question: false,
...getAgentToolRestrictions(input.agentToUse),
},
parts: [{ type: "text", text: input.args.prompt }],
...(input.categoryModel ? { model: { providerID: input.categoryModel.providerID, modelID: input.categoryModel.modelID } } : {}),
@@ -1,5 +1,4 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach } = require("bun:test")
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { __setTimingConfig, __resetTimingConfig } from "./timing"
function createMockCtx(aborted = false) {
@@ -8,6 +7,7 @@ function createMockCtx(aborted = false) {
return {
sessionID: "parent-session",
messageID: "parent-message",
agent: "test-agent",
abort: controller.signal,
}
}
@@ -39,15 +39,12 @@ describe("pollSyncSession", () => {
data: [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "stop" },
parts: [{ type: "text", text: "Done" }],
},
],
}),
status: async () => {
pollCount++
return { data: { "ses_test": { type: "idle" } } }
},
status: async () => ({ data: { "ses_test": { type: "idle" } } }),
},
}
@@ -247,7 +244,7 @@ describe("pollSyncSession", () => {
})
describe("timeout handling", () => {
test("returns null on timeout (graceful)", async () => {
test("returns error string on timeout", async () => {
//#given - never returns a terminal finish, but timeout is very short
const { pollSyncSession } = require("./sync-session-poller")
@@ -255,7 +252,7 @@ describe("pollSyncSession", () => {
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 50,
MAX_POLL_TIME_MS: 0,
})
const mockClient = {
@@ -277,8 +274,8 @@ describe("pollSyncSession", () => {
taskId: undefined,
})
//#then - timeout returns null (not an error, result is fetched separately)
expect(result).toBeNull()
//#then - timeout returns error string
expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout")
})
})
@@ -327,19 +324,111 @@ describe("pollSyncSession", () => {
})
})
describe("isSessionComplete edge cases", () => {
const { isSessionComplete } = require("./sync-session-poller")
describe("isSessionComplete edge cases", () => {
test("returns false when messages array is empty", () => {
const { isSessionComplete } = require("./sync-session-poller")
test("returns false when messages array is empty", () => {
//#given - empty messages array
const messages: any[] = []
//#given - empty messages array
const messages: any[] = []
//#when
const result = isSessionComplete(messages)
//#when
const result = isSessionComplete(messages)
//#then - should return false
expect(result).toBe(false)
})
//#then - should return false
expect(result).toBe(false)
})
test("returns false when no assistant message exists", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - only user messages, no assistant
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{ info: { id: "msg_002", role: "user", time: { created: 2000 } } },
]
//#when
const result = isSessionComplete(messages)
//#then - should return false
expect(result).toBe(false)
})
test("returns false when only assistant message exists (no user)", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - only assistant message, no user message
const messages = [
{
info: { id: "msg_001", role: "assistant", time: { created: 1000 }, finish: "end_turn" },
parts: [{ type: "text", text: "Response" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false (no user message to compare IDs)
expect(result).toBe(false)
})
test("returns false when assistant message has missing finish field", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - assistant message without finish field
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 } },
parts: [{ type: "text", text: "Response" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false (missing finish)
expect(result).toBe(false)
})
test("returns false when assistant message has missing info.id field", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - assistant message without id in info
const messages = [
{ info: { id: "msg_001", role: "user", time: { created: 1000 } } },
{
info: { role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "text", text: "Response" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false (missing assistant id)
expect(result).toBe(false)
})
test("returns false when user message has missing info.id field", () => {
const { isSessionComplete } = require("./sync-session-poller")
//#given - user message without id in info
const messages = [
{ info: { role: "user", time: { created: 1000 } } },
{
info: { id: "msg_002", role: "assistant", time: { created: 2000 }, finish: "end_turn" },
parts: [{ type: "text", text: "Response" }],
},
]
//#when
const result = isSessionComplete(messages)
//#then - should return false (missing user id)
expect(result).toBe(false)
})
})
test("returns false when no assistant message exists", () => {
//#given - only user messages, no assistant
@@ -36,6 +36,7 @@ export async function pollSyncSession(
const syncTiming = getTimingConfig()
const pollStart = Date.now()
let pollCount = 0
let timedOut = false
log("[task] Starting poll loop", { sessionID: input.sessionID, agentToUse: input.agentToUse })
@@ -93,8 +94,9 @@ export async function pollSyncSession(
}
if (Date.now() - pollStart >= syncTiming.MAX_POLL_TIME_MS) {
timedOut = true
log("[task] Poll timeout reached", { sessionID: input.sessionID, pollCount })
}
return null
return timedOut ? `Poll timeout reached after ${syncTiming.MAX_POLL_TIME_MS}ms for session ${input.sessionID}` : null
}
+217
View File
@@ -0,0 +1,217 @@
const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test")
describe("executeSyncTask - cleanup on error paths", () => {
let removeTaskCalls: string[] = []
let addTaskCalls: any[] = []
let deleteCalls: string[] = []
let addCalls: string[] = []
let resetToastManager: (() => void) | null = null
beforeEach(() => {
//#given - configure fast timing for all tests
const { __setTimingConfig } = require("./timing")
__setTimingConfig({
POLL_INTERVAL_MS: 10,
MIN_STABILITY_TIME_MS: 0,
STABILITY_POLLS_REQUIRED: 1,
MAX_POLL_TIME_MS: 100,
})
//#given - reset call tracking
removeTaskCalls = []
addTaskCalls = []
deleteCalls = []
addCalls = []
//#given - initialize real task toast manager (avoid global module mocks)
const { initTaskToastManager, _resetTaskToastManagerForTesting } = require("../../features/task-toast-manager/manager")
_resetTaskToastManagerForTesting()
resetToastManager = _resetTaskToastManagerForTesting
const toastManager = initTaskToastManager({
tui: { showToast: mock(() => Promise.resolve()) },
})
spyOn(toastManager, "addTask").mockImplementation((task: any) => {
addTaskCalls.push(task)
})
spyOn(toastManager, "removeTask").mockImplementation((id: string) => {
removeTaskCalls.push(id)
})
//#given - mock subagentSessions
const { subagentSessions } = require("../../features/claude-code-session-state")
spyOn(subagentSessions, "add").mockImplementation((id: string) => {
addCalls.push(id)
})
spyOn(subagentSessions, "delete").mockImplementation((id: string) => {
deleteCalls.push(id)
})
//#given - mock other dependencies
mock.module("./sync-session-creator.ts", () => ({
createSyncSession: async () => ({ ok: true, sessionID: "ses_test_12345678" }),
}))
mock.module("./sync-prompt-sender.ts", () => ({
sendSyncPrompt: async () => null,
}))
mock.module("./sync-session-poller.ts", () => ({
pollSyncSession: async () => null,
}))
mock.module("./sync-result-fetcher.ts", () => ({
fetchSyncResult: async () => ({ ok: true, textContent: "Result" }),
}))
})
afterEach(() => {
//#given - reset timing after each test
const { __resetTimingConfig } = require("./timing")
__resetTimingConfig()
mock.restore()
resetToastManager?.()
resetToastManager = null
})
test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => {
//#given - mock fetchSyncResult to return error
mock.module("./sync-result-fetcher.ts", () => ({
fetchSyncResult: async () => ({ ok: false, error: "Fetch failed" }),
}))
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask with fetchSyncResult failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined)
//#then - should return error and cleanup resources
expect(result).toBe("Fetch failed")
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("cleans up toast and subagentSessions when pollSyncSession returns error", async () => {
//#given - mock pollSyncSession to return error
mock.module("./sync-session-poller.ts", () => ({
pollSyncSession: async () => "Poll error",
}))
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask with pollSyncSession failing
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined)
//#then - should return error and cleanup resources
expect(result).toBe("Poll error")
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
test("cleans up toast and subagentSessions on successful completion", async () => {
const mockClient = {
session: {
create: async () => ({ data: { id: "ses_test_12345678" } }),
},
}
const { executeSyncTask } = require("./sync-task")
const mockCtx = {
sessionID: "parent-session",
callID: "call-123",
metadata: () => {},
}
const mockExecutorCtx = {
client: mockClient,
directory: "/tmp",
onSyncSessionCreated: null,
}
const args = {
prompt: "test prompt",
description: "test task",
category: "test",
load_skills: [],
run_in_background: false,
command: null,
}
//#when - executeSyncTask completes successfully
const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, {
sessionID: "parent-session",
}, "test-agent", undefined, undefined)
//#then - should complete and cleanup resources
expect(result).toContain("Task completed")
expect(removeTaskCalls.length).toBe(1)
expect(removeTaskCalls[0]).toBe("sync_ses_test")
expect(deleteCalls.length).toBe(1)
expect(deleteCalls[0]).toBe("ses_test_12345678")
})
})
+24 -27
View File
@@ -102,30 +102,25 @@ export async function executeSyncTask(
return promptError
}
const pollError = await pollSyncSession(ctx, client, {
sessionID,
agentToUse,
toastManager,
taskId,
})
if (pollError) {
return pollError
}
try {
const pollError = await pollSyncSession(ctx, client, {
sessionID,
agentToUse,
toastManager,
taskId,
})
if (pollError) {
return pollError
}
const result = await fetchSyncResult(client, sessionID)
if (!result.ok) {
return result.error
}
const result = await fetchSyncResult(client, sessionID)
if (!result.ok) {
return result.error
}
const duration = formatDuration(startTime)
const duration = formatDuration(startTime)
if (toastManager) {
toastManager.removeTask(taskId)
}
subagentSessions.delete(sessionID)
return `Task completed in ${duration}.
return `Task completed in ${duration}.
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
@@ -136,13 +131,15 @@ ${result.textContent || "(No text output)"}
<task_metadata>
session_id: ${sessionID}
</task_metadata>`
} finally {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
}
}
} catch (error) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
if (syncSessionID) {
subagentSessions.delete(syncSessionID)
}
return formatDetailedError(error, {
operation: "Execute task",
args,
+105
View File
@@ -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)
})
})
+42
View File
@@ -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`)
}
+66 -90
View File
@@ -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" }] },
+13 -4
View File
@@ -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}...`)