From f3c875206e95c6e6bb8cc45ce94c4299d5bc99c9 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 23 Sep 2026 20:23:04 -0400 Subject: [PATCH] fix(clients): Present legacy wakes as notifications --- apps/mobile/src/lib/threadActivity.test.ts | 58 ++++++++ apps/mobile/src/lib/threadActivity.ts | 14 +- apps/web/src/session-logic.test.ts | 49 +++++++ apps/web/src/session-logic.ts | 13 +- .../src/state/turnItemPresentation.test.ts | 127 +++++++++++++++++- .../src/state/turnItemPresentation.ts | 48 +++++++ packages/contracts/src/orchestrationV2.ts | 9 ++ 7 files changed, 310 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index e5ddde4993b6..5f3e2bf0815e 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -2251,6 +2251,64 @@ it("renders automatic completion as a neutral activity while retaining its detai expect(buildThreadFeed([projected(userMessage(), 0)])[0]?.type).toBe("message"); }); +it("keeps historical trial wakes outside compact folds without hiding user text", () => { + const wake: OrchestrationV2TurnItem = { + ...userMessage(), + type: "user_message", + messageId: MessageId.make("legacy-wake"), + createdBy: "agent", + creationSource: "provider", + inputIntent: "turn_start", + attachments: [], + text: "Background task completed.", + providerWake: { kind: "background_task", count: 1 }, + }; + const feed = buildThreadFeed( + [projected(wake, 0), projected(command(), 1), projected(assistantMessage(), 2)], + { + anchoredMessages: [ + { + id: wake.messageId, + role: "user", + text: wake.text, + streaming: false, + createdAt: "2026-06-20T00:00:01.000Z", + updatedAt: "2026-06-20T00:00:01.000Z", + }, + ], + }, + ); + expect(feed.some((entry) => entry.type === "message" && entry.id === wake.messageId)).toBe(false); + const attributed = buildThreadFeed([projected({ ...wake, senderThreadId: sourceThreadId }, 0)]); + expect(attributed).toHaveLength(1); + expect(attributed[0]).toMatchObject({ + type: "message", + message: { text: wake.text, senderThreadId: sourceThreadId, sourceThreadId: threadId }, + }); + expect(feed[0]?.type).toBe("activity-group"); + const presented = deriveThreadFeedPresentation( + feed, + { + runId, + status: "completed", + startedAt: "2026-06-20T00:00:01.000Z", + completedAt: "2026-06-20T00:00:03.000Z", + }, + new Set(), + ); + expect( + presented.some( + (entry) => + entry.type === "activity-group" && + entry.activities.some((activity) => activity.summary === "Background task finished"), + ), + ).toBe(true); + expect( + buildThreadFeed([projected({ ...wake, createdBy: "user", creationSource: "mobile" }, 0)])[0] + ?.type, + ).toBe("message"); +}); + it("uses a compact reasoning preview and a short expanded heading", () => { const entry = { id: "thought", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d06af828ee11..017b0ce5c9b7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -3,7 +3,10 @@ import type { ThreadPendingUserInput, ThreadUserInputQuestion, } from "@t3tools/client-runtime/state/thread-requests"; -import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; +import { + presentLegacyWakeItem, + turnItemIsWorkspacePreparation, +} from "@t3tools/client-runtime/state/turn-item-presentation"; import { formatSubagentDisplayTitle } from "@t3tools/client-runtime/state/subagent-display"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; @@ -1755,8 +1758,12 @@ export function buildThreadFeed( : [], ), ); + const legacyWakeMessageIds = new Set(); for (const row of visibleTurnItems) { - const item = row.item; + const item = presentLegacyWakeItem(row.item); + if (row.item.type === "user_message" && item.type === "notification") { + legacyWakeMessageIds.add(row.item.messageId); + } if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; @@ -1842,7 +1849,7 @@ export function buildThreadFeed( entries.push(entry); continue; } - const activity = toFeedActivity(row, attemptId); + const activity = toFeedActivity(item === row.item ? row : { ...row, item }, attemptId); const entry: RawThreadFeedEntry = { type: "activity", id: activity.id, @@ -1855,6 +1862,7 @@ export function buildThreadFeed( } const retainedMessageIds = new Set([ ...foldedAnswerMessageIds, + ...legacyWakeMessageIds, ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])), ]); const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f7d61b8f65a7..25e81c1d0ec5 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1828,5 +1828,54 @@ it("renders automatic completion as a work entry instead of a user bubble", () = }, ], })[0]?.kind, + ).toBe("work"); + const ordinaryUser = { + ...item, + type: "user_message" as const, + messageId: MessageId.make("ordinary-user"), + createdBy: "user" as const, + creationSource: "web" as const, + inputIntent: "turn_start" as const, + attachments: [], + text: "Delegated task node:task-1 reached a terminal state.", + }; + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages: [], + visibleTurnItems: [{ ...row, item: ordinaryUser }], + })[0]?.kind, ).toBe("message"); + const legacyWake = { + ...ordinaryUser, + createdBy: "agent" as const, + creationSource: "server" as const, + }; + const optimisticMessages = [ + { + id: legacyWake.messageId, + role: "user" as const, + text: legacyWake.text, + runId: null, + streaming: false, + createdAt: "2026-09-09T00:00:00Z", + updatedAt: "2026-09-09T00:00:00Z", + }, + ]; + const legacyEntries = deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages, + visibleTurnItems: [{ ...row, item: legacyWake }], + }); + expect(legacyEntries).toHaveLength(1); + expect(legacyEntries[0]?.kind).toBe("work"); + const attributedEntries = deriveTimelineEntriesFromVisibleTurnItems({ + optimisticMessages, + visibleTurnItems: [ + { ...row, item: { ...legacyWake, senderThreadId: ThreadId.make("sender") } }, + ], + }); + expect(attributedEntries).toHaveLength(1); + expect(attributedEntries[0]).toMatchObject({ + kind: "message", + message: { text: legacyWake.text, senderThreadId: "sender" }, + }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 9ce8167251b8..4f39936b9803 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -37,7 +37,10 @@ import type { } from "@t3tools/client-runtime/state/thread-requests"; import type { ThreadRunSummary, ThreadRuntimeSummary } from "@t3tools/client-runtime/state/shell"; import { threadRuntimeHasInterruptibleRun } from "@t3tools/client-runtime/state/thread-execution"; -import { turnItemIsWorkspacePreparation } from "@t3tools/client-runtime/state/turn-item-presentation"; +import { + presentLegacyWakeItem, + turnItemIsWorkspacePreparation, +} from "@t3tools/client-runtime/state/turn-item-presentation"; import { isImageAttachment, @@ -624,8 +627,12 @@ export function deriveTimelineEntriesFromVisibleTurnItems( : [], ), ); - for (const row of input.visibleTurnItems) { - const { item } = row; + for (const sourceRow of input.visibleTurnItems) { + const item = presentLegacyWakeItem(sourceRow.item); + const row = item === sourceRow.item ? sourceRow : { ...sourceRow, item }; + if (sourceRow.item.type === "user_message" && item.type === "notification") { + committedMessageIds.add(sourceRow.item.messageId); + } if (turnItemIsWorkspacePreparation(item)) continue; // Task progress belongs in the composer, not between conversation entries. if (item.type === "todo_list" || item.type === "checkpoint") continue; diff --git a/packages/client-runtime/src/state/turnItemPresentation.test.ts b/packages/client-runtime/src/state/turnItemPresentation.test.ts index bfccbe63cf5f..3390ce380567 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.test.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.test.ts @@ -1,20 +1,28 @@ import { + MessageId, + OrchestrationV2TurnItem, + OrchestrationV2TurnItemJson, ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, RunId, ThreadId, TurnItemId, type OrchestrationV2Run, - type OrchestrationV2TurnItem, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; import { + presentLegacyWakeItem, turnItemIsWorkspacePreparation, workspacePreparationRetryRunIds, } from "./turnItemPresentation.ts"; -function command(input: string): OrchestrationV2TurnItem { +const encodeTurnItemJson = Schema.encodeSync(OrchestrationV2TurnItemJson); +const decodeDurableTurnItem = Schema.decodeSync(Schema.fromJsonString(OrchestrationV2TurnItemJson)); +const decodeWireTurnItem = Schema.decodeSync(OrchestrationV2TurnItem); + +function command(input: string): Extract { const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z"); return { id: TurnItemId.make("item-command"), @@ -70,6 +78,121 @@ function run( }; } +function wake( + overrides: Partial> = {}, +): Extract { + return { + ...command("ignored"), + type: "user_message", + messageId: MessageId.make("old-wake"), + createdBy: "agent", + creationSource: "provider", + inputIntent: "turn_start", + text: "Background task completed.", + attachments: [], + ...overrides, + }; +} + +describe("legacy trial wake presentation", () => { + it("preserves authoritative metadata through durable JSON and live wire decoding", () => { + const original = wake({ + text: "Provider-specific payload without the sentinel", + providerWake: { kind: "background_command", count: 2 }, + }); + const encoded = encodeTurnItemJson(original); + const durable = decodeDurableTurnItem(JSON.stringify(encoded)); + const wire = decodeWireTurnItem(durable); + expect(wire).toMatchObject({ + providerWake: original.providerWake, + createdBy: "agent", + creationSource: "provider", + }); + expect(presentLegacyWakeItem(wire)).toMatchObject({ + type: "notification", + source: { kind: "command" }, + summary: "2 background tasks finished", + detail: original.text, + }); + expect(wire.type).toBe("user_message"); + }); + + it.each([ + ["Background task completed.", "provider", "background_task", "Background task finished"], + [ + "Background command completed (exit -1): command", + "provider", + "command", + "Background task finished", + ], + [ + "Delegated task node:task-1 reached a terminal state. Read the result.", + "server", + "delegated_task", + "Delegated task finished", + ], + [ + "Delegated tasks node:task-1, node:task-2 reached terminal states.", + "server", + "delegated_task", + "2 delegated tasks finished", + ], + ] as const)( + "recognizes the old %s sentinel only with authoritative provenance", + (text, creationSource, kind, summary) => { + expect(presentLegacyWakeItem(wake({ text, creationSource }))).toMatchObject({ + type: "notification", + source: { kind }, + summary, + }); + const user = wake({ text, creationSource, createdBy: "user" }); + expect(presentLegacyWakeItem(user)).toBe(user); + const web = wake({ text, creationSource: "web" }); + expect(presentLegacyWakeItem(web)).toBe(web); + }, + ); + + it("does not trust wake-shaped metadata on an ordinary user or unrelated agent message", () => { + for (const item of [ + wake({ createdBy: "user", providerWake: { kind: "background_task", count: 1 } }), + wake({ text: "Explain this example: Background task completed." }), + wake({ creationSource: "server" }), + ]) + expect(presentLegacyWakeItem(item)).toBe(item); + }); + + it.each([ + ["provider", "Background task completed."], + ["provider", "Background command completed (exit 0): build"], + ["server", "Delegated task node:task-1 reached a terminal state."], + ] as const)( + "retains sender attribution on %s messages resembling wakes", + (creationSource, text) => { + const original = wake({ + creationSource, + text, + senderThreadId: ThreadId.make("sender-thread"), + providerWake: { kind: "background_task", count: 1 }, + }); + const durable = decodeDurableTurnItem(JSON.stringify(encodeTurnItemJson(original))); + const wire = decodeWireTurnItem(durable); + expect(wire).toMatchObject({ senderThreadId: original.senderThreadId }); + expect(presentLegacyWakeItem(wire)).toBe(wire); + }, + ); + + it("leaves upstream typed notifications unchanged", () => { + const item: OrchestrationV2TurnItem = { + ...command("ignored"), + type: "notification", + source: { kind: "monitor" }, + outcome: "failed", + summary: "Build failed", + }; + expect(presentLegacyWakeItem(item)).toBe(item); + }); +}); + describe("turnItemIsWorkspacePreparation", () => { it("identifies the synthetic workspace preparation command", () => { expect(turnItemIsWorkspacePreparation(command("Preparing workspace"))).toBe(true); diff --git a/packages/client-runtime/src/state/turnItemPresentation.ts b/packages/client-runtime/src/state/turnItemPresentation.ts index 5268aee52321..ed2ded4a6a4a 100644 --- a/packages/client-runtime/src/state/turnItemPresentation.ts +++ b/packages/client-runtime/src/state/turnItemPresentation.ts @@ -1,11 +1,59 @@ import { ORCHESTRATION_V2_WORKSPACE_PREPARATION_FAILURE_CODE, type OrchestrationV2Run, + NodeId, + type OrchestrationV2Notification, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; const WORKSPACE_PREPARATION_INPUT = "Preparing workspace"; +/** Present old trial wakes through the notification UI, without changing durable data. */ +export function presentLegacyWakeItem(item: OrchestrationV2TurnItem): OrchestrationV2TurnItem { + if (item.type !== "user_message" || item.createdBy !== "agent") return item; + if (item.senderThreadId !== undefined) return item; + + let notification: OrchestrationV2Notification | undefined; + if (item.creationSource === "provider") { + const commandCount = [ + ...item.text.matchAll(/^Background command completed(?: \(exit -?\d+\))?:/gmu), + ].length; + const taskCount = [...item.text.matchAll(/^Background task completed\./gmu)].length; + const legacyKind = + item.providerWake?.kind ?? (commandCount > 0 ? "background_command" : "background_task"); + const count = item.providerWake?.count ?? commandCount + taskCount; + if (count > 0) { + notification = { + source: { kind: legacyKind === "background_command" ? "command" : "background_task" }, + outcome: "unknown", + summary: count > 1 ? `${count} background tasks finished` : "Background task finished", + detail: item.text, + }; + } + } else if (item.creationSource === "server") { + const taskIds = [ + ...new Set( + [...item.text.matchAll(/^Delegated tasks? (.+?) reached (?:a )?terminal states?\./gmu)] + .flatMap((match) => (match[1] ?? "").split(", ")) + .map((id) => id.trim()) + .filter((id) => id.length > 0), + ), + ].map((id) => NodeId.make(id)); + if (taskIds.length > 0) { + notification = { + source: { kind: "delegated_task", taskIds }, + outcome: "unknown", + summary: + taskIds.length > 1 + ? `${taskIds.length} delegated tasks finished` + : "Delegated task finished", + detail: item.text, + }; + } + } + return notification === undefined ? item : { ...item, type: "notification", ...notification }; +} + /** * Workspace setup is client bookkeeping; preparation failures have their own * error item. A retry cancels that item, which then has nothing left to say. diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 591420ce73c6..84843c1f2452 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1371,6 +1371,13 @@ const OrchestrationV2SecretRequestFields = { secretStatus: OrchestrationV2SecretRequestStatus, } as const; +// Read compatibility for historical trial user-message wakes only. New events +// use OrchestrationV2Notification; this does not restore a wake producer. +const LegacyProviderWake = Schema.Struct({ + kind: Schema.Literals(["background_command", "background_task"]), + count: PositiveInt, +}); + export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -1380,6 +1387,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, ...OrchestrationV2CreationFields, + providerWake: Schema.optional(LegacyProviderWake), type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId), @@ -2159,6 +2167,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields, ...OrchestrationV2CreationFields, + providerWake: Schema.optional(LegacyProviderWake), type: Schema.Literal("user_message"), messageId: MessageId, scheduledTaskId: Schema.optional(ScheduledTaskId),