Merge pull request #1620 from potb/acp-json-error

fix: switch session.prompt() to promptAsync() — delegate broken in ACP
This commit is contained in:
YeonGyu-Kim 2026-02-07 22:52:39 +09:00 committed by GitHub
commit 18c161a9cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1465 additions and 1361 deletions

View File

@ -171,6 +171,7 @@ function createBackgroundManager(): BackgroundManager {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}), abort: async () => ({}),
}, },
} }
@ -880,12 +881,14 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should skip notification when parent session is aborted", async () => { test("should skip notification when parent session is aborted", async () => {
//#given //#given
let promptCalled = false let promptCalled = false
const promptMock = async () => {
promptCalled = true
return {}
}
const client = { const client = {
session: { session: {
prompt: async () => { prompt: promptMock,
promptCalled = true promptAsync: promptMock,
return {}
},
abort: async () => ({}), abort: async () => ({}),
messages: async () => { messages: async () => {
const error = new Error("User aborted") const error = new Error("User aborted")
@ -922,14 +925,16 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should swallow aborted error from prompt", async () => { test("should swallow aborted error from prompt", async () => {
//#given //#given
let promptCalled = false let promptCalled = false
const promptMock = async () => {
promptCalled = true
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
}
const client = { const client = {
session: { session: {
prompt: async () => { prompt: promptMock,
promptCalled = true promptAsync: promptMock,
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
abort: async () => ({}), abort: async () => ({}),
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
@ -1054,19 +1059,20 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0) expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
}) })
test("should abort session on completion", async () => { test("should abort session on completion", async () => {
// #given // #given
const abortedSessionIDs: string[] = [] const abortedSessionIDs: string[] = []
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async (args: { path: { id: string } }) => { promptAsync: async () => ({}),
abortedSessionIDs.push(args.path.id) abort: async (args: { path: { id: string } }) => {
return {} abortedSessionIDs.push(args.path.id)
}, return {}
messages: async () => ({ data: [] }), },
}, messages: async () => ({ data: [] }),
} },
}
manager.shutdown() manager.shutdown()
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager) stubNotifyParentSession(manager)
@ -1196,24 +1202,26 @@ describe("BackgroundManager.resume concurrency key", () => {
}) })
describe("BackgroundManager.resume model persistence", () => { describe("BackgroundManager.resume model persistence", () => {
let manager: BackgroundManager let manager: BackgroundManager
let promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }> let promptCalls: Array<{ path: { id: string }; body: Record<string, unknown> }>
beforeEach(() => { beforeEach(() => {
// given // given
promptCalls = [] promptCalls = []
const client = { const promptMock = async (args: { path: { id: string }; body: Record<string, unknown> }) => {
session: { promptCalls.push(args)
prompt: async (args: { path: { id: string }; body: Record<string, unknown> }) => { return {}
promptCalls.push(args) }
return {} const client = {
}, session: {
abort: async () => ({}), prompt: promptMock,
}, promptAsync: promptMock,
} abort: async () => ({}),
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) },
stubNotifyParentSession(manager) }
}) manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager)
})
afterEach(() => { afterEach(() => {
manager.shutdown() manager.shutdown()
@ -1311,19 +1319,20 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
let manager: BackgroundManager let manager: BackgroundManager
let mockClient: ReturnType<typeof createMockClient> let mockClient: ReturnType<typeof createMockClient>
function createMockClient() { function createMockClient() {
return { return {
session: { session: {
create: async () => ({ data: { id: `ses_${crypto.randomUUID()}` } }), create: async () => ({ data: { id: `ses_${crypto.randomUUID()}` } }),
get: async () => ({ data: { directory: "/test/dir" } }), get: async () => ({ data: { directory: "/test/dir" } }),
prompt: async () => ({}), prompt: async () => ({}),
messages: async () => ({ data: [] }), promptAsync: async () => ({}),
todo: async () => ({ data: [] }), messages: async () => ({ data: [] }),
status: async () => ({ data: {} }), todo: async () => ({ data: [] }),
abort: async () => ({}), status: async () => ({ data: {} }),
}, abort: async () => ({}),
} },
} }
}
beforeEach(() => { beforeEach(() => {
// given // given
@ -1871,13 +1880,14 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
}) })
describe("BackgroundManager.checkAndInterruptStaleTasks", () => { describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => { test("should NOT interrupt task running less than 30 seconds (min runtime guard)", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = { const task: BackgroundTask = {
@ -1903,12 +1913,13 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running") expect(task.status).toBe("running")
}) })
test("should NOT interrupt task with recent lastUpdate", async () => { test("should NOT interrupt task with recent lastUpdate", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
},
} }
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@ -1935,11 +1946,12 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running") expect(task.status).toBe("running")
}) })
test("should interrupt task with stale lastUpdate (> 3min)", async () => { test("should interrupt task with stale lastUpdate (> 3min)", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
abort: async () => ({}),
}, },
} }
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@ -1971,10 +1983,11 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.completedAt).toBeDefined() expect(task.completedAt).toBeDefined()
}) })
test("should respect custom staleTimeoutMs config", async () => { test("should respect custom staleTimeoutMs config", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}), abort: async () => ({}),
}, },
} }
@ -2005,13 +2018,14 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.error).toContain("Stale timeout") expect(task.error).toContain("Stale timeout")
}) })
test("should release concurrency before abort", async () => { test("should release concurrency before abort", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager) stubNotifyParentSession(manager)
@ -2040,13 +2054,14 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("cancelled") expect(task.status).toBe("cancelled")
}) })
test("should handle multiple stale tasks in same poll cycle", async () => { test("should handle multiple stale tasks in same poll cycle", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager) stubNotifyParentSession(manager)
@ -2091,13 +2106,14 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task2.status).toBe("cancelled") expect(task2.status).toBe("cancelled")
}) })
test("should use default timeout when config not provided", async () => { test("should use default timeout when config not provided", async () => {
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager) stubNotifyParentSession(manager)
@ -2126,18 +2142,19 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
}) })
describe("BackgroundManager.shutdown session abort", () => { describe("BackgroundManager.shutdown session abort", () => {
test("should call session.abort for all running tasks during shutdown", () => { test("should call session.abort for all running tasks during shutdown", () => {
// given // given
const abortedSessionIDs: string[] = [] const abortedSessionIDs: string[] = []
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async (args: { path: { id: string } }) => { promptAsync: async () => ({}),
abortedSessionIDs.push(args.path.id) abort: async (args: { path: { id: string } }) => {
return {} abortedSessionIDs.push(args.path.id)
}, return {}
}, },
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task1: BackgroundTask = { const task1: BackgroundTask = {
@ -2175,18 +2192,19 @@ describe("BackgroundManager.shutdown session abort", () => {
expect(abortedSessionIDs).toHaveLength(2) expect(abortedSessionIDs).toHaveLength(2)
}) })
test("should not call session.abort for completed or cancelled tasks", () => { test("should not call session.abort for completed or cancelled tasks", () => {
// given // given
const abortedSessionIDs: string[] = [] const abortedSessionIDs: string[] = []
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async (args: { path: { id: string } }) => { promptAsync: async () => ({}),
abortedSessionIDs.push(args.path.id) abort: async (args: { path: { id: string } }) => {
return {} abortedSessionIDs.push(args.path.id)
}, return {}
}, },
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const completedTask: BackgroundTask = { const completedTask: BackgroundTask = {
@ -2235,15 +2253,16 @@ describe("BackgroundManager.shutdown session abort", () => {
expect(abortedSessionIDs).toHaveLength(0) expect(abortedSessionIDs).toHaveLength(0)
}) })
test("should call onShutdown callback during shutdown", () => { test("should call onShutdown callback during shutdown", () => {
// given // given
let shutdownCalled = false let shutdownCalled = false
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager( const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput, { client, directory: tmpdir() } as unknown as PluginInput,
undefined, undefined,
@ -2261,14 +2280,15 @@ describe("BackgroundManager.shutdown session abort", () => {
expect(shutdownCalled).toBe(true) expect(shutdownCalled).toBe(true)
}) })
test("should not throw when onShutdown callback throws", () => { test("should not throw when onShutdown callback throws", () => {
// given // given
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
}, abort: async () => ({}),
} },
}
const manager = new BackgroundManager( const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput, { client, directory: tmpdir() } as unknown as PluginInput,
undefined, undefined,
@ -2509,19 +2529,20 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
const realDateNow = Date.now const realDateNow = Date.now
const baseNow = realDateNow() const baseNow = realDateNow()
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
messages: async (args: { path: { id: string } }) => { abort: async () => ({}),
messagesCalls.push(args.path.id) messages: async (args: { path: { id: string } }) => {
return { messagesCalls.push(args.path.id)
data: [ return {
{ data: [
info: { role: "assistant" }, {
parts: [{ type: "text", text: "ok" }], info: { role: "assistant" },
}, parts: [{ type: "text", text: "ok" }],
], },
],
} }
}, },
todo: async () => ({ data: [] }), todo: async () => ({ data: [] }),
@ -2566,23 +2587,24 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
}) })
test("should not defer when session.idle fires after MIN_IDLE_TIME_MS", async () => { test("should not defer when session.idle fires after MIN_IDLE_TIME_MS", async () => {
//#given - a running task started more than MIN_IDLE_TIME_MS ago //#given - a running task started more than MIN_IDLE_TIME_MS ago
const sessionID = "session-late-idle" const sessionID = "session-late-idle"
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
messages: async () => ({ abort: async () => ({}),
data: [ messages: async () => ({
{ data: [
info: { role: "assistant" }, {
parts: [{ type: "text", text: "ok" }], info: { role: "assistant" },
}, parts: [{ type: "text", text: "ok" }],
], },
}), ],
todo: async () => ({ data: [] }), }),
}, todo: async () => ({ data: [] }),
} },
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
stubNotifyParentSession(manager) stubNotifyParentSession(manager)
@ -2618,20 +2640,21 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
const realDateNow = Date.now const realDateNow = Date.now
const baseNow = realDateNow() const baseNow = realDateNow()
const client = { const client = {
session: { session: {
prompt: async () => ({}), prompt: async () => ({}),
abort: async () => ({}), promptAsync: async () => ({}),
messages: async () => { abort: async () => ({}),
messagesCallCount += 1 messages: async () => {
return { messagesCallCount += 1
data: [ return {
{ data: [
info: { role: "assistant" }, {
parts: [{ type: "text", text: "ok" }], info: { role: "assistant" },
}, parts: [{ type: "text", text: "ok" }],
], },
} ],
}
}, },
todo: async () => ({ data: [] }), todo: async () => ({ data: [] }),
}, },

View File

@ -310,7 +310,7 @@ export class BackgroundManager {
promptLength: input.prompt.length, promptLength: input.prompt.length,
}) })
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget) // Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if caller provided one (e.g., from Sisyphus category configs) // Include model if caller provided one (e.g., from Sisyphus category configs)
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model // IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" } // OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
@ -571,7 +571,7 @@ export class BackgroundManager {
promptLength: input.prompt.length, promptLength: input.prompt.length,
}) })
// Use prompt() instead of promptAsync() to properly initialize agent loop // Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if task has one (preserved from original launch with category config) // Include model if task has one (preserved from original launch with category config)
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema) // variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
const resumeModel = existingTask.model const resumeModel = existingTask.model
@ -579,7 +579,7 @@ export class BackgroundManager {
: undefined : undefined
const resumeVariant = existingTask.model?.variant const resumeVariant = existingTask.model?.variant
this.client.session.prompt({ this.client.session.promptAsync({
path: { id: existingTask.sessionID }, path: { id: existingTask.sessionID },
body: { body: {
agent: existingTask.agent, agent: existingTask.agent,
@ -1198,7 +1198,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}) })
try { try {
await this.client.session.prompt({ await this.client.session.promptAsync({
path: { id: task.parentSessionID }, path: { id: task.parentSessionID },
body: { body: {
noReply: !allComplete, noReply: !allComplete,

View File

@ -240,7 +240,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}) })
try { try {
await client.session.prompt({ await client.session.promptAsync({
path: { id: task.parentSessionID }, path: { id: task.parentSessionID },
body: { body: {
noReply: !allComplete, noReply: !allComplete,

View File

@ -221,7 +221,7 @@ export async function resumeTask(
: undefined : undefined
const resumeVariant = task.model?.variant const resumeVariant = task.model?.variant
client.session.prompt({ client.session.promptAsync({
path: { id: task.sessionID }, path: { id: task.sessionID },
body: { body: {
agent: task.agent, agent: task.agent,

View File

@ -34,6 +34,7 @@ describe("atlas hook", () => {
client: { client: {
session: { session: {
prompt: promptMock, prompt: promptMock,
promptAsync: promptMock,
}, },
}, },
_promptMock: promptMock, _promptMock: promptMock,

View File

@ -484,7 +484,7 @@ export function createAtlasHook(
: undefined : undefined
} }
await ctx.client.session.prompt({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: agent ?? "atlas", agent: agent ?? "atlas",

View File

@ -364,7 +364,7 @@ export function createRalphLoopHook(
: undefined : undefined
} }
await ctx.client.session.prompt({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
...(agent !== undefined ? { agent } : {}), ...(agent !== undefined ? { agent } : {}),

View File

@ -75,7 +75,7 @@ function extractResumeConfig(userMessage: MessageData | undefined, sessionID: st
async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> { async function resumeSession(client: Client, config: ResumeConfig): Promise<boolean> {
try { try {
await client.session.prompt({ await client.session.promptAsync({
path: { id: config.sessionID }, path: { id: config.sessionID },
body: { body: {
parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }], parts: [{ type: "text", text: RECOVERY_RESUME_TEXT }],
@ -185,7 +185,7 @@ async function recoverToolResultMissing(
})) }))
try { try {
await client.session.prompt({ await client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
// @ts-expect-error - SDK types may not include tool_result parts // @ts-expect-error - SDK types may not include tool_result parts
body: { parts: toolResultParts }, body: { parts: toolResultParts },

View File

@ -152,6 +152,15 @@ describe("todo-continuation-enforcer", () => {
}) })
return {} return {}
}, },
promptAsync: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
}, },
tui: { tui: {
showToast: async (opts: any) => { showToast: async (opts: any) => {
@ -977,32 +986,41 @@ describe("todo-continuation-enforcer", () => {
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}), }),
messages: async () => ({ data: mockMessagesWithAssistant }), messages: async () => ({ data: mockMessagesWithAssistant }),
prompt: async (opts: any) => { prompt: async (opts: any) => {
promptCalls.push({ promptCalls.push({
sessionID: opts.path.id, sessionID: opts.path.id,
agent: opts.body.agent, agent: opts.body.agent,
model: opts.body.model, model: opts.body.model,
text: opts.body.parts[0].text, text: opts.body.parts[0].text,
}) })
return {} return {}
}, },
}, promptAsync: async (opts: any) => {
tui: { showToast: async () => ({}) }, promptCalls.push({
}, sessionID: opts.path.id,
directory: "/tmp/test", agent: opts.body.agent,
} as any model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
}) })
// when - session goes idle // when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500) await fakeTimers.advanceBy(2500)
// then - model should be extracted from assistant message's flat modelID/providerID // then - model should be extracted from assistant message's flat modelID/providerID
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
}) })
// ============================================================ // ============================================================
@ -1028,32 +1046,41 @@ describe("todo-continuation-enforcer", () => {
todo: async () => ({ todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}), }),
messages: async () => ({ data: mockMessagesWithCompaction }), messages: async () => ({ data: mockMessagesWithCompaction }),
prompt: async (opts: any) => { prompt: async (opts: any) => {
promptCalls.push({ promptCalls.push({
sessionID: opts.path.id, sessionID: opts.path.id,
agent: opts.body.agent, agent: opts.body.agent,
model: opts.body.model, model: opts.body.model,
text: opts.body.parts[0].text, text: opts.body.parts[0].text,
}) })
return {} return {}
}, },
}, promptAsync: async (opts: any) => {
tui: { showToast: async () => ({}) }, promptCalls.push({
}, sessionID: opts.path.id,
directory: "/tmp/test", agent: opts.body.agent,
} as any model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false), backgroundManager: createMockBackgroundManager(false),
}) })
// when - session goes idle // when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } }) await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await fakeTimers.advanceBy(2500) await fakeTimers.advanceBy(2500)
// then - continuation uses Sisyphus (skipped compaction agent) // then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("sisyphus") expect(promptCalls[0].agent).toBe("sisyphus")
}) })
@ -1072,32 +1099,41 @@ describe("todo-continuation-enforcer", () => {
todo: async () => ({ todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}), }),
messages: async () => ({ data: mockMessagesOnlyCompaction }), messages: async () => ({ data: mockMessagesOnlyCompaction }),
prompt: async (opts: any) => { prompt: async (opts: any) => {
promptCalls.push({ promptCalls.push({
sessionID: opts.path.id, sessionID: opts.path.id,
agent: opts.body.agent, agent: opts.body.agent,
model: opts.body.model, model: opts.body.model,
text: opts.body.parts[0].text, text: opts.body.parts[0].text,
}) })
return {} return {}
}, },
}, promptAsync: async (opts: any) => {
tui: { showToast: async () => ({}) }, promptCalls.push({
}, sessionID: opts.path.id,
directory: "/tmp/test", agent: opts.body.agent,
} as any model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {}) const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle // when - session goes idle
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await fakeTimers.advanceBy(3000) await fakeTimers.advanceBy(3000)
// then - no continuation (compaction is in default skipAgents) // then - no continuation (compaction is in default skipAgents)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
}) })
@ -1118,32 +1154,41 @@ describe("todo-continuation-enforcer", () => {
todo: async () => ({ todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}), }),
messages: async () => ({ data: mockMessagesPrometheusCompacted }), messages: async () => ({ data: mockMessagesPrometheusCompacted }),
prompt: async (opts: any) => { prompt: async (opts: any) => {
promptCalls.push({ promptCalls.push({
sessionID: opts.path.id, sessionID: opts.path.id,
agent: opts.body.agent, agent: opts.body.agent,
model: opts.body.model, model: opts.body.model,
text: opts.body.parts[0].text, text: opts.body.parts[0].text,
}) })
return {} return {}
}, },
}, promptAsync: async (opts: any) => {
tui: { showToast: async () => ({}) }, promptCalls.push({
}, sessionID: opts.path.id,
directory: "/tmp/test", agent: opts.body.agent,
} as any model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {}) const hook = createTodoContinuationEnforcer(mockInput, {})
// when - session goes idle // when - session goes idle
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await fakeTimers.advanceBy(3000) await fakeTimers.advanceBy(3000)
// then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents) // then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
}) })
@ -1164,32 +1209,41 @@ describe("todo-continuation-enforcer", () => {
todo: async () => ({ todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }], data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}), }),
messages: async () => ({ data: mockMessagesNoAgent }), messages: async () => ({ data: mockMessagesNoAgent }),
prompt: async (opts: any) => { prompt: async (opts: any) => {
promptCalls.push({ promptCalls.push({
sessionID: opts.path.id, sessionID: opts.path.id,
agent: opts.body.agent, agent: opts.body.agent,
model: opts.body.model, model: opts.body.model,
text: opts.body.parts[0].text, text: opts.body.parts[0].text,
}) })
return {} return {}
}, },
}, promptAsync: async (opts: any) => {
tui: { showToast: async () => ({}) }, promptCalls.push({
}, sessionID: opts.path.id,
directory: "/tmp/test", agent: opts.body.agent,
} as any model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, { const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: [], skipAgents: [],
}) })
// when - session goes idle // when - session goes idle
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await wait(2500) await wait(2500)
// then - continuation injected (no agents to skip) // then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)

View File

@ -245,7 +245,7 @@ ${todoList}`
try { try {
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount }) log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
await ctx.client.session.prompt({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: agentName, agent: agentName,

View File

@ -25,6 +25,15 @@ type BabysitterContext = {
} }
query?: { directory?: string } query?: { directory?: string }
}) => Promise<unknown> }) => Promise<unknown>
promptAsync: (args: {
path: { id: string }
body: {
parts: Array<{ type: "text"; text: string }>
agent?: string
model?: { providerID: string; modelID: string }
}
query?: { directory?: string }
}) => Promise<unknown>
} }
} }
} }
@ -218,7 +227,7 @@ export function createUnstableAgentBabysitterHook(ctx: BabysitterContext, option
const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID) const { agent, model } = await resolveMainSessionTarget(ctx, mainSessionID)
try { try {
await ctx.client.session.prompt({ await ctx.client.session.promptAsync({
path: { id: mainSessionID }, path: { id: mainSessionID },
body: { body: {
...(agent ? { agent } : {}), ...(agent ? { agent } : {}),

View File

@ -387,10 +387,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
} }
return []; return [];
}, },
prompt: async (args) => { prompt: async (args) => {
await ctx.client.session.prompt(args); await ctx.client.session.promptAsync(args);
}, },
}, promptAsync: async (args) => {
await ctx.client.session.promptAsync(args);
},
},
}, },
}, },
{ {

View File

@ -212,9 +212,9 @@ describe("parseModelSuggestion", () => {
describe("promptWithModelSuggestionRetry", () => { describe("promptWithModelSuggestionRetry", () => {
it("should succeed on first try without retry", async () => { it("should succeed on first try without retry", async () => {
// given a client where prompt succeeds // given a client where promptAsync succeeds
const promptMock = mock(() => Promise.resolve()) const promptMock = mock(() => Promise.resolve())
const client = { session: { prompt: promptMock } } const client = { session: { promptAsync: promptMock } }
// when calling promptWithModelSuggestionRetry // when calling promptWithModelSuggestionRetry
await promptWithModelSuggestionRetry(client as any, { await promptWithModelSuggestionRetry(client as any, {
@ -225,48 +225,44 @@ describe("promptWithModelSuggestionRetry", () => {
}, },
}) })
// then should call prompt exactly once // then should call promptAsync exactly once
expect(promptMock).toHaveBeenCalledTimes(1) expect(promptMock).toHaveBeenCalledTimes(1)
}) })
it("should retry with suggestion on model-not-found error", async () => { it("should throw error from promptAsync directly on model-not-found error", async () => {
// given a client that fails first with model-not-found, then succeeds // given a client that fails with model-not-found error
const promptMock = mock() const promptMock = mock().mockRejectedValueOnce({
.mockRejectedValueOnce({ name: "ProviderModelNotFoundError",
name: "ProviderModelNotFoundError", data: {
data: { providerID: "anthropic",
providerID: "anthropic", modelID: "claude-sonet-4",
modelID: "claude-sonet-4", suggestions: ["claude-sonnet-4"],
suggestions: ["claude-sonnet-4"],
},
})
.mockResolvedValueOnce(undefined)
const client = { session: { prompt: promptMock } }
// when calling promptWithModelSuggestionRetry
await promptWithModelSuggestionRetry(client as any, {
path: { id: "session-1" },
body: {
agent: "explore",
parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonet-4" },
}, },
}) })
const client = { session: { promptAsync: promptMock } }
// then should call prompt twice - first with original, then with suggestion // when calling promptWithModelSuggestionRetry
expect(promptMock).toHaveBeenCalledTimes(2) // then should throw the error without retrying
const retryCall = promptMock.mock.calls[1][0] await expect(
expect(retryCall.body.model).toEqual({ promptWithModelSuggestionRetry(client as any, {
providerID: "anthropic", path: { id: "session-1" },
modelID: "claude-sonnet-4", body: {
}) agent: "explore",
parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonet-4" },
},
})
).rejects.toThrow()
// and should call promptAsync only once
expect(promptMock).toHaveBeenCalledTimes(1)
}) })
it("should throw original error when no suggestion available", async () => { it("should throw original error when no suggestion available", async () => {
// given a client that fails with a non-model-not-found error // given a client that fails with a non-model-not-found error
const originalError = new Error("Connection refused") const originalError = new Error("Connection refused")
const promptMock = mock().mockRejectedValueOnce(originalError) const promptMock = mock().mockRejectedValueOnce(originalError)
const client = { session: { prompt: promptMock } } const client = { session: { promptAsync: promptMock } }
// when calling promptWithModelSuggestionRetry // when calling promptWithModelSuggestionRetry
// then should throw the original error // then should throw the original error
@ -283,50 +279,32 @@ describe("promptWithModelSuggestionRetry", () => {
expect(promptMock).toHaveBeenCalledTimes(1) expect(promptMock).toHaveBeenCalledTimes(1)
}) })
it("should throw original error when retry also fails", async () => { it("should throw error from promptAsync directly", async () => {
// given a client that fails with model-not-found, retry also fails // given a client that fails with an error
const modelNotFoundError = { const error = new Error("Still not found")
name: "ProviderModelNotFoundError", const promptMock = mock().mockRejectedValueOnce(error)
data: { const client = { session: { promptAsync: promptMock } }
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4"],
},
}
const retryError = new Error("Still not found")
const promptMock = mock()
.mockRejectedValueOnce(modelNotFoundError)
.mockRejectedValueOnce(retryError)
const client = { session: { prompt: promptMock } }
// when calling promptWithModelSuggestionRetry // when calling promptWithModelSuggestionRetry
// then should throw the retry error (not the original) // then should throw the error
await expect( await expect(
promptWithModelSuggestionRetry(client as any, { promptWithModelSuggestionRetry(client as any, {
path: { id: "session-1" }, path: { id: "session-1" },
body: { body: {
parts: [{ type: "text", text: "hello" }], parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonet-4" }, model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
}, },
}) })
).rejects.toThrow("Still not found") ).rejects.toThrow("Still not found")
expect(promptMock).toHaveBeenCalledTimes(2) // and should call promptAsync only once
expect(promptMock).toHaveBeenCalledTimes(1)
}) })
it("should preserve other body fields during retry", async () => { it("should pass all body fields through to promptAsync", async () => {
// given a client that fails first with model-not-found // given a client where promptAsync succeeds
const promptMock = mock() const promptMock = mock().mockResolvedValueOnce(undefined)
.mockRejectedValueOnce({ const client = { session: { promptAsync: promptMock } }
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-sonet-4",
suggestions: ["claude-sonnet-4"],
},
})
.mockResolvedValueOnce(undefined)
const client = { session: { prompt: promptMock } }
// when calling with additional body fields // when calling with additional body fields
await promptWithModelSuggestionRetry(client as any, { await promptWithModelSuggestionRetry(client as any, {
@ -336,57 +314,56 @@ describe("promptWithModelSuggestionRetry", () => {
system: "You are a helpful agent", system: "You are a helpful agent",
tools: { task: false }, tools: { task: false },
parts: [{ type: "text", text: "hello" }], parts: [{ type: "text", text: "hello" }],
model: { providerID: "anthropic", modelID: "claude-sonet-4" }, model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
variant: "max", variant: "max",
}, },
}) })
// then retry call should preserve all fields except corrected model // then call should pass all fields through unchanged
const retryCall = promptMock.mock.calls[1][0] const call = promptMock.mock.calls[0][0]
expect(retryCall.body.agent).toBe("explore") expect(call.body.agent).toBe("explore")
expect(retryCall.body.system).toBe("You are a helpful agent") expect(call.body.system).toBe("You are a helpful agent")
expect(retryCall.body.tools).toEqual({ task: false }) expect(call.body.tools).toEqual({ task: false })
expect(retryCall.body.variant).toBe("max") expect(call.body.variant).toBe("max")
expect(retryCall.body.model).toEqual({ expect(call.body.model).toEqual({
providerID: "anthropic", providerID: "anthropic",
modelID: "claude-sonnet-4", modelID: "claude-sonnet-4",
}) })
}) })
it("should handle string error message with suggestion", async () => { it("should throw string error message from promptAsync", async () => {
// given a client that fails with a string error containing suggestion // given a client that fails with a string error
const promptMock = mock() const promptMock = mock().mockRejectedValueOnce(
.mockRejectedValueOnce( new Error("Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?")
new Error("Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?") )
) const client = { session: { promptAsync: promptMock } }
.mockResolvedValueOnce(undefined)
const client = { session: { prompt: promptMock } }
// when calling promptWithModelSuggestionRetry // when calling promptWithModelSuggestionRetry
await promptWithModelSuggestionRetry(client as any, { // then should throw the error
path: { id: "session-1" }, await expect(
body: { promptWithModelSuggestionRetry(client as any, {
parts: [{ type: "text", text: "hello" }], path: { id: "session-1" },
model: { providerID: "anthropic", modelID: "claude-sonet-4" }, body: {
}, parts: [{ type: "text", text: "hello" }],
}) model: { providerID: "anthropic", modelID: "claude-sonnet-4" },
},
})
).rejects.toThrow()
// then should retry with suggested model // and should call promptAsync only once
expect(promptMock).toHaveBeenCalledTimes(2) expect(promptMock).toHaveBeenCalledTimes(1)
const retryCall = promptMock.mock.calls[1][0]
expect(retryCall.body.model.modelID).toBe("claude-sonnet-4")
}) })
it("should not retry when no model in original request", async () => { it("should throw error when no model in original request", async () => {
// given a client that fails with model-not-found but original has no model param // given a client that fails with an error
const modelNotFoundError = new Error( const modelNotFoundError = new Error(
"Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?" "Model not found: anthropic/claude-sonet-4. Did you mean: claude-sonnet-4?"
) )
const promptMock = mock().mockRejectedValueOnce(modelNotFoundError) const promptMock = mock().mockRejectedValueOnce(modelNotFoundError)
const client = { session: { prompt: promptMock } } const client = { session: { promptAsync: promptMock } }
// when calling without model in body // when calling without model in body
// then should throw without retrying // then should throw the error
await expect( await expect(
promptWithModelSuggestionRetry(client as any, { promptWithModelSuggestionRetry(client as any, {
path: { id: "session-1" }, path: { id: "session-1" },
@ -396,6 +373,7 @@ describe("promptWithModelSuggestionRetry", () => {
}) })
).rejects.toThrow() ).rejects.toThrow()
// and should call promptAsync only once
expect(promptMock).toHaveBeenCalledTimes(1) expect(promptMock).toHaveBeenCalledTimes(1)
}) })
}) })

View File

@ -84,28 +84,7 @@ export async function promptWithModelSuggestionRetry(
client: Client, client: Client,
args: PromptArgs, args: PromptArgs,
): Promise<void> { ): Promise<void> {
try { // NOTE: Model suggestion retry removed — promptAsync returns 204 immediately,
await client.session.prompt(args as Parameters<typeof client.session.prompt>[0]) // model errors happen asynchronously server-side and cannot be caught here
} catch (error) { await client.session.promptAsync(args as Parameters<typeof client.session.promptAsync>[0])
const suggestion = parseModelSuggestion(error)
if (!suggestion || !args.body.model) {
throw error
}
log("[model-suggestion-retry] Model not found, retrying with suggestion", {
original: `${suggestion.providerID}/${suggestion.modelID}`,
suggested: suggestion.suggestion,
})
await client.session.prompt({
...args,
body: {
...args.body,
model: {
providerID: suggestion.providerID,
modelID: suggestion.suggestion,
},
},
} as Parameters<typeof client.session.prompt>[0])
}
} }

View File

@ -219,18 +219,18 @@ Original error: ${createResult.error}`
log(`[call_omo_agent] Sending prompt to session ${sessionID}`) log(`[call_omo_agent] Sending prompt to session ${sessionID}`)
log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100)) log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100))
try { try {
await ctx.client.session.prompt({ await (ctx.client.session as any).promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: args.subagent_type, agent: args.subagent_type,
tools: { tools: {
...getAgentToolRestrictions(args.subagent_type), ...getAgentToolRestrictions(args.subagent_type),
task: false, task: false,
}, },
parts: [{ type: "text", text: args.prompt }], parts: [{ type: "text", text: args.prompt }],
}, },
}) })
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
log(`[call_omo_agent] Prompt error:`, errorMessage) log(`[call_omo_agent] Prompt error:`, errorMessage)

View File

@ -211,20 +211,20 @@ export async function executeSyncContinuation(
: undefined : undefined
} }
await client.session.prompt({ await (client.session as any).promptAsync({
path: { id: args.session_id! }, path: { id: args.session_id! },
body: { body: {
...(resumeAgent !== undefined ? { agent: resumeAgent } : {}), ...(resumeAgent !== undefined ? { agent: resumeAgent } : {}),
...(resumeModel !== undefined ? { model: resumeModel } : {}), ...(resumeModel !== undefined ? { model: resumeModel } : {}),
tools: { tools: {
...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}), ...(resumeAgent ? getAgentToolRestrictions(resumeAgent) : {}),
task: false, task: false,
call_omo_agent: true, call_omo_agent: true,
question: false, question: false,
}, },
parts: [{ type: "text", text: args.prompt }], parts: [{ type: "text", text: args.prompt }],
}, },
}) })
} catch (promptError) { } catch (promptError) {
if (toastManager) { if (toastManager) {
toastManager.removeTask(taskId) toastManager.removeTask(taskId)

File diff suppressed because it is too large Load Diff

View File

@ -111,17 +111,19 @@ describe("look-at tool", () => {
}) })
describe("createLookAt error handling", () => { describe("createLookAt error handling", () => {
// given JSON parse error occurs in session.prompt // given JSON parse error occurs in session.promptAsync
// when LookAt tool executed // when LookAt tool executed
// then return user-friendly error message // then error propagates (band-aid removed since root cause fixed by promptAsync migration)
test("handles JSON parse error from session.prompt gracefully", async () => { test("propagates JSON parse error from session.promptAsync", async () => {
const throwingMock = async () => {
throw new Error("JSON Parse error: Unexpected EOF")
}
const mockClient = { const mockClient = {
session: { session: {
get: async () => ({ data: { directory: "/project" } }), get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_json_error" } }), create: async () => ({ data: { id: "ses_test_json_error" } }),
prompt: async () => { prompt: throwingMock,
throw new Error("JSON Parse error: Unexpected EOF") promptAsync: throwingMock,
},
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
} }
@ -142,28 +144,24 @@ describe("look-at tool", () => {
ask: async () => {}, ask: async () => {},
} }
const result = await tool.execute( await expect(
{ file_path: "/test/file.png", goal: "analyze image" }, tool.execute({ file_path: "/test/file.png", goal: "analyze image" }, toolContext)
toolContext ).rejects.toThrow("JSON Parse error: Unexpected EOF")
)
expect(result).toContain("Error: Failed to analyze")
expect(result).toContain("malformed response")
expect(result).toContain("multimodal-looker")
expect(result).toContain("image/png")
}) })
// given generic error occurs in session.prompt // given generic error occurs in session.promptAsync
// when LookAt tool executed // when LookAt tool executed
// then return error including original error message // then error propagates
test("handles generic prompt error gracefully", async () => { test("propagates generic prompt error", async () => {
const throwingMock = async () => {
throw new Error("Network connection failed")
}
const mockClient = { const mockClient = {
session: { session: {
get: async () => ({ data: { directory: "/project" } }), get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_test_generic_error" } }), create: async () => ({ data: { id: "ses_test_generic_error" } }),
prompt: async () => { prompt: throwingMock,
throw new Error("Network connection failed") promptAsync: throwingMock,
},
messages: async () => ({ data: [] }), messages: async () => ({ data: [] }),
}, },
} }
@ -184,13 +182,9 @@ describe("look-at tool", () => {
ask: async () => {}, ask: async () => {},
} }
const result = await tool.execute( await expect(
{ file_path: "/test/file.pdf", goal: "extract text" }, tool.execute({ file_path: "/test/file.pdf", goal: "extract text" }, toolContext)
toolContext ).rejects.toThrow("Network connection failed")
)
expect(result).toContain("Error: Failed to send prompt")
expect(result).toContain("Network connection failed")
}) })
}) })
@ -220,6 +214,10 @@ describe("look-at tool", () => {
promptBody = input.body promptBody = input.body
return { data: {} } return { data: {} }
}, },
promptAsync: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({ messages: async () => ({
data: [ data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "done" }] }, { info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "done" }] },
@ -274,6 +272,10 @@ describe("look-at tool", () => {
promptBody = input.body promptBody = input.body
return { data: {} } return { data: {} }
}, },
promptAsync: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({ messages: async () => ({
data: [ data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] }, { info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },
@ -327,6 +329,10 @@ describe("look-at tool", () => {
promptBody = input.body promptBody = input.body
return { data: {} } return { data: {} }
}, },
promptAsync: async (input: any) => {
promptBody = input.body
return { data: {} }
},
messages: async () => ({ messages: async () => ({
data: [ data: [
{ info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] }, { info: { role: "assistant", time: { created: 1 } }, parts: [{ type: "text", text: "analyzed" }] },

View File

@ -245,27 +245,7 @@ Original error: ${createResult.error}`
const errorMessage = promptError instanceof Error ? promptError.message : String(promptError) const errorMessage = promptError instanceof Error ? promptError.message : String(promptError)
log(`[look_at] Prompt error:`, promptError) log(`[look_at] Prompt error:`, promptError)
const isJsonParseError = errorMessage.includes("JSON") && (errorMessage.includes("EOF") || errorMessage.includes("parse")) throw promptError
if (isJsonParseError) {
return `Error: Failed to analyze ${isBase64Input ? "image" : "file"} - received malformed response from multimodal-looker agent.
This typically occurs when:
1. The multimodal-looker model is not available or not connected
2. The model does not support this ${isBase64Input ? "image format" : `file type (${mimeType})`}
3. The API returned an empty or truncated response
${isBase64Input ? "Source: clipboard/pasted image" : `File: ${args.file_path}`}
MIME type: ${mimeType}
Try:
- Ensure a vision-capable model (e.g., gemini-3-flash, gpt-5.2) is available
- Check provider connections in opencode settings
${!isBase64Input ? "- For text files like .md, .txt, use the Read tool instead" : ""}
Original error: ${errorMessage}`
}
return `Error: Failed to send prompt to multimodal-looker agent: ${errorMessage}`
} }
log(`[look_at] Prompt sent, fetching messages...`) log(`[look_at] Prompt sent, fetching messages...`)