Skip to content
Merged
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
88 changes: 88 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,94 @@ describe("buildThreadFeed", () => {
});
});

it("folds each run of a provider-native subagent thread like a normal turn", () => {
// A Claude subagent's child thread, as projected: no runs, and a user
// prompt for the launch and for a SendMessage resume.
const runless = <T extends OrchestrationV2TurnItem>(item: T, id: string, ordinal: number) => ({
...item,
id: TurnItemId.make(id),
runId: null,
ordinal,
});
const prompt = (id: string, ordinal: number, at: string) =>
runless(
{ ...userMessage(at), messageId: MessageId.make(id), creationSource: "provider" as const },
id,
ordinal,
);
const answer = (id: string, ordinal: number, at: string) =>
runless({ ...assistantMessage(at), messageId: MessageId.make(id) }, id, ordinal);
const { exitCode: _exitCode, ...completedCommand } = command("2026-06-20T00:01:17.000Z");
const feed = (resumeRunning: boolean) =>
buildThreadFeed(
[
prompt("launch", 1, "2026-06-20T00:00:00.000Z"),
runless(command("2026-06-20T00:00:04.000Z"), "launch-ls", 2),
answer("launch-answer", 3, "2026-06-20T00:00:08.000Z"),
prompt("resume", 4, "2026-06-20T00:01:12.000Z"),
resumeRunning
? runless(
{ ...completedCommand, status: "running", completedAt: null, output: "" },
"resume-ls",
5,
)
: runless(command("2026-06-20T00:01:17.000Z"), "resume-ls", 5),
...(resumeRunning ? [] : [answer("resume-answer", 6, "2026-06-20T00:01:20.000Z")]),
].map((item, position) => projected(item, position)),
);
const shape = (entries: ReadonlyArray<ThreadFeedEntry>) =>
entries.map((entry) =>
entry.type === "run-fold"
? `fold:${entry.label}`
: entry.type === "message"
? `${entry.message.role}:${entry.message.id}`
: entry.type,
);

const settled = deriveThreadFeedPresentation(feed(false), null, new Set());
expect(shape(settled)).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"assistant:launch-answer",
"user:resume",
"fold:Worked for 8.0s",
"assistant:resume-answer",
]);
const launchFold = settled.find((entry) => entry.type === "run-fold");
if (launchFold?.type !== "run-fold") throw new Error("Expected the launch fold");
expect(
shape(deriveThreadFeedPresentation(feed(false), null, new Set([launchFold.runId]))),
).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"work-toggle",
"assistant:launch-answer",
"user:resume",
"fold:Worked for 8.0s",
"assistant:resume-answer",
]);

// While the resume runs, only the settled launch folds.
expect(
shape(
deriveThreadFeedPresentation(
feed(true),
null,
new Set(),
new Set(),
"2026-06-20T00:01:12.000Z",
true,
),
),
).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"assistant:launch-answer",
"user:resume",
"work-toggle",
]);
});

it("keeps a provider-native subagent's runless tool call live while it works", () => {
const startedAt = "2026-06-20T00:00:01.000Z";
const { exitCode: _exitCode, ...completedCommand } = command();
Expand Down
58 changes: 34 additions & 24 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,10 @@ import type {
OrchestrationV2RunStatus,
OrchestrationV2TurnItem,
OrchestrationV2UserMessageInputIntent,
RunId,
RunAttemptId,
ScheduledTaskId,
} from "@t3tools/contracts";
import { ThreadId } from "@t3tools/contracts";
import { RunId, ThreadId } from "@t3tools/contracts";
import { formatDuration } from "@t3tools/shared/orchestrationTiming";
import { compactDynamicToolOutput } from "@t3tools/shared/toolOutput";
import { computerUseToolTitle } from "@t3tools/shared/toolActivity";
Expand Down Expand Up @@ -885,46 +884,38 @@ export function failedFeedRunIds(
return failed;
}

/**
* A thread without runs (a provider-native subagent) folds each prompt's
* response like a run; `isWorking` keeps its latest response open.
*/
function deriveThreadFeedRunFolds(
feed: ReadonlyArray<ThreadFeedEntry>,
latestRun: ThreadFeedLatestRun | null,
isWorking: boolean,
): ReadonlyMap<string, ThreadFeedRunFold> {
const firstAssistantMessageIdByRun = new Map<RunId, string>();
const terminalAssistantMessageIdByRun = new Map<RunId, string>();
const interruptedRunIds = new Set<RunId>();
for (const entry of feed) {
if (entry.type === "message" && entry.message.role === "assistant" && entry.message.runId) {
if (!firstAssistantMessageIdByRun.has(entry.message.runId)) {
firstAssistantMessageIdByRun.set(entry.message.runId, entry.id);
}
terminalAssistantMessageIdByRun.set(entry.message.runId, entry.id);
}
if (
entry.type === "activity-group" &&
entry.runId !== null &&
entry.activities.some(
(activity) => activity.projectedItem.item.type === "run_interrupt_result",
)
) {
interruptedRunIds.add(entry.runId);
}
}

const failedRunIds = failedFeedRunIds(feed, latestRun);
const groupsByRunId = new Map<
RunId,
{ entries: ThreadFeedEntry[]; startBoundary: string | null }
>();
// Fold state is keyed by run, so each prompt of a runless thread lends its
// response a stable key of its own.
let runlessKey: RunId | null = null;
let pendingUserBoundary: string | null = null;
for (const entry of feed) {
if (entry.type === "message" && entry.message.role === "user") {
pendingUserBoundary = entry.message.createdAt;
runlessKey = latestRun === null ? RunId.make(`runless:${entry.id}`) : null;
continue;
}
const runId =
entry.type === "message" && entry.message.role === "assistant"
? entry.message.runId
? (entry.message.runId ?? runlessKey)
: entry.type === "activity-group"
? entry.runId
? (entry.runId ?? runlessKey)
: null;
if (!runId) continue;
let group = groupsByRunId.get(runId);
Expand All @@ -934,14 +925,33 @@ function deriveThreadFeedRunFolds(
groupsByRunId.set(runId, group);
}
group.entries.push(entry);
if (entry.type === "message") {
if (!firstAssistantMessageIdByRun.has(runId)) {
firstAssistantMessageIdByRun.set(runId, entry.id);
}
terminalAssistantMessageIdByRun.set(runId, entry.id);
}
if (entry.type !== "activity-group") continue;
for (const activity of entry.activities) {
const item = activity.projectedItem.item;
if (item.type === "run_interrupt_result") interruptedRunIds.add(runId);
if (
runId === runlessKey &&
item.type === "error" &&
item.status === "failed" &&
item.parentItemId === null
) {
failedRunIds.add(runId);
}
}
}

const activeRunId = unsettledRunId(latestRun);
const failedRunIds = failedFeedRunIds(feed, latestRun);
const foldsByAnchorId = new Map<string, ThreadFeedRunFold>();
for (const [runId, group] of groupsByRunId) {
if (
runId === activeRunId ||
(isWorking && runId === runlessKey) ||
interruptedRunIds.has(runId) ||
failedRunIds.has(runId) ||
group.entries.some((entry) => entry.type === "message" && entry.message.streaming)
Expand Down Expand Up @@ -1028,9 +1038,9 @@ export function deriveThreadFeedPresentation(
);
const failedRunIds = failedFeedRunIds(sourceFeed, latestRun);
const activeTailGroup = sourceFeed.at(-1);
const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun);
const activeRunId = unsettledRunId(latestRun);
const isWorking = activeWorkStartedAt !== null && latestRun?.status !== "preparing";
const foldsByAnchorId = deriveThreadFeedRunFolds(sourceFeed, latestRun, isWorking);
const collapsedEntryIds = new Set<string>();
for (const fold of foldsByAnchorId.values()) {
if (!expandedRunIds.has(fold.runId)) {
Expand Down
166 changes: 164 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1904,6 +1904,168 @@ describe("deriveMessagesTimelineRows", () => {
});
});

it("folds each run of a provider-native subagent thread like a normal turn", () => {
// A Claude subagent's child thread, as projected: no runs, one runless
// root turn, and a user prompt for the launch and for a SendMessage resume.
const threadId = ThreadId.make("subagent-child");
const rootNodeId = NodeId.make("task-root");
const at = (second: number) =>
DateTime.makeUnsafe(new Date(Date.UTC(2026, 8, 25, 22, 51, second)).toISOString());
const base = (id: string, ordinal: number, second: number, endSecond = second) => ({
id: TurnItemId.make(id),
threadId,
runId: null,
nodeId: rootNodeId,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal,
status: "completed" as const,
title: null,
startedAt: at(second),
completedAt: at(endSecond),
updatedAt: at(endSecond),
});
const prompt = (id: string, ordinal: number, second: number) => ({
...base(id, ordinal, second),
type: "user_message" as const,
messageId: MessageId.make(id),
text: `Prompt ${id}`,
attachments: [],
inputIntent: "turn_start" as const,
createdBy: "agent" as const,
creationSource: "provider" as const,
});
const answer = (id: string, ordinal: number, second: number) => ({
...base(id, ordinal, second),
type: "assistant_message" as const,
messageId: MessageId.make(id),
text: `Answer ${id}`,
streaming: false,
});
type ResumeState = "running" | "completed" | "failed";
const items = (resume: ResumeState) =>
[
prompt("launch", 1, 0),
{ ...base("launch-ls", 2, 4), type: "command_execution" as const, input: "ls src" },
{
...base("launch-thinking", 3, 8),
type: "reasoning" as const,
title: "Thinking",
text: "Not there.",
streaming: false,
},
answer("launch-answer", 4, 8),
prompt("resume", 5, 72),
{
...base("resume-ls", 6, 77),
type: "command_execution" as const,
input: "ls src",
status: resume === "running" ? ("running" as const) : ("completed" as const),
completedAt: resume === "running" ? null : at(77),
},
...(resume === "failed"
? [
{
...base("resume-error", 7, 80),
type: "error" as const,
status: "failed" as const,
failure: {
class: "provider_error" as const,
message: "Subagent failed",
code: null,
retryable: null,
},
},
]
: []),
...(resume === "running" ? [] : [answer("resume-answer", 8, 80)]),
].map((item, position) => ({
position,
visibility: "local" as const,
sourceThreadId: threadId,
sourceItemId: item.id,
item,
}));
const rows = (input: {
resume: ResumeState;
working: boolean;
expandedRunIds?: ReadonlySet<RunId>;
}) =>
deriveMessagesTimelineRows({
timelineEntries: deriveTimelineEntriesFromVisibleTurnItems({
visibleTurnItems: items(input.resume),
optimisticMessages: [],
}),
latestRun: null,
isWorking: input.working,
runlessWorkActive: input.working,
...(input.expandedRunIds === undefined ? {} : { expandedRunIds: input.expandedRunIds }),
activeTurnStartedAt: input.working ? DateTime.formatIso(at(72)) : null,
turnDiffSummaries: [],
supportsConversationRollback: false,
});
const shape = (timeline: ReadonlyArray<MessagesTimelineRow>) =>
timeline.map((row) =>
row.kind === "turn-fold"
? `fold:${row.label}`
: row.kind === "message"
? `${row.message.role}:${row.message.id}`
: row.kind,
);

// Settled: each run folds its work, keeping its prompt and final answer.
const settled = rows({ resume: "completed", working: false });
expect(shape(settled)).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"assistant:launch-answer",
"user:resume",
"fold:Worked for 8.0s",
"assistant:resume-answer",
]);

// Each fold opens on its own.
const launchFold = settled.find((row) => row.kind === "turn-fold");
if (launchFold?.kind !== "turn-fold") throw new Error("Expected the launch fold");
const expanded = rows({
resume: "completed",
working: false,
expandedRunIds: new Set([launchFold.runId]),
});
expect(shape(expanded)).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"work-toggle",
"assistant:launch-answer",
"user:resume",
"fold:Worked for 8.0s",
"assistant:resume-answer",
]);

// While the resume runs, only the settled launch folds.
expect(shape(rows({ resume: "running", working: true }))).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"assistant:launch-answer",
"user:resume",
"working",
"work-live",
]);

// A failed run stays open, as on a normal thread.
expect(shape(rows({ resume: "failed", working: false }))).toEqual([
"user:launch",
"fold:Worked for 8.0s",
"assistant:launch-answer",
"user:resume",
"work",
"work",
"assistant:resume-answer",
]);
});

it("shows a provider-native subagent's runless tools as live work while it works", () => {
const entries = (commandStatus: "inProgress" | "completed") => [
{
Expand Down Expand Up @@ -1954,9 +2116,9 @@ describe("deriveMessagesTimelineRows", () => {
active: true,
});

// Once the subagent settles, the same entries read as finished history.
// Once the subagent settles, the same entries fold as finished history.
const settled = rows({ commandStatus: "completed", working: false });
expect(settled.map((row) => row.kind)).toEqual(["message", "work"]);
expect(settled.map((row) => row.kind)).toEqual(["message", "turn-fold"]);
});

it("does not treat runless entries as live work on a thread with runs", () => {
Expand Down
Loading
Loading