Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 52 additions & 0 deletions packages/chat/src/workflow-participant-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import {
} from "./workbench-service";
import type { ChatStore } from "./store";
import { Part, type Part as PartType } from "./parts";
import { QuestionBlockData } from "./blocks";
import type { RoomMessage, RoomMessageStore } from "./room-messages";
import {
CONNECTIONS_PENDING_KEY,
connectServiceConnectorIds,
Expand Down Expand Up @@ -108,6 +110,37 @@ const MintDmInput = type({ definitionId: "string > 0" });

const PostMessageInput = type({ parts: Part.array() });

function questionIdFromParts(parts: readonly PartType[]): string | undefined {
for (const part of parts) {
if (part.kind !== "block" || part.block.type !== "question") continue;
const parsed = QuestionBlockData(part.block.data);
if (parsed instanceof type.errors) continue;
return parsed.questionId;
}
return undefined;
}

async function existingQuestionMessage(
roomMessages: Pick<RoomMessageStore, "listMessages">,
tenantId: string,
workbenchId: string,
questionId: string,
): Promise<RoomMessage | undefined> {
let cursor: string | undefined;
for (;;) {
const page = await roomMessages.listMessages({
tenantId,
workbenchId,
...(cursor !== undefined ? { cursor } : {}),
});
for (const item of page.items) {
if (questionIdFromParts(item.parts) === questionId) return item;
}
if (page.nextCursor === undefined) return undefined;
cursor = page.nextCursor;
}
}

export type CreateWorkflowParticipantRoutesDeps = {
readonly store: Pick<
ChatStore,
Expand Down Expand Up @@ -432,6 +465,25 @@ export function createWorkflowParticipantRoutes(
);
}

// Re-posting a question card with the same questionId is a no-op that
// returns the existing message (CL-7248). `ask_user` derives that id
// from the tool-call id, so a crash between the first post and
// `suspendOnGate` retries the same id instead of orphaning a second
// card. Looked up before any other write so the retry also skips
// connect-service pending-set mutation.
const questionId = questionIdFromParts(body.parts);
if (questionId !== undefined) {
const existing = await existingQuestionMessage(
deps.roomMessages,
scope.tenantId,
workbench.workbenchId,
questionId,
);
if (existing !== undefined) {
return c.json({ id: existing.id, createdAt: existing.createdAt }, 200);
}
}

// A connect-service card registers its connector on the room's own
// settings before the message lands, so a later connection completing
// in the browser can find this room and settle the card
Expand Down
95 changes: 95 additions & 0 deletions packages/chat/test/workflow-participant-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,101 @@ describe("POST /participants/messages", () => {
).toBe(true);
});

test("re-posting a question block with the same questionId returns the existing card and does not insert a second message", async () => {
const store = createInMemoryChatStore();
await store.createWorkbenchSettings({
tenantId: TENANT.id,
workbenchId: "chan_1",
settings: {
"chat/kind": "workbench",
"chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }],
},
updatedBy: "prn_1",
});
const roomMessages = createInMemoryRoomMessageStore();
const app = buildApp({ store, roomMessages });
const questionBlock = {
kind: "block" as const,
block: {
type: "question",
data: {
questionId: "q_retry1",
question: "Which environment?",
options: ["Staging", "Production"],
},
},
};
const post = () =>
app.request("/participants/messages", {
method: "POST",
headers: { "content-type": "application/json", ...AUTH_HEADERS },
body: JSON.stringify({ parts: [questionBlock] }),
});

const first = await post();
expect(first.status).toBe(201);
const firstBody = (await first.json()) as { id: string; createdAt: string };

const second = await post();
expect(second.ok).toBe(true);
const secondBody = (await second.json()) as {
id: string;
createdAt: string;
};
expect(secondBody.id).toBe(firstBody.id);
expect(secondBody.createdAt).toBe(firstBody.createdAt);

const listed = await roomMessages.listMessages({
tenantId: TENANT.id,
workbenchId: "chan_1",
});
expect(listed.items).toHaveLength(1);
expect(listed.items[0]?.id).toBe(firstBody.id);
});

test("a different questionId still posts a second card", async () => {
const store = createInMemoryChatStore();
await store.createWorkbenchSettings({
tenantId: TENANT.id,
workbenchId: "chan_1",
settings: {
"chat/kind": "workbench",
"chat/participants": [{ address: RUN_ADDRESS, handle: "myra" }],
},
updatedBy: "prn_1",
});
const roomMessages = createInMemoryRoomMessageStore();
const app = buildApp({ store, roomMessages });
const post = (questionId: string) =>
app.request("/participants/messages", {
method: "POST",
headers: { "content-type": "application/json", ...AUTH_HEADERS },
body: JSON.stringify({
parts: [
{
kind: "block",
block: {
type: "question",
data: {
questionId,
question: "Which environment?",
options: ["Staging", "Production"],
},
},
},
],
}),
});

expect((await post("q_one")).status).toBe(201);
expect((await post("q_two")).status).toBe(201);
const listed = await roomMessages.listMessages({
tenantId: TENANT.id,
workbenchId: "chan_1",
});
expect(listed.items).toHaveLength(2);
});

test("posting a connect-service block records the pending connection on the workbench and publishes chat.settings", async () => {
const store = createInMemoryChatStore();
await store.createWorkbenchSettings({
Expand Down
2 changes: 1 addition & 1 deletion packages/interaction-tools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.5",
"version": "0.0.6",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
29 changes: 29 additions & 0 deletions packages/interaction-tools/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,35 @@ test("postQuestion posts a question block to the workflow-chat participants/mess
);
});

test("postQuestion stamps a caller-supplied questionId on the card instead of minting", async () => {
let seenBody: unknown;
const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
seenBody = JSON.parse(String(init?.body));
return new Response(
JSON.stringify({ id: "msg_1", createdAt: "2026-08-17T00:00:00.000Z" }),
{ status: 201 },
);
}) as unknown as typeof fetch;

const result = await postQuestion(testConfig(fetchImpl), {
question: "Which environment?",
options: ["Staging", "Production"],
questionId: "q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
});

const body = seenBody as {
parts: readonly {
kind: string;
block: { type: string; data: Record<string, unknown> };
}[];
};
expect(body.parts[0]?.block.data["questionId"]).toBe(
"q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
expect(result.questionId).toBe("q_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
expect(result.messageId).toBe("msg_1");
});

test("a 404 from the route surfaces as NoOwnChannelError", async () => {
const fetchImpl = (async () =>
new Response(
Expand Down
52 changes: 39 additions & 13 deletions packages/interaction-tools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// own channel" route the same bundle family (`@corbits/agent-directory-tools`)
// already reaches for `participants/invite`. Same auth-header shape, same
// error-handling pattern as `@corbits/agent-directory-tools`'s `client.ts`.
import { createHash } from "node:crypto";
import { type } from "arktype";

export interface AskUserClientConfig {
Expand All @@ -19,6 +20,13 @@ export interface AskUserQuestion {
readonly subtitle?: string;
readonly options: readonly string[];
readonly allowFreeText?: boolean;
/**
* Stable id for this card. When omitted, `postQuestion` mints one.
* `ask_user` always supplies `questionIdForCall(call.id)` so a retry of
* the same tool call re-posts the same id — the participants/messages
* route treats that as a no-op and returns the existing card (CL-7248).
*/
readonly questionId?: string;
}

function authHeaders(config: AskUserClientConfig): Record<string, string> {
Expand Down Expand Up @@ -62,25 +70,43 @@ export class NoOwnChannelError extends Error {}
const PostedMessageResponse = type({ id: "string", createdAt: "string" });

/**
* Posts a `question` block into the caller's own channel. Mints the
* block's `questionId` here (never trusts the model to supply a stable,
* collision-free id) and returns it: `beforeAskUser` (`./tool.ts`) reuses
* it verbatim as the `message_response` gate's own `correlationId`, and
* the block-response route persists (and later relays) an answer keyed on
* this same id as `blockId` — a question block's `blockId` IS its
* `questionId`, the same way a poll's is its `pollId`
* (`packages/chat/src/schema.ts`'s `block_responses` table comment).
* `@intx/hub-common`'s `generateId` is a closed enum of platform id kinds
* (vendored, read-only source) with no "question" entry, so this mints its
* own `q_`-prefixed id the same way `packages/chat/src/threads.ts`'s
* `thr_` ids do.
* Derives the question card's `questionId` from a tool-call id. The call
* id is assigned by the runtime, not the model, so it is stable across a
* crash-retry of the same `ask_user` invocation — hashing it into the
* `q_<hex32>` shape `postQuestion` otherwise mints keeps the card and the
* `message_response` gate on one id without trusting model-supplied input.
*/
export function questionIdForCall(callId: string): string {
return `q_${createHash("sha256").update(callId).digest("hex").slice(0, 32)}`;
}

/**
* Posts a `question` block into the caller's own channel. Uses a caller-
* supplied `questionId` when present (never trusts the model to supply a
* stable, collision-free id — `ask_user` derives it from the tool-call id)
* and otherwise mints one here. Returns the id: `beforeAskUser`
* (`./tool.ts`) reuses it verbatim as the `message_response` gate's own
* `correlationId`, and the block-response route persists (and later
* relays) an answer keyed on this same id as `blockId` — a question
* block's `blockId` IS its `questionId`, the same way a poll's is its
* `pollId` (`packages/chat/src/schema.ts`'s `block_responses` table
* comment). `@intx/hub-common`'s `generateId` is a closed enum of
* platform id kinds (vendored, read-only source) with no "question"
* entry, so a minted id is `q_`-prefixed the same way
* `packages/chat/src/threads.ts`'s `thr_` ids are.
*
* Re-posting the same `questionId` into the same workbench is a no-op:
* the participants/messages route returns the existing card rather than
* inserting a second one, so a crash between this post and
* `suspendOnGate` cannot orphan a duplicate on retry.
*/
export async function postQuestion(
config: AskUserClientConfig,
question: AskUserQuestion,
): Promise<{ readonly messageId: string; readonly questionId: string }> {
const doFetch = config.fetchImpl ?? fetch;
const questionId = `q_${crypto.randomUUID().replace(/-/g, "")}`;
const questionId =
question.questionId ?? `q_${crypto.randomUUID().replace(/-/g, "")}`;
const response = await doFetch(
`${config.hubChatUrl}/api/workflow-chat/participants/messages`,
{
Expand Down
51 changes: 49 additions & 2 deletions packages/interaction-tools/src/tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,58 @@ test("ask_user posts a question block and suspends on a message_response gate",
expect(decision.pendingOp.kind).toBe("message_response");
expect(decision.pendingOp.suspendedCall).toEqual(call);
expect(decision.pendingOp.correlationId).toBe(decision.gate.correlationId);
// Minted by `postQuestion` (`q_<hex32>`), not a separately-minted
// `crypto.randomUUID()` — the gate and the question card share one id.
// Derived from the tool-call id (`q_<hex32>`), not minted per attempt —
// the gate and the question card share one id, and a retry of the same
// call reuses it.
expect(decision.gate.correlationId).toMatch(/^q_[0-9a-f]{32}$/);
});

test("retrying ask_user for the same call reuses the questionId so a crash between post and suspend cannot orphan a second card", async () => {
const postedQuestionIds: string[] = [];
const fetchImpl = (async (_url: string | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as {
parts: readonly {
block: { data: Record<string, unknown> };
}[];
};
const questionId = body.parts[0]?.block.data["questionId"];
if (typeof questionId === "string") postedQuestionIds.push(questionId);
return new Response(
JSON.stringify({
id: `msg_${postedQuestionIds.length}`,
createdAt: "2026-08-17T00:00:00.000Z",
}),
{ status: 201 },
);
}) as unknown as typeof fetch;

const bundle = interactionTools(testEnv());
const call = {
id: "call_1",
name: ASK_USER_TOOL,
arguments: {
question: "Which environment?",
options: ["Staging", "Production"],
},
};

const first = await withFetch(fetchImpl, () => beforeTool(bundle, call));
const second = await withFetch(fetchImpl, () => beforeTool(bundle, call));

expect(postedQuestionIds).toHaveLength(2);
const reusedId = postedQuestionIds[0];
if (reusedId === undefined) {
throw new Error("expected a posted questionId");
}
expect(postedQuestionIds[1]).toBe(reusedId);
expect(reusedId).toMatch(/^q_[0-9a-f]{32}$/);
if (first.type !== "suspend" || second.type !== "suspend") {
throw new Error("expected both retries to suspend");
}
expect(first.gate.correlationId).toBe(reusedId);
expect(second.gate.correlationId).toBe(reusedId);
});

test("ask_user rejects fewer than 2 options before ever posting", async () => {
let posted = false;
const fetchImpl = (async () => {
Expand Down
Loading
Loading