Skip to content

Commit 02a55b3

Browse files
authored
fix(decopilot): stream user prompt to other thread viewers (#4461)
* fix(decopilot): stream user prompt to other thread viewers The per-thread run stream carried only assistant/harness chunks, so a second viewer of a shared thread never saw the other user's prompt until a DB refetch. Publish the materialized request message onto the same JetStream subject as a transient `data-user-message` control chunk at POST time (before the run's assistant chunks), reusing publishRawChunk → serializeChunk which already fragments payloads over MAX_PUBLISH_BYTES and drops over MAX_CHUNKED_BYTES. The client intercepts the chunk before the assistant fold (the AI SDK reassembler hardcodes role:"assistant") and upserts it by id via applyLocalMessage, so the author's optimistic copy dedupes while other viewers get it live. The durable projection filters the chunk out; the DB copy remains the emitRequestMessage write. * style(decopilot): biome format user-message stream tests
1 parent acc63c8 commit 02a55b3

6 files changed

Lines changed: 207 additions & 2 deletions

File tree

apps/mesh/src/api/routes/decopilot/project-chunks.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,4 +440,64 @@ describe("projectChunks", () => {
440440
}),
441441
).toBe(true);
442442
});
443+
444+
test("ignores data-user-message chunks when projecting assistant parts", async () => {
445+
const emitted: Array<{ id: string; parts?: unknown[] }> = [];
446+
447+
await projectChunks({
448+
chunks: (async function* () {
449+
yield {
450+
type: "data-user-message",
451+
data: {
452+
id: "u-1",
453+
role: "user",
454+
parts: [{ type: "text", text: "hi" }],
455+
},
456+
} as unknown as UIMessageChunk;
457+
yield { type: "start", messageId: "m-1" } as UIMessageChunk;
458+
yield { type: "text-start", id: "txt" } as UIMessageChunk;
459+
yield {
460+
type: "text-delta",
461+
id: "txt",
462+
delta: "hello",
463+
} as UIMessageChunk;
464+
yield { type: "text-end", id: "txt" } as UIMessageChunk;
465+
yield { type: "finish", finishReason: "stop" } as UIMessageChunk;
466+
})(),
467+
persistence: {
468+
emitStepParts: async (message) => {
469+
emitted.push({ id: message.id, parts: message.parts });
470+
},
471+
emitFinal: async (message) => {
472+
emitted.push({ id: message.id, parts: message.parts });
473+
},
474+
emitError: async () => {},
475+
},
476+
});
477+
478+
// The user-message chunk must not leak into any projected assistant part,
479+
// and the assistant text still folds normally.
480+
expect(
481+
emitted
482+
.flatMap((message) => message.parts ?? [])
483+
.some(
484+
(part) =>
485+
typeof part === "object" &&
486+
part !== null &&
487+
(part as { type?: unknown }).type === "data-user-message",
488+
),
489+
).toBe(false);
490+
expect(emitted.some((m) => m.id === "u-1")).toBe(false);
491+
expect(
492+
emitted
493+
.flatMap((message) => message.parts ?? [])
494+
.some(
495+
(part) =>
496+
typeof part === "object" &&
497+
part !== null &&
498+
(part as { type?: unknown; text?: unknown }).type === "text" &&
499+
(part as { text?: unknown }).text === "hello",
500+
),
501+
).toBe(true);
502+
});
443503
});

apps/mesh/src/api/routes/decopilot/project-chunks.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ import {
55
type HarnessUsage,
66
} from "./consume-harness-stream";
77
import { isRunStatusControlChunk } from "./run-status-stage";
8+
import { isUserMessageControlChunk } from "./user-message-stream";
9+
10+
/** Transient control chunks that ride the run stream for the live UI but must
11+
* never be folded into the durable assistant projection. `fenceFilter`
12+
* already drops the fence-less ones, but filtering by type here keeps the
13+
* projection correct even if one is ever published with a run fence. */
14+
function isTransientControlChunk(chunk: unknown): boolean {
15+
return isRunStatusControlChunk(chunk) || isUserMessageControlChunk(chunk);
16+
}
817

918
/**
1019
* Title persistence for the projector. When supplied, the projector becomes the
@@ -192,7 +201,7 @@ export async function projectChunks(
192201
? (async function* () {
193202
try {
194203
for await (const chunk of options.chunks!) {
195-
if (isRunStatusControlChunk(chunk)) continue;
204+
if (isTransientControlChunk(chunk)) continue;
196205
yield chunk;
197206
}
198207
} catch (e) {
@@ -207,7 +216,7 @@ export async function projectChunks(
207216
}).pipeThrough(
208217
new TransformStream<UIMessageChunk, UIMessageChunk>({
209218
transform(chunk, controller) {
210-
if (!isRunStatusControlChunk(chunk)) controller.enqueue(chunk);
219+
if (!isTransientControlChunk(chunk)) controller.enqueue(chunk);
211220
},
212221
}),
213222
)

apps/mesh/src/api/routes/decopilot/routes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
publishRunStatusStage,
4646
shouldPublishClusterRunStatus,
4747
} from "./run-status-stage";
48+
import { publishUserMessage } from "./user-message-stream";
4849
import { wrapWithSseKeepalive } from "./sse-keepalive";
4950
import { resolveDispatchTarget } from "../../../links/resolve-dispatch-target";
5051
import {
@@ -746,6 +747,10 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
746747
})
747748
) {
748749
await emitter.emitRequestMessage(persistedRequestMessage);
750+
// Mirror the prompt onto the run stream so OTHER viewers of a shared
751+
// thread render it live, not only after a DB refetch. Best-effort and
752+
// published before the run's assistant chunks so it sorts first.
753+
await publishUserMessage(streamBuffer, taskId, persistedRequestMessage);
749754
}
750755

751756
const serializableRequest = buildDurableDispatchInput(input, {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { UIMessage } from "ai";
3+
import {
4+
buildUserMessageChunk,
5+
isUserMessageControlChunk,
6+
publishUserMessage,
7+
USER_MESSAGE_CHUNK_TYPE,
8+
} from "./user-message-stream";
9+
10+
const MESSAGE = {
11+
id: "u-1",
12+
role: "user",
13+
parts: [{ type: "text", text: "hello" }],
14+
} as unknown as UIMessage;
15+
16+
describe("buildUserMessageChunk", () => {
17+
test("wraps the message in a data-user-message chunk", () => {
18+
expect(buildUserMessageChunk(MESSAGE)).toEqual({
19+
type: USER_MESSAGE_CHUNK_TYPE,
20+
data: MESSAGE,
21+
});
22+
});
23+
});
24+
25+
describe("isUserMessageControlChunk", () => {
26+
test("matches the control chunk and nothing else", () => {
27+
expect(isUserMessageControlChunk(buildUserMessageChunk(MESSAGE))).toBe(
28+
true,
29+
);
30+
expect(isUserMessageControlChunk({ type: "data-run-status" })).toBe(false);
31+
expect(isUserMessageControlChunk({ type: "text-delta" })).toBe(false);
32+
expect(isUserMessageControlChunk(null)).toBe(false);
33+
expect(isUserMessageControlChunk("data-user-message")).toBe(false);
34+
});
35+
});
36+
37+
describe("publishUserMessage", () => {
38+
test("publishes the chunk via streamBuffer.publishRawChunk", async () => {
39+
const calls: Array<{ taskId: string; chunk: unknown }> = [];
40+
await publishUserMessage(
41+
{
42+
publishRawChunk: async (taskId, chunk) => {
43+
calls.push({ taskId, chunk });
44+
return true;
45+
},
46+
},
47+
"task-1",
48+
MESSAGE,
49+
);
50+
expect(calls).toEqual([
51+
{ taskId: "task-1", chunk: buildUserMessageChunk(MESSAGE) },
52+
]);
53+
});
54+
55+
test("is a no-op without a stream buffer", async () => {
56+
await expect(
57+
publishUserMessage(undefined, "task-1", MESSAGE),
58+
).resolves.toBeUndefined();
59+
});
60+
61+
test("swallows publish errors (best-effort)", async () => {
62+
await expect(
63+
publishUserMessage(
64+
{
65+
publishRawChunk: async () => {
66+
throw new Error("nats down");
67+
},
68+
},
69+
"task-1",
70+
MESSAGE,
71+
),
72+
).resolves.toBeUndefined();
73+
});
74+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { UIMessage, UIMessageChunk } from "ai";
2+
import type { StreamBuffer } from "./stream-buffer";
3+
4+
/**
5+
* Live mirror of a just-posted USER prompt onto the run stream.
6+
*
7+
* The run's JetStream subject carries only assistant/harness output, so a
8+
* SECOND viewer of a shared thread never saw the other user's prompt until a DB
9+
* refetch. We publish the materialized request message as a transient control
10+
* chunk at POST time (before the run's assistant chunks) so every tailing
11+
* viewer renders it live. It is deliberately fence-less and NOT persisted from
12+
* the stream — the durable copy is the `emitRequestMessage` DB write; the
13+
* projector filters this chunk out (see `project-chunks.ts`).
14+
*
15+
* Large prompts are handled for free: `publishRawChunk` → `serializeChunk`
16+
* fragments anything over `MAX_PUBLISH_BYTES` and drops over `MAX_CHUNKED_BYTES`.
17+
*/
18+
export const USER_MESSAGE_CHUNK_TYPE = "data-user-message";
19+
20+
export type UserMessageChunk = Extract<
21+
UIMessageChunk,
22+
{ type: `data-${string}` }
23+
> & {
24+
type: typeof USER_MESSAGE_CHUNK_TYPE;
25+
data: UIMessage;
26+
};
27+
28+
export function buildUserMessageChunk(message: UIMessage): UserMessageChunk {
29+
return { type: USER_MESSAGE_CHUNK_TYPE, data: message } as UserMessageChunk;
30+
}
31+
32+
export function isUserMessageControlChunk(chunk: unknown): boolean {
33+
if (!chunk || typeof chunk !== "object") return false;
34+
return (chunk as { type?: unknown }).type === USER_MESSAGE_CHUNK_TYPE;
35+
}
36+
37+
export async function publishUserMessage(
38+
streamBuffer: Pick<StreamBuffer, "publishRawChunk"> | undefined,
39+
taskId: string,
40+
message: UIMessage,
41+
): Promise<void> {
42+
if (!streamBuffer) return;
43+
try {
44+
await streamBuffer.publishRawChunk(taskId, buildUserMessageChunk(message));
45+
} catch {
46+
// Best-effort live mirror. Never fail the POST because a viewer hint failed.
47+
}
48+
}

apps/mesh/src/web/components/chat/store/thread-connection.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,15 @@ export class ThreadConnection {
952952
this.chunkBuffer.push(chunk);
953953
return;
954954
}
955+
// A user prompt mirrored onto the run stream (see server
956+
// `user-message-stream.ts`). Intercept BEFORE the assistant fold — the AI
957+
// SDK reassembler hardcodes `role:"assistant"` — and upsert it by id so the
958+
// author's optimistic copy dedupes while other viewers get it live.
959+
if ((chunk as { type?: unknown }).type === "data-user-message") {
960+
const message = (chunk as { data?: UIMessage }).data;
961+
if (message) this.applyLocalMessage(message);
962+
return;
963+
}
955964
const isRunStatusControl = isRunStatusControlChunk(chunk);
956965
const runStatusStage = parseRunStatusStageChunk(chunk);
957966
if (this.waitingForNewRun) {

0 commit comments

Comments
 (0)