Skip to content
Draft
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
58 changes: 58 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1755,8 +1758,12 @@ export function buildThreadFeed(
: [],
),
);
const legacyWakeMessageIds = new Set<string>();
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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 => {
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
});
});
13 changes: 10 additions & 3 deletions apps/web/src/session-logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
127 changes: 125 additions & 2 deletions packages/client-runtime/src/state/turnItemPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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<OrchestrationV2TurnItem, { type: "command_execution" }> {
const now = DateTime.makeUnsafe("2026-08-03T00:00:00.000Z");
return {
id: TurnItemId.make("item-command"),
Expand Down Expand Up @@ -70,6 +78,121 @@ function run(
};
}

function wake(
overrides: Partial<Extract<OrchestrationV2TurnItem, { type: "user_message" }>> = {},
): Extract<OrchestrationV2TurnItem, { type: "user_message" }> {
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);
Expand Down
Loading