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
35 changes: 35 additions & 0 deletions apps/server/src/mcp/toolkits/pair/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/mcp/toolkits/pair/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
derivePairExecutorState,
isPairLeadSupported,
normalizeProtectedPath,
latestTurnCheckpoints,
pairAwaitBudgetSeconds,
pairAwaitCapSeconds,
pairExecutorThreadId,
Expand Down Expand Up @@ -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;
}

Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/mcp/toolkits/pair/logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
changedProtectedPaths,
derivePairExecutorState,
isPairLeadSupported,
latestTurnCheckpoints,
pairAwaitBudgetSeconds,
normalizeProtectedPath,
isPairExecutorThreadId,
Expand Down Expand Up @@ -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([]);
});
});
14 changes: 14 additions & 0 deletions apps/server/src/mcp/toolkits/pair/logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends { readonly turnId: string }>(
checkpoints: ReadonlyArray<T>,
latestTurnId: string | null,
): ReadonlyArray<T> {
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
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/pair/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/internals/delegation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading