Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
eb7f45e
fix(acp): agents get T3's runtime notes once per session, not on ever…
Radu028 Oct 29, 2026
3c990ef
fix(acp): resend T3's notes after idle and Copilot compactions
Radu028 Oct 29, 2026
492c421
fix(acp): mark compactions after routing and accept older Copilot rep…
Radu028 Oct 29, 2026
6757893
fix(acp): keep compaction marks for every session and mark each compa…
Radu028 Oct 29, 2026
3c00ca3
fix(acp): a compaction left open at turn end still counts when it rea…
Radu028 Oct 29, 2026
a6966ef
fix(acp): a replaced runtime's late prompt no longer hides notes from…
Radu028 Oct 29, 2026
f3bdcb6
fix(acp): compactions during prompt preparation and from ACP v1 agent…
Radu028 Oct 29, 2026
c3c16d2
fix(acp): cancelled prompts resend T3 notes, and compaction patches c…
Radu028 Oct 29, 2026
4359e8c
test(acp): the late-prompt rollback case settles the stale prompt suc…
Radu028 Oct 29, 2026
c52a2d3
fix(acp): /compact with preservation instructions also resends T3 notes
Radu028 Oct 29, 2026
7789d1a
Merge remote-tracking branch 'github/main' into maria/takeover-pr-26959
maria-rcks Oct 31, 2026
17d766b
test(acp): keep prompt echo coverage with session runtime notes
maria-rcks Oct 31, 2026
c34d5b4
fix(acp): retry runtime notes after refused or failed prompts
maria-rcks Oct 31, 2026
2300f25
fix(acp): finish attachment preparation before checking delivery
maria-rcks Oct 31, 2026
49a5f55
fix(acp): preserve stop reasons when v1 usage is incomplete
maria-rcks Oct 31, 2026
00f5ff1
test(acp): expect notes after an early fx cancellation
maria-rcks Oct 31, 2026
7009037
chore: merge main into acp runtime notes fix
maria-rcks Nov 1, 2026
67ad2a4
chore: merge main into acp runtime notes fix
Radu028 Nov 2, 2026
3c89aa9
chore: merge main into acp runtime notes fix
Radu028 Nov 9, 2026
e085954
fix(acp): a runtime restarted after a hidden failure resends T3 notes
Radu028 Nov 9, 2026
45cb620
test(acp): the replay helper keeps compactions that finish while a pr…
Radu028 Nov 9, 2026
2291dad
test(acp): the replay helper resends after a compaction a completed t…
Radu028 Nov 9, 2026
89c8622
test(acp): the replay helper tracks the exact notes each session rece…
Radu028 Nov 9, 2026
1cc7db1
test(acp): the replay helper expects the notes for each session's cur…
Radu028 Nov 9, 2026
6e45427
test(acp): the replay helper counts a recorded notes block as the cur…
Radu028 Nov 9, 2026
1bf0167
Merge remote-tracking branch 'github/main' into fix/acp-runtime-notes…
maria-rcks Nov 11, 2026
ade6ac1
Merge remote-tracking branch 'origin/main' into fix/acp-runtime-notes…
juliusmarminge Nov 12, 2026
ff3455b
chore(acp): keep the runtime notes change to its scope
juliusmarminge Nov 12, 2026
7f1b9ae
Merge remote-tracking branch 'origin/main' into fix/acp-runtime-notes…
juliusmarminge Nov 12, 2026
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
14 changes: 14 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ const failPromptOrdinal = Number(process.env.T3_ACP_FAIL_PROMPT_ORDINAL);
const compactionPromptOrdinals = new Set(
(process.env.T3_ACP_COMPACTION_PROMPT_ORDINALS ?? "").split(",").map(Number),
);
// These prompts end while their compaction is still in progress.
const openCompactionPromptOrdinals = new Set(
(process.env.T3_ACP_OPEN_COMPACTION_PROMPT_ORDINALS ?? "").split(",").map(Number),
);
const failSetModeConfigOption = process.env.T3_ACP_FAIL_SET_MODE_CONFIG_OPTION === "1";
const emitEmptyDefaultAgentOption = process.env.T3_ACP_EMIT_EMPTY_DEFAULT_AGENT_OPTION === "1";
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
Expand Down Expand Up @@ -1369,6 +1373,16 @@ const program = Effect.gen(function* () {
yield* Effect.sleep(`${promptDelayMs} millis`);
}

if (openCompactionPromptOrdinals.has(promptCount)) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "compaction_update",
compactionId: `compact-${promptCount}`,
status: "in_progress",
},
});
}
if (compactionPromptOrdinals.has(promptCount)) {
for (const status of ["in_progress", "completed"] as const) {
yield* agent.client.sessionUpdate({
Expand Down
437 changes: 391 additions & 46 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1490,7 +1490,7 @@ describe("AcpRegistryAdapterV2", () => {
})
: "test prompt",
},
runtimeInstructions,
...(ordinal === 1 ? [runtimeInstructions] : []),
];
const echo = (messageId: string, content: ReadonlyArray<unknown>) =>
claudeFrames.update(CLAUDE_ROOT_SESSION, {
Expand All @@ -1511,6 +1511,7 @@ describe("AcpRegistryAdapterV2", () => {
{
type: "text",
text: promptText(ordinal),
annotations: { audience: ["user"] },
},
]),
...(ordering === "before" ? [matching, acknowledgment] : [acknowledgment, matching]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export function makeAntigravityAcpAdapterFlavor(
mcpOverHttp: true,
sessionCarriesPullRequestLinking: antigravityAppendsSystemPrompt,
makeRuntime,
buildPrompt: ({
buildAttachmentPrompt: ({
text,
attachments,
resolveAttachmentPath,
Expand All @@ -230,6 +230,7 @@ export function makeAntigravityAcpAdapterFlavor(
agentVersion,
currentModel,
}).pipe(
Effect.map((blocks) => (text.trim() ? blocks.slice(1) : blocks)),
Effect.provideService(FileSystem.FileSystem, options.fileSystem),
Effect.provideService(Path.Path, options.path),
),
Expand Down
200 changes: 187 additions & 13 deletions apps/server/src/orchestration-v2/testkit/ReplayRuntimeInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,102 @@ export function materializeReplayTranscriptRuntimeInstructions(
: undefined;
if (harness === undefined) return transcript;
const instructions = buildRuntimeInstructions({ harness, model: runtime.model });
// The notes name the model, so each prompt expects the ones for its session's current model.
const sessionModels = new Map<unknown, string>();
const notesFor = (sessionId: unknown) => {
const model = sessionModels.get(sessionId) ?? runtime.model;
return model === runtime.model ? instructions : buildRuntimeInstructions({ harness, model });
};
// Grok's flavor sets `supportsCompaction`; Kiro compacts through its own request instead.
const compactsThroughPrompt = runtime.driver === "grok";
// Like the adapter: notes go out until a prompt carrying that text succeeds, and a new runtime
// or compaction resets that. Not mirrored, as no recording has them: Copilot compaction events,
// child-session compactions, and ACP v2 prompts that settle through an idle `state_update`.
const deliveredNotes = new Map<unknown, string>();
// A prompt's success records delivery only if no compaction completed after it was sent.
const compactionCounts = new Map<unknown, number>();
const completedCompactionIds = new Set<string>();
// A turn that completes with a compaction still open projects it as completed.
const openCompactions = new Map<unknown, Set<unknown>>();
const markCompacted = (sessionId: unknown, compactionId?: unknown) => {
if (typeof compactionId === "string") {
const key = `${String(sessionId)}\u0000${compactionId}`;
if (completedCompactionIds.has(key)) return;
completedCompactionIds.add(key);
}
deliveredNotes.delete(sessionId);
compactionCounts.set(sessionId, (compactionCounts.get(sessionId) ?? 0) + 1);
};
let pendingPrompt:
| {
readonly sessionId: unknown;
/** The runtime notes the prompt carried, if any. */
readonly notes: string | undefined;
readonly compact: boolean;
readonly compactionsAtSend: number;
}
| undefined;
let pendingKiroCompact: { readonly sessionId: unknown } | undefined;

return {
...transcript,
entries: transcript.entries.map((entry) => {
if (entry.type === "emit_inbound") {
const frame = asFrame(entry.frame);
if (frame?.kind === "response" && frame.method === "session/prompt" && pendingPrompt) {
const stopReason = isRecord(frame.result) ? frame.result.stopReason : undefined;
const delivered =
frame.error === undefined &&
stopReason !== "cancelled" &&
stopReason !== "refusal" &&
stopReason !== "error";
const open = openCompactions.get(pendingPrompt.sessionId);
if (delivered && open !== undefined && open.size > 0) {
openCompactions.delete(pendingPrompt.sessionId);
markCompacted(pendingPrompt.sessionId);
}
if (delivered && pendingPrompt.compact) {
markCompacted(pendingPrompt.sessionId);
} else if (
delivered &&
pendingPrompt.notes !== undefined &&
(compactionCounts.get(pendingPrompt.sessionId) ?? 0) === pendingPrompt.compactionsAtSend
) {
deliveredNotes.set(pendingPrompt.sessionId, pendingPrompt.notes);
}
pendingPrompt = undefined;
}
if (
frame?.kind === "response" &&
frame.method === "_kiro/session/compact" &&
pendingKiroCompact
) {
// Kiro's adapter accepts only `{ success: true }`.
if (isRecord(frame.result) && frame.result.success === true) {
markCompacted(pendingKiroCompact.sessionId);
}
pendingKiroCompact = undefined;
}
const update =
frame?.method === "session/update" && isRecord(frame.params)
? frame.params.update
: undefined;
if (
isRecord(update) &&
update.sessionUpdate === "compaction_update" &&
isRecord(frame?.params)
) {
const sessionId = frame.params.sessionId;
const open = openCompactions.get(sessionId) ?? new Set<unknown>();
if (update.status === "in_progress") {
openCompactions.set(sessionId, open.add(update.compactionId));
} else {
open.delete(update.compactionId);
}
if (update.status === "completed") markCompacted(sessionId, update.compactionId);
}
return entry;
}
if (entry.type !== "expect_outbound") return entry;
const frame = entry.frame;
if (typeof frame !== "object" || frame === null) return entry;
Expand All @@ -38,6 +130,43 @@ export function materializeReplayTranscriptRuntimeInstructions(
? entry
: { ...entry, frame: { ...frame, message: `${frame.message}\n\n${instructions}` } };
}
if (
"method" in frame &&
(frame.method === "session/close" || frame.method === "initialize")
) {
// A closed or restarted session comes back in a new runtime, which resends the instructions.
deliveredNotes.clear();
completedCompactionIds.clear();
openCompactions.clear();
pendingPrompt = undefined;
pendingKiroCompact = undefined;
return entry;
}
const params = "params" in frame && isRecord(frame.params) ? frame.params : undefined;
const selectedModel =
"method" in frame && frame.method === "session/set_config_option"
? params?.configId === "model"
? params.value
: undefined
: "method" in frame && frame.method === "session/set_model"
? params?.modelId
: undefined;
if (typeof selectedModel === "string") {
sessionModels.set(params?.sessionId, selectedModel);
return entry;
}
if (
"method" in frame &&
frame.method === "_kiro/session/compact" &&
"params" in frame &&
typeof frame.params === "object" &&
frame.params !== null &&
"sessionId" in frame.params
) {
// Once it succeeds, Kiro's native compaction makes the next prompt resend them.
pendingKiroCompact = { sessionId: frame.params.sessionId };
return entry;
}
if (
"method" in frame &&
frame.method === "session/prompt" &&
Expand All @@ -47,30 +176,75 @@ export function materializeReplayTranscriptRuntimeInstructions(
"prompt" in frame.params &&
Array.isArray(frame.params.prompt)
) {
const promptSessionId = "sessionId" in frame.params ? frame.params.sessionId : undefined;
const prompt: ReadonlyArray<unknown> = frame.params.prompt;
const lastPart = prompt.at(-1);
if (
typeof lastPart === "object" &&
lastPart !== null &&
"type" in lastPart &&
lastPart.type === "text" &&
"text" in lastPart &&
typeof lastPart.text === "string" &&
// A recording that switched models mid-thread already carries the
// instructions for the model each prompt ran on.
(lastPart.text === instructions || lastPart.text.startsWith("<runtime_info>"))
) {
const firstText = promptPartText(prompt[0])?.trim();
// Like the adapter, native slash commands go out without instructions. Where the agent
// compacts through the prompt, a successful `/compact` makes the next prompt resend them.
if (firstText?.startsWith("/") === true) {
pendingPrompt = {
sessionId: promptSessionId,
notes: undefined,
compact:
compactsThroughPrompt && prompt.length === 1 && /^\/compact(?:\s|$)/.test(firstText),
compactionsAtSend: compactionCounts.get(promptSessionId) ?? 0,
};
return entry;
}
const lastText = promptPartText(prompt.at(-1));
const expected = notesFor(promptSessionId);
// A recording that switched models mid-thread already carries the
// instructions for the model each prompt ran on.
const recordedNotes =
lastText === expected || lastText?.startsWith("<runtime_info>") === true
? lastText
: undefined;
const resend =
recordedNotes === undefined && deliveredNotes.get(promptSessionId) !== expected;
pendingPrompt = {
sessionId: promptSessionId,
// Recorders normalize the notes they keep, so a recorded block stands for this model's.
notes: recordedNotes !== undefined || resend ? expected : undefined,
compact: false,
compactionsAtSend: compactionCounts.get(promptSessionId) ?? 0,
};
if (!resend) return entry;
return {
...entry,
frame: {
...frame,
params: { ...frame.params, prompt: [...prompt, { type: "text", text: instructions }] },
params: { ...frame.params, prompt: [...prompt, { type: "text", text: expected }] },
},
};
}
return entry;
}),
};
}

function promptPartText(part: unknown): string | undefined {
return typeof part === "object" &&
part !== null &&
"type" in part &&
part.type === "text" &&
"text" in part &&
typeof part.text === "string"
? part.text
: undefined;
}

type ReplayFrame = {
readonly kind?: unknown;
readonly method?: unknown;
readonly params?: unknown;
readonly result?: unknown;
readonly error?: unknown;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

function asFrame(value: unknown): ReplayFrame | undefined {
return isRecord(value) ? value : undefined;
}
Loading
Loading