authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 23:09:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:09:20-07:00
loga7223f3969428170cc13bd7859aa59098b2f94b1
tree4f8ceeb3626f25899c0ee918e037d6a6237ccfcb
parent9cc57680cb5f762f553acd1fa5f3efcf17d8fc67
signature Signed by PGP key B5690EEEBB952194

fix(clients): restart continuations show as a T3 Code notice, not another agent's message (#17723)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

12 files changed, 171 insertions(+), 32 deletions(-)

apps/mobile/src/features/threads/ThreadFeed.tsx+6-2
......@@ -1719,11 +1719,15 @@ function renderFeedEntry(
17191719 className="mb-5 items-end"
17201720 {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})}
17211721 >
1722 {presentation.isAutomation ? (
1722 {presentation.attribution === "automation" ? (
17231723 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
17241724 Sent by automation
17251725 </Text>
1726 ) : message.createdBy === "agent" ? (
1726 ) : presentation.attribution === "t3code" ? (
1727 <Text className="mb-1 pr-1 font-t3-medium text-2xs text-foreground-muted opacity-60">
1728 Sent by T3 Code
1729 </Text>
1730 ) : presentation.attribution === "agent" ? (
17271731 <AgentMessageAttribution
17281732 environmentId={props.environmentId}
17291733 senderThreadId={message.senderThreadId}
apps/mobile/src/lib/threadActivity.test.ts+19-1
......@@ -85,6 +85,24 @@ it("shows only the structured path in expanded mobile read details", () => {
8585 expect(withoutPath?.canExpand).toBe(false);
8686});
8787
88it("expands a T3 Code notice to the prompt the agent got", () => {
89 const item = {
90 ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2),
91 type: "notification",
92 source: { kind: "system" },
93 outcome: "updated",
94 summary: "T3 Code restarted and resumed this turn",
95 detail: "Continue where you left off.",
96 } as OrchestrationV2TurnItem;
97 const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) =>
98 entry.type === "activity-group" ? entry.activities : [],
99 );
100
101 expect(activity?.summary).toBe("T3 Code restarted and resumed this turn");
102 expect(activity?.canExpand).toBe(true);
103 expect(activity?.getFullDetail()).toBe("Continue where you left off.");
104});
105
88106it("labels file searches with the adapter title and its search target", () => {
89107 const item: OrchestrationV2TurnItem = {
90108 ...base("file-search", "2026-06-20T00:00:03.000Z", 2),
......@@ -317,7 +335,7 @@ describe("buildThreadFeed", () => {
317335 expect(messageEntry).toBeDefined();
318336 expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({
319337 text: "Run checks",
320 isAutomation: true,
338 attribution: "automation",
321339 });
322340 });
323341
apps/mobile/src/lib/threadActivity.ts+6-1
......@@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] {
521521 case "monitor":
522522 return "eye";
523523 case "background_task":
524 case "system":
524525 return "zap";
525526 default:
526527 source satisfies never;
......@@ -814,7 +815,11 @@ function toFeedActivity(
814815 ? collectToolFilePaths(item)
815816 : null;
816817 const getFullDetail = memoizeValue(() =>
817 readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item),
818 readPaths
819 ? readPaths.join("\n") || null
820 : item.type === "notification"
821 ? item.detail?.trim() || null
822 : formatItemFullDetail(row, item),
818823 );
819824 const getCopyText = memoizeValue(() =>
820825 [summary, detail, getFullDetail()]
apps/server/src/orchestration-v2/Orchestrator.ts+7-1
......@@ -4674,11 +4674,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
46744674 dispatchMode = { type: "start_immediately" };
46754675 }
46764676 }
4677 // A restart continuation resumes the interrupted turn, so it starts
4678 // immediately rather than queueing like other wakes.
46774679 if (
46784680 command.notification !== undefined &&
46794681 (command.createdBy !== "agent" ||
46804682 (command.creationSource !== "server" && command.creationSource !== "provider") ||
4681 dispatchMode.type !== "queue_after_active")
4683 (dispatchMode.type !== "queue_after_active" &&
4684 !(
4685 command.restartContinuationOfRunId !== undefined &&
4686 dispatchMode.type === "start_immediately"
4687 )))
46824688 ) {
46834689 return yield* new OrchestratorDispatchError({
46844690 commandId: command.commandId,
apps/server/src/orchestration-v2/RestartContinuation.test.ts+22-4
......@@ -10,6 +10,7 @@ import {
1010 RunAttemptId,
1111 RunId,
1212 ThreadId,
13 type OrchestrationV2Command,
1314 type OrchestrationV2ThreadProjection,
1415} from "@t3tools/contracts";
1516import * as DateTime from "effect/DateTime";
......@@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent
606607 }),
607608);
608609
609const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
610const continuationDispatches = (projection: OrchestrationV2ThreadProjection) =>
610611 Effect.gen(function* () {
611 const texts: Array<string> = [];
612 const dispatches: Array<Extract<OrchestrationV2Command, { type: "message.dispatch" }>> = [];
612613 yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe(
613614 Effect.provide(
614615 Layer.merge(
......@@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
616617 getThreadRecords: () => Effect.succeed(projection),
617618 recoverDelegatedTask: () => Effect.void,
618619 dispatch: (command) => {
619 if (command.type === "message.dispatch") texts.push(command.text);
620 if (command.type === "message.dispatch") dispatches.push(command);
620621 return Effect.succeed({} as never);
621622 },
622623 }),
......@@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
624625 ),
625626 ),
626627 );
627 return texts;
628 return dispatches;
628629 });
629630
631const continuationTexts = (projection: OrchestrationV2ThreadProjection) =>
632 continuationDispatches(projection).pipe(
633 Effect.map((dispatches) => dispatches.map((command) => command.text)),
634 );
635
630636const cutMidTurn = (extra: Record<string, unknown> = {}) => {
631637 const base = makeProjection();
632638 return {
......@@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () =>
740746 }),
741747);
742748
749it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () =>
750 Effect.gen(function* () {
751 const [resumed] = yield* continuationDispatches(cutMidTurn());
752 assert.deepEqual(resumed?.notification, {
753 source: { kind: "system" },
754 outcome: "updated",
755 summary: "T3 Code restarted and resumed this turn",
756 detail: "Continue where you left off.",
757 });
758 }),
759);
760
743761it.effect(
744762 "carries the note forward when its continuation was cut before reaching the provider",
745763 () =>
apps/server/src/orchestration-v2/RestartContinuation.ts+16-6
......@@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
153153 );
154154 const noteText =
155155 note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work);
156 const text =
157 noteText === undefined
158 ? CONTINUE_PROMPT
159 : note.settled
160 ? noteText
161 : `${noteText}\n\n${CONTINUE_PROMPT}`;
156162 yield* threads.dispatch({
157163 type: "message.dispatch",
158164 commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`),
159165 threadId: input.threadId,
160166 messageId,
161 text:
162 noteText === undefined
163 ? CONTINUE_PROMPT
164 : note.settled
165 ? noteText
166 : `${noteText}\n\n${CONTINUE_PROMPT}`,
167 text,
168 // Shown as a work log row; the prompt the agent got is its detail.
169 notification: {
170 source: { kind: "system" },
171 outcome: "updated",
172 summary: note.settled
173 ? "T3 Code restarted and stopped background work"
174 : "T3 Code restarted and resumed this turn",
175 detail: text,
176 },
167177 attachments: [],
168178 modelSelection: source.modelSelection,
169179 dispatchMode: { type: "start_immediately" },
apps/server/src/orchestration-v2/runtimeLayer.test.ts+17
......@@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
32113211 threadId,
32123212 messageId: MessageId.make("restart-automatic-message"),
32133213 text: "Continue where you left off.",
3214 notification: {
3215 source: { kind: "system" as const },
3216 outcome: "updated" as const,
3217 summary: "T3 Code restarted and resumed this turn",
3218 detail: "Continue where you left off.",
3219 },
32143220 attachments: [],
32153221 modelSelection,
32163222 dispatchMode: { type: "start_immediately" as const },
......@@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
32213227 const admitted = yield* orchestrator.getThreadProjection(threadId);
32223228 assert.lengthOf(admitted.runs, 2);
32233229 assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id);
3230 // The timeline shows a work log row; the prompt stays in its detail.
3231 const continuationItems = admitted.turnItems.filter(
3232 (item) => item.runId === admitted.runs[1]?.id,
3233 );
3234 assert.isFalse(continuationItems.some((item) => item.type === "user_message"));
3235 assert.deepInclude(
3236 continuationItems.flatMap((item) =>
3237 item.type === "notification" ? [[item.summary, item.detail]] : [],
3238 ),
3239 ["T3 Code restarted and resumed this turn", "Continue where you left off."],
3240 );
32243241 // A differently identified stale delivery still must not create another run.
32253242 yield* orchestrator.dispatch({
32263243 ...command,
apps/web/src/components/chat/MessagesTimeline.tsx+11-2
......@@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
23572357
23582358 return (
23592359 <div className="group flex flex-col items-end gap-1">
2360 {userMessage.isAutomation ? (
2360 {userMessage.attribution === "automation" ? (
23612361 <p
23622362 className="me-1 text-2xs text-muted-foreground/70"
23632363 data-user-message-attribution="automation"
......@@ -2377,7 +2377,14 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
23772377 "Sent by automation"
23782378 )}
23792379 </p>
2380 ) : row.message.createdBy === "agent" ? (
2380 ) : userMessage.attribution === "t3code" ? (
2381 <p
2382 className="me-1 text-2xs text-muted-foreground/70"
2383 data-user-message-attribution="t3code"
2384 >
2385 Sent by T3 Code
2386 </p>
2387 ) : userMessage.attribution === "agent" ? (
23812388 <p className="me-1 text-2xs text-muted-foreground/70" data-user-message-attribution="agent">
23822389 {senderThreadId ? (
23832390 <InlineButton
......@@ -5276,6 +5283,8 @@ function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName {
52765283 return "eye";
52775284 case "background_task":
52785285 return "zap";
5286 case "system":
5287 return "t3-code";
52795288 default:
52805289 source satisfies never;
52815290 return "zap";
packages/client-runtime/src/state/threadExecution.ts+1
......@@ -444,6 +444,7 @@ export function notificationChildThreadId(
444444 case "command":
445445 case "monitor":
446446 case "background_task":
447 case "system":
447448 return undefined;
448449 default:
449450 source satisfies never;
packages/client-runtime/src/userMessage.test.ts+26-5
......@@ -9,7 +9,11 @@ describe("resolveUserMessagePresentation", () => {
99 it("removes attribution from legacy scheduled prompts", () => {
1010 expect(
1111 resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }),
12 ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined });
12 ).toEqual({
13 text: "Check for crashes.\n",
14 attribution: "automation",
15 scheduledTaskId: undefined,
16 });
1317 });
1418
1519 it("uses task metadata without changing the prompt", () => {
......@@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => {
2024 scheduledTaskId: ScheduledTaskId.make("task-1"),
2125 text: legacyText,
2226 }),
23 ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" });
27 ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" });
2428 });
2529
2630 it("preserves user-written and assistant-quoted schedule headers", () => {
......@@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => {
3135 ]) {
3236 expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({
3337 text: legacyText,
34 isAutomation: false,
38 attribution: null,
3539 scheduledTaskId: undefined,
3640 });
3741 }
......@@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => {
4549 ]) {
4650 expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({
4751 text,
48 isAutomation: false,
52 attribution: "agent",
4953 scheduledTaskId: undefined,
5054 });
5155 }
5256 });
5357
58 it("attributes older server-sent restart continuations to T3 Code, not another agent", () => {
59 const text = "Note: the T3 server restarted.";
60 expect(
61 resolveUserMessagePresentation({
62 role: "user",
63 createdBy: "agent",
64 creationSource: "server",
65 text,
66 }),
67 ).toMatchObject({ attribution: "t3code" });
68 for (const creationSource of ["mcp", "provider"] as const) {
69 expect(
70 resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }),
71 ).toMatchObject({ attribution: "agent" });
72 }
73 });
74
5475 it("recovers the triggering automation from older scheduler message ids", () => {
5576 for (const trigger of ["scheduled", "manual"]) {
5677 expect(
......@@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => {
6283 }),
6384 ).toEqual({
6485 text: "Check for crashes.\n",
65 isAutomation: true,
86 attribution: "automation",
6687 scheduledTaskId: "task:daily-audit",
6788 });
6889 }
packages/client-runtime/src/userMessage.ts+38-10
......@@ -1,31 +1,59 @@
1import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts";
1import {
2 type OrchestrationV2Actor,
3 type OrchestrationV2CreationSource,
4 ScheduledTaskId,
5} from "@t3tools/contracts";
26
37const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/;
48const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/;
59
6/** Older scheduled messages stored their attribution in the prompt itself. */
10/**
11 * Text and sender label for a user-role message. Older scheduled messages
12 * stored their attribution in the prompt itself.
13 */
714export function resolveUserMessagePresentation(message: {
815 readonly id?: string;
916 readonly role: string;
1017 readonly text: string;
1118 readonly createdBy?: OrchestrationV2Actor;
19 readonly creationSource?: OrchestrationV2CreationSource;
1220 readonly scheduledTaskId?: ScheduledTaskId;
13}) {
21}): {
22 readonly text: string;
23 /** Who sent a user-role message the user did not type. */
24 readonly attribution: "automation" | "agent" | "t3code" | null;
25 readonly scheduledTaskId: ScheduledTaskId | undefined;
26} {
1427 if (message.role !== "user") {
15 return { text: message.text, isAutomation: false, scheduledTaskId: undefined };
28 return { text: message.text, attribution: null, scheduledTaskId: undefined };
1629 }
1730 if (message.scheduledTaskId !== undefined) {
18 return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId };
31 return {
32 text: message.text,
33 attribution: "automation",
34 scheduledTaskId: message.scheduledTaskId,
35 };
1936 }
2037 const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text);
2138 const legacyTaskId = legacyPrefix
2239 ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1]
2340 : undefined;
24 const isAutomation =
25 legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent");
41 if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) {
42 return {
43 text: message.text.slice(legacyPrefix[0].length),
44 attribution: "automation",
45 scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId),
46 };
47 }
2648 return {
27 text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text,
28 isAutomation,
29 scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId),
49 text: message.text,
50 // Restart continuations were sent as the agent before they became notices.
51 attribution:
52 message.createdBy !== "agent"
53 ? null
54 : message.creationSource === "server"
55 ? "t3code"
56 : "agent",
57 scheduledTaskId: undefined,
3058 };
3159}
packages/contracts/src/orchestrationV2.ts+2
......@@ -1097,6 +1097,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback(
10971097 CommandNotificationSource,
10981098 Schema.Struct({ kind: Schema.Literal("monitor") }),
10991099 Schema.Struct({ kind: Schema.Literal("background_task") }),
1100 /** T3 Code itself, such as a restart continuing an interrupted turn. */
1101 Schema.Struct({ kind: Schema.Literal("system") }),
11001102 ],
11011103 (kind) => Schema.Struct({ kind }),
11021104 () => ({ kind: "background_task" }),