diff --git a/bun.lock b/bun.lock index 8f7f39c51..4f2d5eb98 100644 --- a/bun.lock +++ b/bun.lock @@ -888,7 +888,7 @@ }, "packages/interaction-tools": { "name": "@corbits/interaction-tools", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@intx/agent": "workspace:*", "@intx/types": "workspace:*", diff --git a/packages/interaction-tools/package.json b/packages/interaction-tools/package.json index d52be8cc1..c5f1f4cc3 100644 --- a/packages/interaction-tools/package.json +++ b/packages/interaction-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/interaction-tools", "private": true, "description": "The ask_user tool: an @intx/agent bundle that poses an interview question in-thread as a question block and parks the turn on a message_response gate until the user answers", - "version": "0.0.4", + "version": "0.0.5", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/interaction-tools/src/client.test.ts b/packages/interaction-tools/src/client.test.ts index 1fc894738..d6f63a10a 100644 --- a/packages/interaction-tools/src/client.test.ts +++ b/packages/interaction-tools/src/client.test.ts @@ -65,27 +65,114 @@ test("a 404 from the route surfaces as NoOwnChannelError", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "no channel" }, + error: { + code: "not_found", + userMessage: "no channel", + refId: "ref_test", + }, }), { status: 404 }, )) as unknown as typeof fetch; - await expect( - postQuestion(testConfig(fetchImpl), { + try { + await postQuestion(testConfig(fetchImpl), { question: "Q?", options: ["a", "b"], - }), - ).rejects.toBeInstanceOf(NoOwnChannelError); + }); + throw new Error("expected NoOwnChannelError"); + } catch (error) { + expect(error).toBeInstanceOf(NoOwnChannelError); + expect((error as Error).message).toBe("no channel"); + } }); -test("a non-ok, non-404 response throws a plain error", async () => { +test("a non-ok, non-404 response surfaces the envelope userMessage", async () => { const fetchImpl = (async () => - new Response("boom", { status: 500 })) as unknown as typeof fetch; + new Response( + JSON.stringify({ + error: { + code: "internal_error", + userMessage: "the hub could not post the question", + refId: "ref_test", + }, + }), + { status: 500 }, + )) as unknown as typeof fetch; await expect( postQuestion(testConfig(fetchImpl), { question: "Q?", options: ["a", "b"], }), - ).rejects.toThrow(); + ).rejects.toThrow("the hub could not post the question"); +}); + +test("reads userMessage even when a legacy message field is also present", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + error: { + code: "internal_error", + message: "legacy", + userMessage: "canonical", + refId: "ref_test", + }, + }), + { status: 500 }, + )) as unknown as typeof fetch; + + try { + await postQuestion(testConfig(fetchImpl), { + question: "Q?", + options: ["a", "b"], + }); + throw new Error("expected postQuestion to throw"); + } catch (error) { + expect((error as Error).message).toBe( + "Posting the question failed: canonical", + ); + } +}); + +test("a 404 envelope with only error.message falls back, never the legacy field", async () => { + const fetchImpl = (async () => + new Response( + JSON.stringify({ + error: { code: "not_found", message: "legacy" }, + }), + { status: 404 }, + )) as unknown as typeof fetch; + + try { + await postQuestion(testConfig(fetchImpl), { + question: "Q?", + options: ["a", "b"], + }); + throw new Error("expected NoOwnChannelError"); + } catch (error) { + expect(error).toBeInstanceOf(NoOwnChannelError); + expect((error as Error).message).toBe( + "The caller has no channel of its own to post into", + ); + } +}); + +test("a non-ok, non-envelope response falls back to status text", async () => { + const fetchImpl = (async () => + new Response("boom", { + status: 500, + statusText: "Internal Server Error", + })) as unknown as typeof fetch; + + try { + await postQuestion(testConfig(fetchImpl), { + question: "Q?", + options: ["a", "b"], + }); + throw new Error("expected postQuestion to throw"); + } catch (error) { + expect((error as Error).message).toBe( + "Posting the question failed: 500 Internal Server Error", + ); + } }); diff --git a/packages/interaction-tools/src/client.ts b/packages/interaction-tools/src/client.ts index 20695654a..b36a6bb00 100644 --- a/packages/interaction-tools/src/client.ts +++ b/packages/interaction-tools/src/client.ts @@ -28,16 +28,23 @@ function authHeaders(config: AskUserClientConfig): Record { }; } +/** Pulls `error.userMessage` out of the canonical hub envelope + * (`{error: {code, userMessage, refId}}`), if `body` matches that shape — + * same shape `@corbits/agent-directory-tools`' client reads. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { + if ( + error === null || + typeof error !== "object" || + !("userMessage" in error) + ) { return undefined; } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + const userMessage = (error as { userMessage: unknown }).userMessage; + return typeof userMessage === "string" ? userMessage : undefined; } async function readErrorMessage( @@ -112,7 +119,10 @@ export async function postQuestion( } if (!response.ok) { throw new Error( - `Posting the question failed: ${response.status} ${response.statusText}`, + `Posting the question failed: ${await readErrorMessage( + response, + `${response.status} ${response.statusText}`, + )}`, ); } const body: unknown = await response.json(); diff --git a/packages/interaction-tools/src/tool.test.ts b/packages/interaction-tools/src/tool.test.ts index a07f74f8a..4a9c6d9df 100644 --- a/packages/interaction-tools/src/tool.test.ts +++ b/packages/interaction-tools/src/tool.test.ts @@ -107,7 +107,13 @@ test("ask_user rejects fewer than 2 options before ever posting", async () => { test("ask_user surfaces a no-own-channel failure as a blocked call, not a throw", async () => { const fetchImpl = (async () => new Response( - JSON.stringify({ error: { code: "not_found", message: "no channel" } }), + JSON.stringify({ + error: { + code: "not_found", + userMessage: "no channel found", + refId: "ref_test", + }, + }), { status: 404 }, )) as unknown as typeof fetch; @@ -123,7 +129,7 @@ test("ask_user surfaces a no-own-channel failure as a blocked call, not a throw" if (decision.type !== "block") { throw new Error(`expected a block decision, got ${decision.type}`); } - expect(decision.reason).toContain("no channel"); + expect(decision.reason).toBe("no channel found"); }); test("a call for another tool name is allowed through unsuspended", async () => { diff --git a/workflows/assistant/src/index.ts b/workflows/assistant/src/index.ts index 6f2d4bb5b..62c341e91 100644 --- a/workflows/assistant/src/index.ts +++ b/workflows/assistant/src/index.ts @@ -52,7 +52,7 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/catalog-tools", version: "0.0.2" }, { name: "@corbits/skills-tools", version: "0.0.6" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, - { name: "@corbits/interaction-tools", version: "0.0.4" }, + { name: "@corbits/interaction-tools", version: "0.0.5" }, { name: "@corbits/manus-tools", version: "0.0.11" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.4" }, ];