diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index 37df05a36..f639ecd82 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -1,137 +1,252 @@ const { describe, test, expect, mock } = require("bun:test") -describe("executeSync", () => { - test("passes question=false via tools parameter to block question tool", async () => { - //#given - const { executeSync } = require("./sync-executor") +type ExecuteSync = typeof import("./sync-executor").executeSync - const deps = { - createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), - waitForCompletion: mock(async () => {}), - processMessages: mock(async () => "agent response"), - setSessionFallbackChain: mock(() => {}), +type PromptAsyncInput = { + path: { id: string } + body: { + agent: string + tools: Record + parts: Array<{ type: string; text: string }> + } +} + +type ToolContext = { + sessionID: string + messageID: string + agent: string + abort: AbortSignal + metadata: ReturnType +} + +type Dependencies = { + createOrGetSession: ReturnType + waitForCompletion: ReturnType + processMessages: ReturnType + setSessionFallbackChain: ReturnType +} + +async function importExecuteSync(): Promise { + const module = await import("./sync-executor") + return module.executeSync +} + +function createDependencies(overrides?: Partial): Dependencies { + return { + createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), + waitForCompletion: mock(async () => {}), + processMessages: mock(async () => "agent response"), + setSessionFallbackChain: mock(() => {}), + ...overrides, + } +} + +function createPromptAsyncRecorder(implementation?: (input: PromptAsyncInput) => Promise) { + let capturedInput: PromptAsyncInput | undefined + + const promptAsync = mock(async (input: PromptAsyncInput) => { + capturedInput = input + if (implementation) { + return implementation(input) } - let promptArgs: any - const promptAsync = mock(async (input: any) => { - promptArgs = input - return { data: {} } - }) + return { data: {} } + }) + return { + promptAsync, + getCapturedInput(): PromptAsyncInput | undefined { + return capturedInput + }, + } +} + +function createToolContext(): ToolContext { + return { + sessionID: "parent-session", + messageID: "msg-1", + agent: "sisyphus", + abort: new AbortController().signal, + metadata: mock(async () => {}), + } +} + +function createContext(promptAsync: ReturnType) { + return { + client: { + session: { + promptAsync, + }, + }, + } +} + +describe("executeSync", () => { + test("sends sync prompt with question and task tools disabled", async () => { + //#given + const executeSync = await importExecuteSync() + const deps = createDependencies() + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder() const args = { subagent_type: "explore", description: "test task", prompt: "find something", - } - - const toolContext = { - sessionID: "parent-session", - messageID: "msg-1", - agent: "sisyphus", - abort: new AbortController().signal, - metadata: mock(async () => {}), - } - - const ctx = { - client: { - session: { promptAsync }, - }, + run_in_background: false, } //#when - await executeSync(args, toolContext, ctx as any, deps) + await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps) //#then - expect(promptAsync).toHaveBeenCalled() - expect(promptArgs.body.tools.question).toBe(false) + const promptInput = recorder.getCapturedInput() + expect(promptInput).toBeDefined() + expect(promptInput?.path.id).toBe("ses-test-123") + expect(promptInput?.body.agent).toBe("explore") + expect(promptInput?.body.tools.question).toBe(false) + expect(promptInput?.body.tools.task).toBe(false) + expect(promptInput?.body.parts).toEqual([{ type: "text", text: "find something" }]) }) - test("passes task=false via tools parameter", async () => { + test("returns processed response with task metadata footer", async () => { //#given - const { executeSync } = require("./sync-executor") - - const deps = { - createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), - waitForCompletion: mock(async () => {}), - processMessages: mock(async () => "agent response"), - setSessionFallbackChain: mock(() => {}), - } - - let promptArgs: any - const promptAsync = mock(async (input: any) => { - promptArgs = input - return { data: {} } + const executeSync = await importExecuteSync() + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-test-456", isNew: true })), + processMessages: mock(async () => "final answer"), }) - + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder() const args = { subagent_type: "librarian", description: "search docs", prompt: "find docs", - } - - const toolContext = { - sessionID: "parent-session", - messageID: "msg-2", - agent: "sisyphus", - abort: new AbortController().signal, - metadata: mock(async () => {}), - } - - const ctx = { - client: { - session: { promptAsync }, - }, + run_in_background: false, } //#when - await executeSync(args, toolContext, ctx as any, deps) + const result = await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps) //#then - expect(promptAsync).toHaveBeenCalled() - expect(promptArgs.body.tools.task).toBe(false) + expect(result).toContain("final answer") + expect(result).toContain("") + expect(result).toContain("session_id: ses-test-456") + expect(result).toContain("") + expect(deps.waitForCompletion).toHaveBeenCalledWith( + "ses-test-456", + toolContext, + expect.objectContaining({ client: expect.anything() }) + ) }) - test("applies fallbackChain to sync sessions", async () => { + test("records metadata with description and created session id", async () => { //#given - const { executeSync } = require("./sync-executor") - - const setSessionFallbackChain = mock(() => {}) - const deps = { - createOrGetSession: mock(async () => ({ sessionID: "ses-test-456", isNew: true })), - waitForCompletion: mock(async () => {}), - processMessages: mock(async () => "agent response"), - setSessionFallbackChain, + const executeSync = await importExecuteSync() + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-metadata", isNew: true })), + }) + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder() + const args = { + subagent_type: "explore", + description: "metadata title", + prompt: "collect evidence", + run_in_background: false, } + //#when + await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps) + + //#then + expect(toolContext.metadata).toHaveBeenCalledWith({ + title: "metadata title", + metadata: { sessionId: "ses-metadata" }, + }) + }) + + test("applies fallback chain to sync sessions before completion polling", async () => { + //#given + const executeSync = await importExecuteSync() + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-fallback", isNew: true })), + }) + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder() const args = { subagent_type: "explore", description: "test task", prompt: "find something", + run_in_background: false, } - - const toolContext = { - sessionID: "parent-session", - messageID: "msg-3", - agent: "sisyphus", - abort: new AbortController().signal, - metadata: mock(async () => {}), - } - - const ctx = { - client: { - session: { promptAsync: mock(async () => ({ data: {} })) }, - }, - } - const fallbackChain = [ { providers: ["quotio"], model: "kimi-k2.5", variant: undefined }, { providers: ["openai"], model: "gpt-5.2", variant: "high" }, ] //#when - await executeSync(args, toolContext, ctx as any, deps, fallbackChain) + await executeSync( + args, + toolContext, + createContext(recorder.promptAsync) as never, + deps, + fallbackChain + ) //#then - expect(setSessionFallbackChain).toHaveBeenCalledWith("ses-test-456", fallbackChain) + expect(deps.setSessionFallbackChain).toHaveBeenCalledWith("ses-fallback", fallbackChain) + }) + + test("returns dedicated agent-not-found error with task metadata", async () => { + //#given + const executeSync = await importExecuteSync() + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-missing-agent", isNew: true })), + }) + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder(async () => { + throw new Error("agent.name is undefined") + }) + const args = { + subagent_type: "explore", + description: "missing agent", + prompt: "find something", + run_in_background: false, + } + + //#when + const result = await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps) + + //#then + expect(result).toContain('Error: Agent "explore" not found') + expect(result).toContain("session_id: ses-missing-agent") + expect(deps.waitForCompletion).not.toHaveBeenCalled() + expect(deps.processMessages).not.toHaveBeenCalled() + }) + + test("returns generic prompt failure with task metadata", async () => { + //#given + const executeSync = await importExecuteSync() + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-prompt-error", isNew: true })), + }) + const toolContext = createToolContext() + const recorder = createPromptAsyncRecorder(async () => { + throw new Error("network exploded") + }) + const args = { + subagent_type: "librarian", + description: "generic failure", + prompt: "find docs", + run_in_background: false, + } + + //#when + const result = await executeSync(args, toolContext, createContext(recorder.promptAsync) as never, deps) + + //#then + expect(result).toContain("Error: Failed to send prompt: network exploded") + expect(result).toContain("session_id: ses-prompt-error") + expect(deps.waitForCompletion).not.toHaveBeenCalled() + expect(deps.processMessages).not.toHaveBeenCalled() }) })