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
34 changes: 17 additions & 17 deletions VENDORED.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/chat/src/platform-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,9 @@ export function createHubChatPlatform(
domain,
content: input.content.content,
cryptoProvider,
...(input.correlationId !== undefined
? { correlationId: input.correlationId }
: {}),
};
const withAttachments =
attachments !== undefined
Expand Down
7 changes: 7 additions & 0 deletions packages/chat/src/platform-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ export interface WorkbenchMail {
* a principal address has no mailbox: a reply to it vanishes.
*/
readonly fromWorkbenchId?: string;
/**
* The MIME `Interchange-Correlation-ID` header to stamp on this mail
* (`vendor/intx/mime/src/mail-builder.ts` already accepts this as
* `opts.correlationId`) — set when this message is an answer that must
* resolve a specific parked `message_response` gate, absent otherwise.
*/
readonly correlationId?: string;
}): Promise<SentMail>;

fetchBlob(workbenchId: string, blobId: string): Promise<string | Uint8Array>;
Expand Down
7 changes: 7 additions & 0 deletions packages/chat/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2269,6 +2269,13 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono<TenantEnv> {
senderAddress: senderAddressOf(c),
workbenchId,
messageParts: [{ kind: "text", text: payload.answer }],
// A question block's `blockId` IS its `questionId`
// (`ask_user`'s `postQuestion` mints one id and reuses it as
// both), which is also the `message_response` gate's own
// correlationId (`beforeAskUser`, `@corbits/interaction-tools`)
// — so this is the exact id that resolves the gate this answer
// is for, not merely "whichever gate is next".
correlationId: blockId,
},
);
deps.onMessageFanout?.(answer.fanoutDelivered);
Expand Down
4 changes: 4 additions & 0 deletions packages/chat/src/turn-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export type QueuedTurn = {
readonly principalId: string;
readonly recipients: readonly string[];
readonly parts: readonly PartType[];
/** The id an answer must carry to resolve the `message_response` gate it
* answers (see `SendWorkbenchMessageInput.correlationId` in
* `./workbench-service.ts`). Absent for every ordinary message. */
readonly correlationId?: string;
};

export type WorkbenchTurnQueueDeps = {
Expand Down
40 changes: 40 additions & 0 deletions packages/chat/src/workbench-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1381,6 +1381,16 @@ export type SendWorkbenchMessageInput = {
* into the run the room already has.
*/
readonly forcedRecipientAddress?: string;
/**
* The id an answer must carry to resolve the gate it answers — a
* `message_response` gate's own correlationId (`@corbits/interaction-tools`'s
* `beforeAskUser`), threaded down to `headers.interchangeCorrelationId`
* on the InboundMessage the reactor's `tryCorrelate` reads. Sourced from
* the answered block's own id for a question response
* (`packages/chat/src/routes.ts`'s blocks/responses route); absent for
* every ordinary message, which carries no correlation at all.
*/
readonly correlationId?: string;
};

export type SendWorkbenchMessageResult = {
Expand Down Expand Up @@ -1620,6 +1630,9 @@ async function routeToRecipients(
principalId: input.principalId,
recipients,
parts: turnParts,
...(input.correlationId !== undefined
? { correlationId: input.correlationId }
: {}),
},
(batch) =>
dispatchTurnBatch(deps, input.tenantId, input.workbenchId, batch),
Expand Down Expand Up @@ -1664,6 +1677,18 @@ async function dispatchTurnBatch(
const last = batch[batch.length - 1];
if (last === undefined) return;
const messageIds = batch.map((turn) => turn.messageId);
// An answer's correlationId must survive batching regardless of where in
// the batch it landed — unlike `principalId`, which is legitimately
// "whoever sent last," a gate answer is a specific message a specific
// queued turn carries, not necessarily the batch's final one (a further
// unrelated message queued behind the answer, before this batch drains,
// would otherwise make `last.correlationId` undefined and silently strand
// the gate on its timeout instead of resolving it). At most one queued
// turn in a batch carries a correlationId in practice — a batch answering
// more than one live question is not a shape this dispatch produces.
const batchCorrelationId = batch.find(
(turn) => turn.correlationId !== undefined,
)?.correlationId;

// CL-6644: unconditional entry marker — see the matching note on the
// caller's own recipient-resolution log. This is the one line that
Expand Down Expand Up @@ -1730,6 +1755,15 @@ async function dispatchTurnBatch(
agentAddress,
parts,
requestMessageIds: messageIds,
// A batch concatenating more than one queued message's parts
// still stamps the whole combined body as the answer when any
// one of them carries a correlationId — acceptable (the user
// did answer), but a batch mixing the actual answer with an
// unrelated follow-up hands the gate the whole blob, not just
// the answer.
...(batchCorrelationId !== undefined
? { correlationId: batchCorrelationId }
: {}),
}),
turnDispatchTimeoutMs,
turnDispatchTimeoutMessage(agentAddress, turnDispatchTimeoutMs),
Expand Down Expand Up @@ -1774,6 +1808,9 @@ export type DispatchTurnInput = {
readonly parts: PartType[];
/** The room messages this turn answers, in arrival order. */
readonly requestMessageIds: readonly string[];
/** See `SendWorkbenchMessageInput.correlationId`; threaded straight
* through to `WorkbenchMail.sendMail`. */
readonly correlationId?: string;
};

/**
Expand Down Expand Up @@ -1812,6 +1849,9 @@ export async function dispatchTurn(
principalId: input.principalId,
content: encodeParts(input.parts, { replyTo: input.workbenchId }),
fromWorkbenchId: input.workbenchId,
...(input.correlationId !== undefined
? { correlationId: input.correlationId }
: {}),
});
} catch (err) {
if (turn !== undefined) {
Expand Down
111 changes: 111 additions & 0 deletions packages/chat/test/block-responses-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createWorkbench,
fakePlatform,
mountAs,
settleFanout,
TENANT,
timelineEvents,
timelineOf,
Expand Down Expand Up @@ -306,6 +307,116 @@ describe("block response routes — question answers", () => {
});
});

test("answering a question stamps the answer's mail with the block's own id as its correlationId", async () => {
const platform = fakePlatform();
const deps = buildDeps({
platform,
blockResponses: createInMemoryBlockResponseStore(),
});
const app = mountAs(createChatRoutes(deps), "prn_alice");
const { body: workbench } = await createWorkbench(app, {
kind: "workbench",
participants: ["ins_echo1@acme.example"],
});

const post = await app.request(
responsesUrl(workbench.id, "m1", "blk_question1"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ kind: "question", answer: "Staging" }),
},
);
expect(post.status).toBe(200);
await settleFanout();

// The block's own id (`blk_question1`, the same id `postQuestion`
// mints as `questionId`) rides as the answer mail's correlationId —
// the exact id `tryCorrelate` (`vendor/intx/inference/src/reactor.ts`)
// needs to resolve the `message_response` gate this answers, rather
// than "whichever gate is next".
expect(platform.sentMail).toHaveLength(1);
expect(platform.sentMail[0]?.correlationId).toBe("blk_question1");
});

test("a question's correlationId survives batching even when it isn't the batch's last queued message", async () => {
// A held first dispatch forces the answer (queued 2nd) and a further
// plain follow-up (queued 3rd, landing last) into one batched turn —
// proving the batch's correlationId comes from whichever queued turn
// carries one, not from `batch[batch.length - 1]`, which here is the
// follow-up and carries none.
let releaseHold: () => void = () => {};
const held = new Promise<void>((resolve) => {
releaseHold = resolve;
});
let resolveFirstDispatchStarted: () => void = () => {};
const firstDispatchStarted = new Promise<void>((resolve) => {
resolveFirstDispatchStarted = resolve;
});
let holdConsumed = false;

const platform = fakePlatform();
const deliverMail = platform.sendMail.bind(platform);
platform.sendMail = async (input) => {
if (!holdConsumed) {
holdConsumed = true;
resolveFirstDispatchStarted();
await held;
}
return deliverMail(input);
};

const deps = buildDeps({
platform,
blockResponses: createInMemoryBlockResponseStore(),
});
const app = mountAs(createChatRoutes(deps), "prn_alice");
const { body: workbench } = await createWorkbench(app, {
kind: "workbench",
participants: ["ins_echo1@acme.example"],
});

// Message 1 dispatches immediately and is held open.
const first = await app.request(`/workbenches/${workbench.id}/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ kind: "text", text: "hello" }] }),
});
expect(first.status).toBe(201);
await firstDispatchStarted;

// Queued 2nd, while the first dispatch is still held: the answer,
// carrying the block's id as its correlationId.
const post = await app.request(
responsesUrl(workbench.id, "m1", "blk_question1"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ kind: "question", answer: "Staging" }),
},
);
expect(post.status).toBe(200);

// Queued 3rd, landing last in the batch: an unrelated follow-up with
// no correlationId of its own.
const third = await app.request(`/workbenches/${workbench.id}/messages`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
parts: [{ kind: "text", text: "also, one more thing" }],
}),
});
expect(third.status).toBe(201);

releaseHold();
await settleFanout();

// First dispatch (message 1), then one batched dispatch covering
// both the answer and the follow-up.
expect(platform.sentMail).toHaveLength(2);
expect(platform.sentMail[1]?.correlationId).toBe("blk_question1");
});

test("a question response without answer text is rejected", async () => {
const deps = buildDeps({
blockResponses: createInMemoryBlockResponseStore(),
Expand Down
10 changes: 8 additions & 2 deletions packages/chat/test/test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export function fakePlatform(
principalId?: string;
content: MailContent;
fromWorkbenchId?: string;
correlationId?: string;
}[];
launchInviteCalls: {
tenantId: string;
Expand All @@ -96,6 +97,7 @@ export function fakePlatform(
principalId?: string;
content: MailContent;
fromWorkbenchId?: string;
correlationId?: string;
}[] = [];
const launchInviteCalls: {
tenantId: string;
Expand Down Expand Up @@ -172,10 +174,14 @@ export function fakePlatform(
input.principalId !== undefined
? { ...sentMailEntryBase, principalId: input.principalId }
: sentMailEntryBase;
sentMail.push(
const withFromWorkbench =
input.fromWorkbenchId !== undefined
? { ...withPrincipal, fromWorkbenchId: input.fromWorkbenchId }
: withPrincipal,
: withPrincipal;
sentMail.push(
input.correlationId !== undefined
? { ...withFromWorkbench, correlationId: input.correlationId }
: withFromWorkbench,
);
const id = `mail_${++mailCounter}`;
const createdAt = new Date().toISOString();
Expand Down
6 changes: 6 additions & 0 deletions packages/folded-runs/src/mail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export type SendFoldedMailParams = {
attachments?: MessageAttachment[];
replyTo?: string;
cryptoProvider: CryptoProvider;
/** The MIME `Interchange-Correlation-ID` header — set when this message
* answers a specific parked `message_response` gate, absent otherwise. */
correlationId?: string;
};

/**
Expand All @@ -50,6 +53,9 @@ async function deliverFoldedMailMIME(
sessionId: params.sessionId,
tenantId: params.tenantId,
cryptoProvider: params.cryptoProvider,
...(params.correlationId !== undefined
? { correlationId: params.correlationId }
: {}),
};
const withAttachments =
params.attachments !== undefined
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.3",
"version": "0.0.4",
"license": "LGPL-2.1-or-later",
"type": "module",
"exports": {
Expand Down
16 changes: 10 additions & 6 deletions packages/interaction-tools/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,16 @@ 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, since `ask_user`'s tool result names
* it so a caller can correlate a later answer, and the route persists
* responses keyed by `(messageId, blockId)`. `@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.
* 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.
*/
export async function postQuestion(
config: AskUserClientConfig,
Expand Down
2 changes: 1 addition & 1 deletion scripts/checks/kill-dates.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vendor/intx/db | sawyer | 2026-10-26 | 0a4cdb9a8a6ff19d5d4713cbc4f5cc9257aad839b
vendor/intx/harness | sawyer | 2026-10-26 | 867f2b0eb4a360c68bf552d5b95a9530411e2c1a2d046d1f5ce718d4a9be36a8
vendor/intx/hub-agent | sawyer | 2026-10-26 | 30d5050511f22bc73b3c5d34728dfdf5b791de203452b83d4333a1dc762afceb
vendor/intx/hub-api | sawyer | 2026-10-26 | 42ee33e027559b236065382cb94f393bbcfee69625615894f82be778f34f7aa1
vendor/intx/hub-sessions | sawyer | 2026-10-26 | 19fcc2c0bf6af1dd9a1b267513bea777968c454a323cc402fc5ec1cca8097523
vendor/intx/hub-sessions | sawyer | 2026-10-26 | 79a6f534a5166dfc5904d1b82214d1a0f857f2b2bde0b845115e0618f84745f2
vendor/intx/inference | sawyer | 2026-10-26 | f32792856f555b0fed40ce75badde7630a45a6452ad20cfc61058192f8376f11
vendor/intx/mail-memory | sawyer | 2026-10-26 | 9f3601a7fb22e2d1c63daa976f3afccbd79af2187c155a0080c0d60c82450b92
vendor/intx/mailbox | sawyer | 2026-10-26 | d36d7ffcc32018571276e4922a8c2714b7ee0bb5deb80b01e73859245975d4c6
Expand Down
Loading
Loading