diff --git a/apps/server/src/mcp/toolkits/pair/handlers.test.ts b/apps/server/src/mcp/toolkits/pair/handlers.test.ts index 9c246be4ee..f5a781891d 100644 --- a/apps/server/src/mcp/toolkits/pair/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pair/handlers.test.ts @@ -816,6 +816,41 @@ describe("pair_await", () => { }), ); + it.effect("lists only the files of the executor's latest turn", () => + Effect.gen(function* () { + // A pair keeps one executor across briefs, so its checkpoints accumulate. + const second = makeExecutor({ + latestTurn: completedTurn({ + turnId: TurnId.make("turn-2"), + assistantMessageId: MessageId.make("a-2"), + }), + }); + const checkpoint = (turnId: string, count: number, path: string) => ({ + turnId: TurnId.make(turnId), + checkpointTurnCount: count, + checkpointRef: CheckpointRef.make(`ref-${count}`), + status: "ready" as const, + files: [{ path, kind: "modified", additions: 1, deletions: 0 }], + assistantMessageId: null, + completedAt: NOW, + }); + const harness = yield* makeHarness({ + shells: [makeShell(LEAD_ID), second], + details: [ + detailOf(second, { + messages: [message("a-2", { text: "Second brief done." })], + checkpoints: [checkpoint("turn-1", 1, "src/a.ts"), checkpoint("turn-2", 2, "src/b.ts")], + }), + ], + }); + expect(yield* harness.call("pair_await", { maxSeconds: 0 })).toMatchObject({ + state: "completed", + filesChanged: [{ path: "src/b.ts", kind: "modified", additions: 1, deletions: 0 }], + turnCount: 2, + }); + }), + ); + it.effect("returns at once when the executor needs the user", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/apps/server/src/mcp/toolkits/pair/handlers.ts b/apps/server/src/mcp/toolkits/pair/handlers.ts index ee6a9d60c4..8705979bc4 100644 --- a/apps/server/src/mcp/toolkits/pair/handlers.ts +++ b/apps/server/src/mcp/toolkits/pair/handlers.ts @@ -52,6 +52,7 @@ import { derivePairExecutorState, isPairLeadSupported, normalizeProtectedPath, + latestTurnCheckpoints, pairAwaitBudgetSeconds, pairAwaitCapSeconds, pairExecutorThreadId, @@ -562,7 +563,9 @@ const make = Effect.gen(function* () { createdAt: message.createdAt, }; } - filesChanged = aggregateFilesChanged(detail.checkpoints); + filesChanged = aggregateFilesChanged( + latestTurnCheckpoints(detail.checkpoints, current.latestTurn?.turnId ?? null), + ); turnCount = detail.checkpoints.length; } diff --git a/apps/server/src/mcp/toolkits/pair/logic.test.ts b/apps/server/src/mcp/toolkits/pair/logic.test.ts index cb45f9da8d..4b84c55ce6 100644 --- a/apps/server/src/mcp/toolkits/pair/logic.test.ts +++ b/apps/server/src/mcp/toolkits/pair/logic.test.ts @@ -16,6 +16,7 @@ import { changedProtectedPaths, derivePairExecutorState, isPairLeadSupported, + latestTurnCheckpoints, pairAwaitBudgetSeconds, normalizeProtectedPath, isPairExecutorThreadId, @@ -239,3 +240,18 @@ describe("pair await budget", () => { expect(pairAwaitBudgetSeconds(0, 45)).toBe(0); }); }); + +describe("the files a lead is shown", () => { + it("come from the executor's latest turn only", () => { + const first = { turnId: "turn-1", files: ["a.ts"] }; + const second = { turnId: "turn-2", files: ["b.ts"] }; + const secondAgain = { turnId: "turn-2", files: ["c.ts"] }; + expect(latestTurnCheckpoints([first, second, secondAgain], "turn-2")).toEqual([ + second, + secondAgain, + ]); + // A turn that changed nothing has no checkpoint, and shows no files. + expect(latestTurnCheckpoints([first], "turn-2")).toEqual([]); + expect(latestTurnCheckpoints([first], null)).toEqual([]); + }); +}); diff --git a/apps/server/src/mcp/toolkits/pair/logic.ts b/apps/server/src/mcp/toolkits/pair/logic.ts index 6536003db3..31d2c48c09 100644 --- a/apps/server/src/mcp/toolkits/pair/logic.ts +++ b/apps/server/src/mcp/toolkits/pair/logic.ts @@ -51,6 +51,20 @@ export function derivePairExecutorState(shell: OrchestrationThreadShell): PairEx return state; } +/** + * The checkpoints of the executor's latest turn. One executor serves every + * brief of a pair, so its checkpoints pile up; the lead is reviewing the brief + * it just sent and has already read the earlier ones. + */ +export function latestTurnCheckpoints( + checkpoints: ReadonlyArray, + latestTurnId: string | null, +): ReadonlyArray { + return latestTurnId === null + ? [] + : checkpoints.filter((checkpoint) => checkpoint.turnId === latestTurnId); +} + /** * How long one `pair_await` call waits. Only an explicit 0 reads the state and * returns; any other request waits the lead's whole cap, because a short wait diff --git a/apps/server/src/mcp/toolkits/pair/tools.ts b/apps/server/src/mcp/toolkits/pair/tools.ts index c4695b8b57..a985e8eb80 100644 --- a/apps/server/src/mcp/toolkits/pair/tools.ts +++ b/apps/server/src/mcp/toolkits/pair/tools.ts @@ -154,6 +154,7 @@ export const PairAwaitResult = Schema.Struct({ createdAt: Schema.String, }), ), + /** The files changed by the executor's latest turn, not by earlier briefs. */ filesChanged: Schema.Array( Schema.Struct({ path: Schema.String, @@ -162,6 +163,7 @@ export const PairAwaitResult = Schema.Struct({ deletions: Schema.Int, }), ), + /** Every turn the executor has completed for this pair. */ turnCount: Schema.Int, /** * The protected paths of the latest brief, checked once the executor is no diff --git a/docs/internals/delegation.md b/docs/internals/delegation.md index 449b49ecc3..c25e5bfa11 100644 --- a/docs/internals/delegation.md +++ b/docs/internals/delegation.md @@ -108,7 +108,9 @@ tokens, up to a cap chosen by the lead's provider: long only where Pylon sets th tool timeout itself. The lead does not choose the length. A live Codex lead asked for 10 to 20 seconds at a time and looped, which is polling with a model turn per call, so any request other than an explicit 0 waits the whole cap; the call still returns the moment the executor changes state, and -0 reads the state without waiting. +0 reads the state without waiting. Its `filesChanged` covers the executor's latest turn only: one +executor serves every brief of a pair, so listing all of its checkpoints would hand the lead files it +reviewed several briefs ago. `turnCount` still counts them all. Pairing is session-scoped. When a provider session is prepared, `ProviderService` adds a `pair` capability if the thread's executor exists and is not archived. The `enableAgentDelegation` setting