Skip to content

Commit 499513d

Browse files
committed
Cut a forked Codex child's transcript at the clock, not at the timestamps
The inheritance arrives in two shapes and the earlier fix only knew one. Codex replays older history as a turn with no times, which that fix dropped, but the parent turn that was live at the moment of the fork comes across with an ordinary id and a real startedAt -- from before this thread existed -- so the rule slid past it and the child still opened with the operator's own message and the main thread's reply. What separates inherited turns from the child's own is neither their ids nor their missing times but the clock: a turn that started before the child was created cannot be the child's. Measured against two real forks, inherited turns start 11s before creation while own work begins 1s and 2s after it, so the comparison is strict rather than fuzzy.
1 parent fb33a63 commit 499513d

2 files changed

Lines changed: 99 additions & 10 deletions

File tree

apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,81 @@ describe("CodexAdapter item mapping", () => {
257257
);
258258
});
259259

260+
it("drops the parent turn that was live at the fork, which carries a real time", () => {
261+
// The second shape of the same inheritance, and the one a "no timestamps"
262+
// rule slid straight past: the turn the parent was in the middle of when it
263+
// spawned this child comes across with an ordinary id and a startedAt from
264+
// before the child existed. Measured from a real fork: the parent's live
265+
// turn started 11s before creation, the child's own work 1s after it.
266+
const createdAt = 1_786_558_783;
267+
const thread = {
268+
id: "forked-child-live-parent-turn",
269+
forkedFromId: "parent-thread",
270+
parentThreadId: "parent-thread",
271+
createdAt,
272+
turns: [
273+
{
274+
id: "rollout-2",
275+
status: "completed",
276+
startedAt: null,
277+
items: [{ id: "assistant-1", type: "agentMessage", text: "Older replayed history." }],
278+
},
279+
{
280+
id: "019ff733-7569-7ad2-9171-d6f19574734f",
281+
status: "interrupted",
282+
startedAt: createdAt - 11,
283+
items: [
284+
{ id: "user-1", type: "userMessage", text: "can you run just 1 subagent this time" },
285+
{
286+
id: "assistant-2",
287+
type: "agentMessage",
288+
text: "I can run exactly one subagent now.",
289+
},
290+
],
291+
},
292+
{
293+
id: "019ff733-a3ec-7060-912e-f858d78b9ef9",
294+
status: "completed",
295+
startedAt: createdAt + 1,
296+
items: [{ id: "assistant-3", type: "agentMessage", text: "Read-only scan complete." }],
297+
},
298+
],
299+
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];
300+
301+
assert.deepStrictEqual(
302+
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
303+
["Read-only scan complete."],
304+
);
305+
});
306+
307+
it("keeps a child's own first turn that starts in the second it was created", () => {
308+
const createdAt = 1_786_558_783;
309+
const thread = {
310+
id: "forked-child-instant-start",
311+
forkedFromId: "parent-thread",
312+
createdAt,
313+
turns: [
314+
{
315+
id: "019ff733-7569-7ad2-9171-d6f19574734f",
316+
status: "completed",
317+
startedAt: createdAt - 4,
318+
items: [{ id: "assistant-1", type: "agentMessage", text: "Parent's own words." }],
319+
},
320+
{
321+
id: "019ff733-a3ec-7060-912e-f858d78b9ef9",
322+
status: "completed",
323+
startedAt: createdAt,
324+
items: [{ id: "assistant-2", type: "agentMessage", text: "Off to work." }],
325+
},
326+
],
327+
} as unknown as EffectCodexSchema.V2ThreadReadResponse["thread"];
328+
329+
assert.deepStrictEqual(
330+
mapCodexSubagentTranscript(thread).entries.map((entry) => entry.text),
331+
["Off to work."],
332+
);
333+
});
334+
260335
it("keeps every turn when a forked child has no timed turn to start from", () => {
261336
const thread = {
262337
id: "forked-child-untimed",

apps/server/src/provider/Layers/CodexAdapter.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -417,18 +417,32 @@ export function mapCodexSubagentTranscript(
417417
? isoFromEpochSeconds(thread.createdAt)
418418
: undefined;
419419
// Codex spawns a subagent by forking its parent, so the child inherits the
420-
// parent's conversation and replays it as that leading timeless turn. None of
421-
// it is the child's own work: the replayed user message is what the operator
420+
// parent's conversation and replays it at the head of its own history. None
421+
// of it is the child's work: the replayed user message is what the operator
422422
// typed to the main thread, which the panel then labelled as the instruction
423423
// this agent was given, and the replayed assistant message is the main
424-
// thread's own reply attributed to the child. A forked child's transcript
425-
// starts at its first real turn instead. The child's actual instruction is
426-
// the spawn prompt, which the panel already carries as its objective.
427-
const firstOwnTurnIndex = thread.forkedFromId
428-
? thread.turns.findIndex((turn) => turn.startedAt !== undefined && turn.startedAt !== null)
429-
: 0;
430-
// A thread with no timed turn at all is not a fork whose inheritance can be
431-
// told apart, so it keeps every turn rather than losing its only content.
424+
// thread's own reply attributed to the child.
425+
//
426+
// The inheritance arrives in two shapes, and only one of them is timeless: an
427+
// older block Codex ids `rollout-N` with no times at all, and the parent turn
428+
// that was live at the moment of the fork, which carries an ordinary id and a
429+
// real `startedAt` -- from *before* this thread existed. What separates them
430+
// from the child's own work is therefore not the ids or the missing times but
431+
// the clock: a turn that started before the child was created cannot be the
432+
// child's. Measured against two real forks, own work begins one and two
433+
// seconds after `createdAt`, so the comparison is strict rather than fuzzy.
434+
const forkedThreadCreatedAt = Number.isFinite(thread.createdAt) ? thread.createdAt : null;
435+
const firstOwnTurnIndex =
436+
thread.forkedFromId && forkedThreadCreatedAt !== null
437+
? thread.turns.findIndex(
438+
(turn) =>
439+
turn.startedAt !== undefined &&
440+
turn.startedAt !== null &&
441+
turn.startedAt >= forkedThreadCreatedAt,
442+
)
443+
: 0;
444+
// No turn of the child's own leaves nothing to tell the inheritance from, so
445+
// the thread keeps every turn rather than losing its only content.
432446
const ownTurns = firstOwnTurnIndex > 0 ? thread.turns.slice(firstOwnTurnIndex) : thread.turns;
433447
const entries = ownTurns.flatMap((turn) =>
434448
turn.items.flatMap((item) => {

0 commit comments

Comments
 (0)