From c86b32ba4cebc0b89dbe53062343bacae948a2cd Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 00:17:32 -0700 Subject: [PATCH 1/4] Add tests for interaction-tools canonical error envelope (CL-7298) --- packages/interaction-tools/src/client.test.ts | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/interaction-tools/src/client.test.ts b/packages/interaction-tools/src/client.test.ts index 1fc894738..1d1428e07 100644 --- a/packages/interaction-tools/src/client.test.ts +++ b/packages/interaction-tools/src/client.test.ts @@ -65,27 +65,44 @@ 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"); }); From 50b0eebf13d893d88e36466e6889e2eccfae007c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 00:20:56 -0700 Subject: [PATCH 2/4] Read userMessage from the canonical error envelope in interaction-tools (CL-7298) --- packages/interaction-tools/src/client.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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(); From 5851075ef9afa780d64e87e4697d254f18501783 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 00:40:30 -0700 Subject: [PATCH 3/4] Lock interaction-tools tests to the canonical userMessage (CL-7298) The no-own-channel tool test stubbed error.message and asserted a substring that also appears in the 404 fallback sentence, so it passed even after the client stopped reading that field. --- packages/interaction-tools/src/client.test.ts | 70 +++++++++++++++++++ packages/interaction-tools/src/tool.test.ts | 10 ++- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/interaction-tools/src/client.test.ts b/packages/interaction-tools/src/client.test.ts index 1d1428e07..d6f63a10a 100644 --- a/packages/interaction-tools/src/client.test.ts +++ b/packages/interaction-tools/src/client.test.ts @@ -106,3 +106,73 @@ test("a non-ok, non-404 response surfaces the envelope userMessage", async () => }), ).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/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 () => { From fd9bedf3840443b292bf02ec4665f8986020769b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 3 Sep 2026 14:52:13 -0700 Subject: [PATCH 4/4] Bump interaction-tools to 0.0.5 (CL-7298) src/ changed under 0.0.4, so the freshness check rejected the PR. Pin the assistant workflow to the new version. --- bun.lock | 2 +- packages/interaction-tools/package.json | 2 +- workflows/assistant/src/index.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/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" }, ];