From 702cb0d812f5804dc7e0b094c6a15f570ad2d1bc Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:14:56 -0700 Subject: [PATCH] fix(clients): restart continuations show as a T3 Code notice, not another agent's message After a server restart, T3 Code prompts the interrupted turn to continue. That prompt was stored as a user message sent by the agent, so the timeline showed it as a bubble labelled "Sent by another agent". The continuation now carries a `system` notification, like background task and delegated task wakes, so it renders as a collapsed work log row. Expanding the row shows the prompt the agent received. Notices already saved as user messages are labelled "Sent by T3 Code". Co-Authored-By: Claude Opus 5.5 --- .../src/features/threads/ThreadFeed.tsx | 8 +++- apps/mobile/src/lib/threadActivity.test.ts | 20 +++++++- apps/mobile/src/lib/threadActivity.ts | 7 ++- .../src/orchestration-v2/Orchestrator.ts | 8 +++- .../RestartContinuation.test.ts | 26 ++++++++-- .../orchestration-v2/RestartContinuation.ts | 22 ++++++--- .../src/orchestration-v2/runtimeLayer.test.ts | 17 +++++++ .../src/components/chat/MessagesTimeline.tsx | 13 ++++- .../src/state/threadExecution.ts | 1 + .../client-runtime/src/userMessage.test.ts | 31 ++++++++++-- packages/client-runtime/src/userMessage.ts | 48 +++++++++++++++---- packages/contracts/src/orchestrationV2.ts | 2 + 12 files changed, 171 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index a210ffcb1dc6..ee0c4711b03b 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1719,11 +1719,15 @@ function renderFeedEntry( className="mb-5 items-end" {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} > - {presentation.isAutomation ? ( + {presentation.attribution === "automation" ? ( Sent by automation - ) : message.createdBy === "agent" ? ( + ) : presentation.attribution === "t3code" ? ( + + Sent by T3 Code + + ) : presentation.attribution === "agent" ? ( { expect(withoutPath?.canExpand).toBe(false); }); +it("expands a T3 Code notice to the prompt the agent got", () => { + const item = { + ...base("restart-notice", "2026-06-20T00:00:03.000Z", 2), + type: "notification", + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + } as OrchestrationV2TurnItem; + const [activity] = buildThreadFeed([projected(item, 0)]).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + + expect(activity?.summary).toBe("T3 Code restarted and resumed this turn"); + expect(activity?.canExpand).toBe(true); + expect(activity?.getFullDetail()).toBe("Continue where you left off."); +}); + it("labels file searches with the adapter title and its search target", () => { const item: OrchestrationV2TurnItem = { ...base("file-search", "2026-06-20T00:00:03.000Z", 2), @@ -317,7 +335,7 @@ describe("buildThreadFeed", () => { expect(messageEntry).toBeDefined(); expect(resolveUserMessagePresentation(messageEntry!.message)).toMatchObject({ text: "Run checks", - isAutomation: true, + attribution: "automation", }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11..edaaf2b0fab8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -521,6 +521,7 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { case "monitor": return "eye"; case "background_task": + case "system": return "zap"; default: source satisfies never; @@ -814,7 +815,11 @@ function toFeedActivity( ? collectToolFilePaths(item) : null; const getFullDetail = memoizeValue(() => - readPaths ? readPaths.join("\n") || null : formatItemFullDetail(row, item), + readPaths + ? readPaths.join("\n") || null + : item.type === "notification" + ? item.detail?.trim() || null + : formatItemFullDetail(row, item), ); const getCopyText = memoizeValue(() => [summary, detail, getFullDetail()] diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 977b225f5256..43af11bf98a8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4667,11 +4667,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio dispatchMode = { type: "start_immediately" }; } } + // A restart continuation resumes the interrupted turn, so it starts + // immediately rather than queueing like other wakes. if ( command.notification !== undefined && (command.createdBy !== "agent" || (command.creationSource !== "server" && command.creationSource !== "provider") || - dispatchMode.type !== "queue_after_active") + (dispatchMode.type !== "queue_after_active" && + !( + command.restartContinuationOfRunId !== undefined && + dispatchMode.type === "start_immediately" + ))) ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index dcbf4aa63728..34b3d8352c68 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -10,6 +10,7 @@ import { RunAttemptId, RunId, ThreadId, + type OrchestrationV2Command, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -606,9 +607,9 @@ it.effect("does not cancel or resume a run that completes while shutdown intent }), ); -const continuationTexts = (projection: OrchestrationV2ThreadProjection) => +const continuationDispatches = (projection: OrchestrationV2ThreadProjection) => Effect.gen(function* () { - const texts: Array = []; + const dispatches: Array> = []; yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe( Effect.provide( Layer.merge( @@ -616,7 +617,7 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => getThreadRecords: () => Effect.succeed(projection), recoverDelegatedTask: () => Effect.void, dispatch: (command) => { - if (command.type === "message.dispatch") texts.push(command.text); + if (command.type === "message.dispatch") dispatches.push(command); return Effect.succeed({} as never); }, }), @@ -624,9 +625,14 @@ const continuationTexts = (projection: OrchestrationV2ThreadProjection) => ), ), ); - return texts; + return dispatches; }); +const continuationTexts = (projection: OrchestrationV2ThreadProjection) => + continuationDispatches(projection).pipe( + Effect.map((dispatches) => dispatches.map((command) => command.text)), + ); + const cutMidTurn = (extra: Record = {}) => { const base = makeProjection(); return { @@ -740,6 +746,18 @@ it.effect("tells a turn cut mid-way about the background work it lost", () => }), ); +it.effect("shows a continuation as a T3 Code notice whose detail is the prompt", () => + Effect.gen(function* () { + const [resumed] = yield* continuationDispatches(cutMidTurn()); + assert.deepEqual(resumed?.notification, { + source: { kind: "system" }, + outcome: "updated", + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }); + }), +); + it.effect( "carries the note forward when its continuation was cut before reaching the provider", () => diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 013e7a07fbc4..b7e905d0f4a9 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -153,17 +153,27 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta ); const noteText = note.work.length === 0 ? undefined : restartCancelledBackgroundWorkNote(note.work); + const text = + noteText === undefined + ? CONTINUE_PROMPT + : note.settled + ? noteText + : `${noteText}\n\n${CONTINUE_PROMPT}`; yield* threads.dispatch({ type: "message.dispatch", commandId: CommandId.make(`command:restart-continuation:${input.sourceRunId}`), threadId: input.threadId, messageId, - text: - noteText === undefined - ? CONTINUE_PROMPT - : note.settled - ? noteText - : `${noteText}\n\n${CONTINUE_PROMPT}`, + text, + // Shown as a work log row; the prompt the agent got is its detail. + notification: { + source: { kind: "system" }, + outcome: "updated", + summary: note.settled + ? "T3 Code restarted and stopped background work" + : "T3 Code restarted and resumed this turn", + detail: text, + }, attachments: [], modelSelection: source.modelSelection, dispatchMode: { type: "start_immediately" }, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bb70c880b061..3d4bf4c2a64a 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3211,6 +3211,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, messageId: MessageId.make("restart-automatic-message"), text: "Continue where you left off.", + notification: { + source: { kind: "system" as const }, + outcome: "updated" as const, + summary: "T3 Code restarted and resumed this turn", + detail: "Continue where you left off.", + }, attachments: [], modelSelection, dispatchMode: { type: "start_immediately" as const }, @@ -3221,6 +3227,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const admitted = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf(admitted.runs, 2); assert.equal(admitted.runs[1]?.restartContinuationOfRunId, original.id); + // The timeline shows a work log row; the prompt stays in its detail. + const continuationItems = admitted.turnItems.filter( + (item) => item.runId === admitted.runs[1]?.id, + ); + assert.isFalse(continuationItems.some((item) => item.type === "user_message")); + assert.deepInclude( + continuationItems.flatMap((item) => + item.type === "notification" ? [[item.summary, item.detail]] : [], + ), + ["T3 Code restarted and resumed this turn", "Continue where you left off."], + ); // A differently identified stale delivery still must not create another run. yield* orchestrator.dispatch({ ...command, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a3138c29044b..0518c96afc58 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2357,7 +2357,7 @@ function UserTimelineRow({ row }: { row: Extract - {userMessage.isAutomation ? ( + {userMessage.attribution === "automation" ? (

- ) : row.message.createdBy === "agent" ? ( + ) : userMessage.attribution === "t3code" ? ( +

+ Sent by T3 Code +

+ ) : userMessage.attribution === "agent" ? (

{senderThreadId ? ( { it("removes attribution from legacy scheduled prompts", () => { expect( resolveUserMessagePresentation({ role: "user", createdBy: "agent", text: legacyText }), - ).toEqual({ text: "Check for crashes.\n", isAutomation: true, scheduledTaskId: undefined }); + ).toEqual({ + text: "Check for crashes.\n", + attribution: "automation", + scheduledTaskId: undefined, + }); }); it("uses task metadata without changing the prompt", () => { @@ -20,7 +24,7 @@ describe("resolveUserMessagePresentation", () => { scheduledTaskId: ScheduledTaskId.make("task-1"), text: legacyText, }), - ).toEqual({ text: legacyText, isAutomation: true, scheduledTaskId: "task-1" }); + ).toEqual({ text: legacyText, attribution: "automation", scheduledTaskId: "task-1" }); }); it("preserves user-written and assistant-quoted schedule headers", () => { @@ -31,7 +35,7 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ ...message, text: legacyText })).toEqual({ text: legacyText, - isAutomation: false, + attribution: null, scheduledTaskId: undefined, }); } @@ -45,12 +49,29 @@ describe("resolveUserMessagePresentation", () => { ]) { expect(resolveUserMessagePresentation({ role: "user", createdBy: "agent", text })).toEqual({ text, - isAutomation: false, + attribution: "agent", scheduledTaskId: undefined, }); } }); + it("attributes older server-sent restart continuations to T3 Code, not another agent", () => { + const text = "Note: the T3 server restarted."; + expect( + resolveUserMessagePresentation({ + role: "user", + createdBy: "agent", + creationSource: "server", + text, + }), + ).toMatchObject({ attribution: "t3code" }); + for (const creationSource of ["mcp", "provider"] as const) { + expect( + resolveUserMessagePresentation({ role: "user", createdBy: "agent", creationSource, text }), + ).toMatchObject({ attribution: "agent" }); + } + }); + it("recovers the triggering automation from older scheduler message ids", () => { for (const trigger of ["scheduled", "manual"]) { expect( @@ -62,7 +83,7 @@ describe("resolveUserMessagePresentation", () => { }), ).toEqual({ text: "Check for crashes.\n", - isAutomation: true, + attribution: "automation", scheduledTaskId: "task:daily-audit", }); } diff --git a/packages/client-runtime/src/userMessage.ts b/packages/client-runtime/src/userMessage.ts index 5acad0a44802..b292bafe0ca4 100644 --- a/packages/client-runtime/src/userMessage.ts +++ b/packages/client-runtime/src/userMessage.ts @@ -1,31 +1,59 @@ -import { type OrchestrationV2Actor, ScheduledTaskId } from "@t3tools/contracts"; +import { + type OrchestrationV2Actor, + type OrchestrationV2CreationSource, + ScheduledTaskId, +} from "@t3tools/contracts"; const LEGACY_AUTOMATION_PREFIX = /^\[Triggered by schedule task: [^\r\n]+\]\r?\n\r?\n/; const LEGACY_AUTOMATION_MESSAGE_ID = /^scheduled-task-message:(.+):\d+:(?:scheduled|manual)$/; -/** Older scheduled messages stored their attribution in the prompt itself. */ +/** + * Text and sender label for a user-role message. Older scheduled messages + * stored their attribution in the prompt itself. + */ export function resolveUserMessagePresentation(message: { readonly id?: string; readonly role: string; readonly text: string; readonly createdBy?: OrchestrationV2Actor; + readonly creationSource?: OrchestrationV2CreationSource; readonly scheduledTaskId?: ScheduledTaskId; -}) { +}): { + readonly text: string; + /** Who sent a user-role message the user did not type. */ + readonly attribution: "automation" | "agent" | "t3code" | null; + readonly scheduledTaskId: ScheduledTaskId | undefined; +} { if (message.role !== "user") { - return { text: message.text, isAutomation: false, scheduledTaskId: undefined }; + return { text: message.text, attribution: null, scheduledTaskId: undefined }; } if (message.scheduledTaskId !== undefined) { - return { text: message.text, isAutomation: true, scheduledTaskId: message.scheduledTaskId }; + return { + text: message.text, + attribution: "automation", + scheduledTaskId: message.scheduledTaskId, + }; } const legacyPrefix = LEGACY_AUTOMATION_PREFIX.exec(message.text); const legacyTaskId = legacyPrefix ? LEGACY_AUTOMATION_MESSAGE_ID.exec(message.id ?? "")?.[1] : undefined; - const isAutomation = - legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent"); + if (legacyPrefix !== null && (legacyTaskId !== undefined || message.createdBy === "agent")) { + return { + text: message.text.slice(legacyPrefix[0].length), + attribution: "automation", + scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + }; + } return { - text: isAutomation ? message.text.slice(legacyPrefix[0].length) : message.text, - isAutomation, - scheduledTaskId: legacyTaskId === undefined ? undefined : ScheduledTaskId.make(legacyTaskId), + text: message.text, + // Restart continuations were sent as the agent before they became notices. + attribution: + message.createdBy !== "agent" + ? null + : message.creationSource === "server" + ? "t3code" + : "agent", + scheduledTaskId: undefined, }; } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 591420ce73c6..c1535756f350 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1095,6 +1095,8 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( CommandNotificationSource, Schema.Struct({ kind: Schema.Literal("monitor") }), Schema.Struct({ kind: Schema.Literal("background_task") }), + /** T3 Code itself, such as a restart continuing an interrupted turn. */ + Schema.Struct({ kind: Schema.Literal("system") }), ], (kind) => Schema.Struct({ kind }), () => ({ kind: "background_task" }),