Skip to content

Commit b36dc12

Browse files
committed
fix(web): anchor a continued sub-agent's fold with the turn that started it
1 parent 9e5229b commit b36dc12

2 files changed

Lines changed: 284 additions & 10 deletions

File tree

‎apps/web/src/components/chat/MessagesTimeline.logic.test.ts‎

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3675,6 +3675,261 @@ describe("v2 run and attempt history", () => {
36753675
"assistant-final-entry",
36763676
]);
36773677
});
3678+
it("folds a continued sub-agent with the turn that started it, not the run that resumed it", () => {
3679+
const run1 = RunId.make("run-1");
3680+
const run2 = RunId.make("run-2");
3681+
// Shaped like the stored data: the sub-agent item kept the ordinal it
3682+
// got in turn 1, but continuing it in turn 2 reassigned its runId to
3683+
// run 2 (CodexAdapterV2 emits the continued item with the parent's
3684+
// current run; TurnItemPositionStore.allocate never moves it).
3685+
const timelineEntries = [
3686+
{
3687+
id: "prompt-1",
3688+
kind: "message" as const,
3689+
createdAt: "2026-01-01T00:00:00Z",
3690+
message: {
3691+
id: MessageId.make("prompt-1"),
3692+
role: "user" as const,
3693+
text: "Spawn a subagent",
3694+
runId: run1,
3695+
createdAt: "2026-01-01T00:00:00Z",
3696+
updatedAt: "2026-01-01T00:00:00Z",
3697+
streaming: false,
3698+
},
3699+
},
3700+
{
3701+
id: "continued-subagent",
3702+
kind: "event" as const,
3703+
createdAt: "2026-01-01T00:00:03Z",
3704+
projectedItem: {
3705+
item: {
3706+
type: "subagent",
3707+
runId: run2,
3708+
},
3709+
},
3710+
} as never,
3711+
{
3712+
id: "answer-1",
3713+
kind: "message" as const,
3714+
createdAt: "2026-01-01T00:00:20Z",
3715+
message: {
3716+
id: MessageId.make("answer-1"),
3717+
role: "assistant" as const,
3718+
text: "Subagent started.",
3719+
runId: run1,
3720+
createdAt: "2026-01-01T00:00:20Z",
3721+
updatedAt: "2026-01-01T00:00:22Z",
3722+
streaming: false,
3723+
},
3724+
},
3725+
{
3726+
id: "prompt-2",
3727+
kind: "message" as const,
3728+
createdAt: "2026-01-01T00:01:00Z",
3729+
message: {
3730+
id: MessageId.make("prompt-2"),
3731+
role: "user" as const,
3732+
text: "Continue the subagent",
3733+
runId: run2,
3734+
createdAt: "2026-01-01T00:01:00Z",
3735+
updatedAt: "2026-01-01T00:01:00Z",
3736+
streaming: false,
3737+
},
3738+
},
3739+
{
3740+
id: "work-2",
3741+
kind: "work" as const,
3742+
createdAt: "2026-01-01T00:01:05Z",
3743+
entry: {
3744+
id: "work-2",
3745+
runId: run2,
3746+
createdAt: "2026-01-01T00:01:05Z",
3747+
label: "Running command",
3748+
tone: "tool" as const,
3749+
toolLifecycleStatus: "completed" as const,
3750+
},
3751+
},
3752+
{
3753+
id: "answer-2",
3754+
kind: "message" as const,
3755+
createdAt: "2026-01-01T00:01:20Z",
3756+
message: {
3757+
id: MessageId.make("answer-2"),
3758+
role: "assistant" as const,
3759+
text: "Done.",
3760+
runId: run2,
3761+
createdAt: "2026-01-01T00:01:20Z",
3762+
updatedAt: "2026-01-01T00:01:22Z",
3763+
streaming: false,
3764+
},
3765+
},
3766+
];
3767+
3768+
const rows = deriveMessagesTimelineRows({
3769+
timelineEntries,
3770+
isWorking: false,
3771+
turnDiffSummaries: [],
3772+
supportsConversationRollback: false,
3773+
});
3774+
3775+
// Each turn's fold sits between its own prompt and answer: the continued
3776+
// sub-agent folds with turn 1, and turn 2's work folds with turn 2.
3777+
expect(rows.map((row) => row.id)).toEqual([
3778+
"prompt-1",
3779+
"turn-fold:run-1",
3780+
"answer-1",
3781+
"prompt-2",
3782+
"turn-fold:run-2",
3783+
"answer-2",
3784+
]);
3785+
expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.runId)).toEqual([
3786+
run1,
3787+
run2,
3788+
]);
3789+
});
3790+
it("does not move the fold of an ordinary single-run turn", () => {
3791+
const run1 = RunId.make("run-1");
3792+
const timelineEntries = [
3793+
{
3794+
id: "prompt-1",
3795+
kind: "message" as const,
3796+
createdAt: "2026-01-01T00:00:00Z",
3797+
message: {
3798+
id: MessageId.make("prompt-1"),
3799+
role: "user" as const,
3800+
text: "Build it",
3801+
runId: run1,
3802+
createdAt: "2026-01-01T00:00:00Z",
3803+
updatedAt: "2026-01-01T00:00:00Z",
3804+
streaming: false,
3805+
},
3806+
},
3807+
{
3808+
id: "work-1",
3809+
kind: "work" as const,
3810+
createdAt: "2026-01-01T00:00:05Z",
3811+
entry: {
3812+
id: "work-1",
3813+
runId: run1,
3814+
createdAt: "2026-01-01T00:00:05Z",
3815+
label: "Ran command",
3816+
tone: "tool" as const,
3817+
toolLifecycleStatus: "completed" as const,
3818+
},
3819+
},
3820+
{
3821+
id: "answer-1",
3822+
kind: "message" as const,
3823+
createdAt: "2026-01-01T00:00:20Z",
3824+
message: {
3825+
id: MessageId.make("answer-1"),
3826+
role: "assistant" as const,
3827+
text: "Done.",
3828+
runId: run1,
3829+
createdAt: "2026-01-01T00:00:20Z",
3830+
updatedAt: "2026-01-01T00:00:22Z",
3831+
streaming: false,
3832+
},
3833+
},
3834+
];
3835+
3836+
const rows = deriveMessagesTimelineRows({
3837+
timelineEntries,
3838+
isWorking: false,
3839+
turnDiffSummaries: [],
3840+
supportsConversationRollback: false,
3841+
});
3842+
3843+
expect(rows.map((row) => row.id)).toEqual(["prompt-1", "turn-fold:run-1", "answer-1"]);
3844+
});
3845+
it("does not move the fold of a sub-agent started fresh by a later turn", () => {
3846+
const run1 = RunId.make("run-1");
3847+
const run2 = RunId.make("run-2");
3848+
const timelineEntries = [
3849+
{
3850+
id: "prompt-1",
3851+
kind: "message" as const,
3852+
createdAt: "2026-01-01T00:00:00Z",
3853+
message: {
3854+
id: MessageId.make("prompt-1"),
3855+
role: "user" as const,
3856+
text: "First question",
3857+
runId: run1,
3858+
createdAt: "2026-01-01T00:00:00Z",
3859+
updatedAt: "2026-01-01T00:00:00Z",
3860+
streaming: false,
3861+
},
3862+
},
3863+
{
3864+
id: "answer-1",
3865+
kind: "message" as const,
3866+
createdAt: "2026-01-01T00:00:20Z",
3867+
message: {
3868+
id: MessageId.make("answer-1"),
3869+
role: "assistant" as const,
3870+
text: "First answer.",
3871+
runId: run1,
3872+
createdAt: "2026-01-01T00:00:20Z",
3873+
updatedAt: "2026-01-01T00:00:22Z",
3874+
streaming: false,
3875+
},
3876+
},
3877+
{
3878+
id: "prompt-2",
3879+
kind: "message" as const,
3880+
createdAt: "2026-01-01T00:01:00Z",
3881+
message: {
3882+
id: MessageId.make("prompt-2"),
3883+
role: "user" as const,
3884+
text: "Spawn a subagent",
3885+
runId: run2,
3886+
createdAt: "2026-01-01T00:01:00Z",
3887+
updatedAt: "2026-01-01T00:01:00Z",
3888+
streaming: false,
3889+
},
3890+
},
3891+
{
3892+
id: "fresh-subagent",
3893+
kind: "event" as const,
3894+
createdAt: "2026-01-01T00:01:03Z",
3895+
projectedItem: {
3896+
item: {
3897+
type: "subagent",
3898+
runId: run2,
3899+
},
3900+
},
3901+
} as never,
3902+
{
3903+
id: "answer-2",
3904+
kind: "message" as const,
3905+
createdAt: "2026-01-01T00:01:20Z",
3906+
message: {
3907+
id: MessageId.make("answer-2"),
3908+
role: "assistant" as const,
3909+
text: "Done.",
3910+
runId: run2,
3911+
createdAt: "2026-01-01T00:01:20Z",
3912+
updatedAt: "2026-01-01T00:01:22Z",
3913+
streaming: false,
3914+
},
3915+
},
3916+
];
3917+
3918+
const rows = deriveMessagesTimelineRows({
3919+
timelineEntries,
3920+
isWorking: false,
3921+
turnDiffSummaries: [],
3922+
supportsConversationRollback: false,
3923+
});
3924+
3925+
expect(rows.map((row) => row.id)).toEqual([
3926+
"prompt-1",
3927+
"answer-1",
3928+
"prompt-2",
3929+
"turn-fold:run-2",
3930+
"answer-2",
3931+
]);
3932+
});
36783933
it("collapses only output from a superseded V2 attempt within the active logical run", () => {
36793934
const runId = "run-steered" as never;
36803935
const supersededAttemptId = "attempt-1" as never;

‎apps/web/src/components/chat/MessagesTimeline.logic.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,13 @@ function timelineEntryStartsResponse(entry: TimelineEntry): boolean {
786786
);
787787
}
788788

789+
/** The run a response boundary (prompt or automatic-wake notification) belongs to. */
790+
function responseBoundaryRunId(entry: TimelineEntry): RunId | null {
791+
if (entry.kind === "message") return entry.message.runId ?? null;
792+
if (entry.kind === "work") return entry.entry.runId ?? null;
793+
return null;
794+
}
795+
789796
/**
790797
* A promptless provider restart replaces the native turn without adding a
791798
* user message. Keep every provider turn since the initiating prompt in one
@@ -898,29 +905,41 @@ function deriveTurnFolds(input: {
898905
// first V2 run must not unfold every imported turn above it.
899906
let runlessKey: RunId | null = null;
900907
let pendingBoundary: { createdAt: string; anchorEntryId: string } | null = null;
908+
// A sub-agent continued by a later turn keeps its place in the turn that
909+
// started it but takes the later run's id. It folds with the turn it sits
910+
// in, or the later turn's fold would open above its own prompt.
911+
const promptIndexByRunId = new Map<RunId, number>();
912+
for (const [index, entry] of input.timelineEntries.entries()) {
913+
if (!timelineEntryStartsResponse(entry)) continue;
914+
const boundaryRunId = responseBoundaryRunId(entry);
915+
if (boundaryRunId !== null && !promptIndexByRunId.has(boundaryRunId)) {
916+
promptIndexByRunId.set(boundaryRunId, index);
917+
}
918+
}
919+
let promptRunId: RunId | null = null;
901920
for (const [index, entry] of input.timelineEntries.entries()) {
902921
if (timelineEntryStartsResponse(entry)) {
903922
const nextEntry = input.timelineEntries[index + 1];
904923
pendingBoundary = nextEntry
905924
? { createdAt: entry.createdAt, anchorEntryId: nextEntry.id }
906925
: null;
907-
const boundaryRunId =
908-
entry.kind === "message"
909-
? entry.message.runId
910-
: entry.kind === "work"
911-
? entry.entry.runId
912-
: null;
926+
const boundaryRunId = responseBoundaryRunId(entry);
913927
runlessKey = boundaryRunId == null ? RunId.make(`runless:${entry.id}`) : null;
928+
promptRunId = boundaryRunId;
914929
continue;
915930
}
916931
const runId = timelineEntryFoldRunId(entry, runlessKey);
917932
if (!runId) {
918933
continue;
919934
}
920-
if (runId === runlessKey && timelineEntryFailedItem(entry) !== null) {
921-
runlessFailedKeys.add(runId);
935+
// An entry that sits before its own run's prompt (a continued sub-agent)
936+
// folds with the turn it sits in, keyed by the run of that prompt.
937+
const foldRunId =
938+
promptRunId !== null && (promptIndexByRunId.get(runId) ?? -1) > index ? promptRunId : runId;
939+
if (foldRunId === runlessKey && timelineEntryFailedItem(entry) !== null) {
940+
runlessFailedKeys.add(foldRunId);
922941
}
923-
let group = groupsByRunId.get(runId);
942+
let group = groupsByRunId.get(foldRunId);
924943
if (!group) {
925944
group = {
926945
entries: [],
@@ -933,7 +952,7 @@ function deriveTurnFolds(input: {
933952
anchorEntryId: pendingBoundary?.anchorEntryId ?? entry.id,
934953
};
935954
pendingBoundary = null;
936-
groupsByRunId.set(runId, group);
955+
groupsByRunId.set(foldRunId, group);
937956
}
938957
group.entries.push(entry);
939958
if (entry.kind === "message") {

0 commit comments

Comments
 (0)