mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: do not hold an AIR load replay until the CLI starts (#1433)
## Summary #1432 made an AIR `session/load` replay wait for the CLI start before it sent the children's models. So the end of every such replay (`loadReplayEnd` and the children's `subagent_state_update`) came only after the CLI started, also for a history without subagents. - The replay no longer waits. It sends each child's model as the replay reaches it. During a load the session's model list is not known yet, so the model goes as its id. - After the load response, each replayed child whose model the list maps to another entry gets that entry. This also applies to a child that the replay already ended. It is a more exact snapshot of the same configuration. - `modelEntryFor` and `modelDisplayName` keep their results per model list. `reportModel` skips a model that the child already has. - The buffer of a child that is not announced yet keeps only its latest configuration update, so model reports do not crowd out the child's output. - AIR: the runtime forgets the configuration of an ended child. ## Validation - `npm run build`, `npm run check` - `npm run test:run`, two runs: 3321 passed, 30 skipped. - `src/tests/acp-scenarios.test.ts`, 30 runs in 6 parallel workers: no failure. - New tests: the load end event before the CLI start, with and without subagents (both fail on `main`); the post-load refinement of an ended child; the buffer; the cleanup; the memo after a list change. - Microbenchmark of 10,000 child responses over 200 children, with 51 models: | | before | after | |---|---|---| | `modelEntryFor`, last entry | 12.16 µs | 0.23 µs | | `modelEntryFor`, no match | 64.60 µs | 0.08 µs | | `reportModel`, AIR | 13.66 µs | 4.03 µs | | `reportModel`, RFD | 12.80 µs | 3.23 µs | ## Related - Follow-up to https://github.com/agentclientprotocol/claude-agent-acp/pull/1432 - RFD change: https://github.com/agentclientprotocol/agent-client-protocol/pull/2482 - JetBrains issue: [IJAI-4754](https://youtrack.jetbrains.com/issue/IJAI-4754)
This commit is contained in:
@@ -122,8 +122,10 @@ import {
|
||||
NativeSubagent,
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
type ModelEntry,
|
||||
responseModelEntry,
|
||||
resumedNativeSubagentId,
|
||||
sameModelEntry,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
type SubagentModels,
|
||||
@@ -986,8 +988,6 @@ type ReplayState = Pick<
|
||||
/** A replay that runs before its session record exists. */
|
||||
type PendingReplay = {
|
||||
state: ReplayState;
|
||||
/** Settles when the session creation ends: the session record and its model list exist, or `stopped` is set. */
|
||||
sessionReady?: Promise<void>;
|
||||
/** Set when the session creation fails. The replay then sends no more updates. */
|
||||
stopped: boolean;
|
||||
};
|
||||
@@ -2728,6 +2728,15 @@ export class ClaudeAcpAgent {
|
||||
* {@link publishCurrentSubagentStates} sends its children's current states.
|
||||
*/
|
||||
private readonly replayedSubagents = new Map<string, RfdSubagentReplay>();
|
||||
/**
|
||||
* AIR's draft: the model that the last load of each session replayed to each
|
||||
* child session, as the model id and the entry that went out. See
|
||||
* {@link publishReplayedChildModels}.
|
||||
*/
|
||||
private readonly replayedChildModels = new Map<
|
||||
string,
|
||||
Map<string, { model: string; sent: ModelEntry }>
|
||||
>();
|
||||
/** See the `foreground` constructor option. */
|
||||
private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents;
|
||||
/**
|
||||
@@ -2806,6 +2815,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(id);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(id);
|
||||
if (this.sessions[id] === session) delete this.sessions[id];
|
||||
},
|
||||
settleCancelledTurn: (original, session, turn) => {
|
||||
@@ -3075,8 +3085,6 @@ export class ClaudeAcpAgent {
|
||||
},
|
||||
stopped: false,
|
||||
};
|
||||
let sessionCreated!: () => void;
|
||||
pending.sessionReady = new Promise((resolve) => (sessionCreated = resolve));
|
||||
const transcript = readResumedSession(params.sessionId, this.logger);
|
||||
const replay = transcript
|
||||
.then(({ messages }) => this.replaySessionHistory(params.sessionId, messages, pending))
|
||||
@@ -3102,11 +3110,10 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
} catch (error) {
|
||||
pending.stopped = true;
|
||||
sessionCreated();
|
||||
await replay;
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
throw error;
|
||||
}
|
||||
sessionCreated();
|
||||
timing.phase("session-ready");
|
||||
const replayed = await replay;
|
||||
if (!replayed.ok) throw replayed.error;
|
||||
@@ -8505,6 +8512,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(params.sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(params.sessionId);
|
||||
delete this.sessions[params.sessionId];
|
||||
} else {
|
||||
this.logger.error(`Session ${params.sessionId}: query stream error: ${message}`);
|
||||
@@ -9083,6 +9091,7 @@ export class ClaudeAcpAgent {
|
||||
clearHookCallbacks(sessionId);
|
||||
session.nativeSubagentRuntime?.clear();
|
||||
session.asyncTaskRuntime?.clear();
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
delete this.sessions[sessionId];
|
||||
this.sessionIndex.onTeardown(sessionId);
|
||||
const ended = await raceTimeoutAndAbort(
|
||||
@@ -9299,9 +9308,15 @@ export class ClaudeAcpAgent {
|
||||
notifiedState?: SubagentState;
|
||||
/** The model id of the child's latest response in its transcript. */
|
||||
responseModel?: string;
|
||||
/** The `model` option entry that the replay sent to the child. */
|
||||
model?: ModelEntry;
|
||||
}
|
||||
>();
|
||||
const replayModels = this.subagentModels(sessionId);
|
||||
// A load replays while the CLI starts, and the session's model list comes
|
||||
// with the CLI at an unknown point of the replay. So a pending replay maps
|
||||
// no model to the list, and each child's model is its id until the load
|
||||
// response refines it (see `publishCurrentSubagentStates`).
|
||||
const replayModels = pending ? undefined : this.subagentModels(sessionId);
|
||||
|
||||
// Registers the Agent and Task launches of `list`, and the terminal state
|
||||
// of each launch from its tool_result.
|
||||
@@ -9562,10 +9577,21 @@ export class ClaudeAcpAgent {
|
||||
if (rfdReplay && child) {
|
||||
await rfdReplay.responseModel(child, model);
|
||||
} else if (nativeReplayEnabled && parentToolUseId) {
|
||||
// AIR's draft: sent before the child's end, when the model list is known.
|
||||
// AIR's draft: the entry of the model list that is known now. A load
|
||||
// can lack the list until the CLI started, so the entry goes again
|
||||
// after the load response when the list maps it to another one.
|
||||
const replayChild = replayChildren.get(parentToolUseId);
|
||||
if (replayChild && model && model !== "<synthetic>") {
|
||||
const entry = responseModelEntry(model, replayModels);
|
||||
if (replayChild?.announced && entry && !sameModelEntry(replayChild.model, entry)) {
|
||||
replayChild.responseModel = model;
|
||||
replayChild.model = entry;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: replayChild.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9718,24 +9744,14 @@ export class ClaudeAcpAgent {
|
||||
this.replayedSubagents.set(sessionId, rfdReplay);
|
||||
}
|
||||
|
||||
// AIR's draft: each child's model goes before its end, which allows no
|
||||
// later update. The model maps to an entry of the session's model list,
|
||||
// which a load knows only when the CLI started (see `sessionReady`).
|
||||
if (nativeReplayEnabled && !pending?.stopped) await pending?.sessionReady;
|
||||
if (nativeReplayEnabled && !pending?.stopped) {
|
||||
const childModels = new Map<string, { model: string; sent: ModelEntry }>();
|
||||
for (const child of replayChildren.values()) {
|
||||
const model = child.announced
|
||||
? responseModelEntry(child.responseModel, replayModels)
|
||||
: undefined;
|
||||
if (!model) continue;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(model)],
|
||||
},
|
||||
});
|
||||
if (child.responseModel && child.model) {
|
||||
childModels.set(child.sessionId, { model: child.responseModel, sent: child.model });
|
||||
}
|
||||
}
|
||||
if (childModels.size > 0) this.replayedChildModels.set(sessionId, childModels);
|
||||
// Claude history persists sidechain messages and Agent/Task tool uses,
|
||||
// but not task_started/task_updated lifecycle frames. Recover terminal
|
||||
// state from the launch tool_result. Missing results and malformed or
|
||||
@@ -10785,6 +10801,7 @@ export class ClaudeAcpAgent {
|
||||
* as history.
|
||||
*/
|
||||
async publishCurrentSubagentStates(sessionId: string): Promise<void> {
|
||||
await this.publishReplayedChildModels(sessionId);
|
||||
const replay = this.replayedSubagents.get(sessionId);
|
||||
if (!replay) return;
|
||||
this.replayedSubagents.delete(sessionId);
|
||||
@@ -10801,6 +10818,36 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AIR's draft: a load replays while the CLI starts, so the replay can lack
|
||||
* the session's model list and send a child's model as its id. After the
|
||||
* load response the list is known, and each replayed child whose model the
|
||||
* list maps to another entry gets that entry. The replay already ended the
|
||||
* child, and AIR's draft allows no other update after a child's end, but it
|
||||
* takes this more exact snapshot of the same configuration.
|
||||
*/
|
||||
private async publishReplayedChildModels(sessionId: string): Promise<void> {
|
||||
const childModels = this.replayedChildModels.get(sessionId);
|
||||
if (!childModels) return;
|
||||
this.replayedChildModels.delete(sessionId);
|
||||
const models = this.subagentModels(sessionId);
|
||||
try {
|
||||
for (const [childSessionId, { model, sent }] of childModels) {
|
||||
const entry = responseModelEntry(model, models);
|
||||
if (!entry || sameModelEntry(entry, sent)) continue;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: childSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(entry)],
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send subagent models:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateConfigOption(
|
||||
sessionId: string,
|
||||
configId: string,
|
||||
|
||||
@@ -281,14 +281,23 @@ export class NativeSubagentRuntime {
|
||||
model: string | undefined,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
if (!this.form || !parentToolUseId || !model || model === "<synthetic>") {
|
||||
return;
|
||||
const entry = this.form && parentToolUseId ? responseModelEntry(model, this.models) : undefined;
|
||||
if (!entry) return;
|
||||
// Every response of a child reports its model: most repeat the model that
|
||||
// the client already has, and need no routing.
|
||||
const child = this.childByParentToolUse.get(parentToolUseId!);
|
||||
const current = child?.announced
|
||||
? this.configBySession.get(child.sessionId)?.options.get(MODEL_CONFIG_ID)
|
||||
: undefined;
|
||||
if (current?.type === "select" && current.currentValue === entry.value) {
|
||||
const shown = current.options[0];
|
||||
if (shown && "name" in shown && shown.name === entry.name) return;
|
||||
}
|
||||
await deliver({
|
||||
sessionId: this.rootSessionId,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [childModelOption(responseModelEntry(model, this.models)!)],
|
||||
configOptions: [childModelOption(entry)],
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
});
|
||||
@@ -316,6 +325,14 @@ export class NativeSubagentRuntime {
|
||||
// `<agent id>:generation:<n>`, so the agent id names its current generation.
|
||||
const known = this.children.get(agentId) ?? this.childOfSession(agentId);
|
||||
const sessionId = known?.sessionId ?? agentId;
|
||||
// AIR's draft takes no configuration of an ended child (see `finishTask`).
|
||||
if (
|
||||
this.form === "air" &&
|
||||
known &&
|
||||
(known.terminalState !== undefined || known.terminalPromise)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let config = this.configBySession.get(sessionId);
|
||||
if (!config) {
|
||||
const pending = [...this.configBySession.keys()].filter(
|
||||
@@ -718,6 +735,8 @@ export class NativeSubagentRuntime {
|
||||
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
||||
} else {
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
// AIR's draft takes no configuration of an ended child.
|
||||
this.configBySession.delete(child.sessionId);
|
||||
}
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
@@ -1399,6 +1418,17 @@ export class NativeSubagentRuntime {
|
||||
|
||||
private buffer(parentToolUseId: string, notification: AcpSessionNotification): void {
|
||||
const updates = this.pending.get(parentToolUseId);
|
||||
// A child reports its model with each response before its announcement.
|
||||
// Only the latest configuration waits, so they do not fill the buffer.
|
||||
if (notification.update.sessionUpdate === "config_option_update" && updates) {
|
||||
const index = updates.findIndex(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
if (index >= 0) {
|
||||
updates[index] = notification;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.pendingCount >= MAX_PENDING_UPDATES ||
|
||||
(updates === undefined && this.pending.size >= MAX_PENDING_PARENTS) ||
|
||||
@@ -1527,6 +1557,11 @@ export function childModelOption(entry: ModelEntry): SessionConfigOption {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether two model entries have the same value and name. */
|
||||
export function sameModelEntry(left: ModelEntry | undefined, right: ModelEntry): boolean {
|
||||
return left?.value === right.value && left.name === right.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model entry for `model`, the model id of a child's response: the entry
|
||||
* of the root session's `model` option that matches it, else the id, with its
|
||||
|
||||
@@ -609,10 +609,18 @@ export async function getAvailableModels(
|
||||
|
||||
/** The display name of the model id `model` in `modelInfos`, if it is there. */
|
||||
export function modelDisplayName(modelInfos: ModelInfo[], model: string): string | undefined {
|
||||
return modelInfos.find((info) => info.value === model || info.resolvedModel === model)
|
||||
?.displayName;
|
||||
let byModel = displayNameCache.get(modelInfos);
|
||||
if (!byModel) displayNameCache.set(modelInfos, (byModel = new Map()));
|
||||
if (!byModel.has(model)) {
|
||||
const info = modelInfos.find((info) => info.value === model || info.resolvedModel === model);
|
||||
byModel.set(model, info?.displayName);
|
||||
}
|
||||
return byModel.get(model);
|
||||
}
|
||||
|
||||
/** {@link modelDisplayName} per model list, which a session replaces and never edits. */
|
||||
const displayNameCache = new WeakMap<ModelInfo[], Map<string, string | undefined>>();
|
||||
|
||||
/** The flat entries of a session's `model` select option. */
|
||||
function modelOptionEntries(option: SessionConfigOption): Array<{ value: string; name: string }> {
|
||||
if (option.type !== "select") return [];
|
||||
@@ -642,6 +650,28 @@ export function modelEntryFor(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
// Each response of a subagent reports its model, so the result is kept per
|
||||
// list. A session replaces its lists on a change and never edits them.
|
||||
let byInfos = modelEntryCache.get(configOptions);
|
||||
if (!byInfos) modelEntryCache.set(configOptions, (byInfos = new WeakMap()));
|
||||
let byModel = byInfos.get(modelInfos);
|
||||
if (!byModel) byInfos.set(modelInfos, (byModel = new Map()));
|
||||
if (byModel.has(model)) return byModel.get(model);
|
||||
const entry = findModelEntry(configOptions, modelInfos, model);
|
||||
byModel.set(model, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
const modelEntryCache = new WeakMap<
|
||||
readonly SessionConfigOption[],
|
||||
WeakMap<ModelInfo[], Map<string, { value: string; name: string } | undefined>>
|
||||
>();
|
||||
|
||||
function findModelEntry(
|
||||
configOptions: readonly SessionConfigOption[],
|
||||
modelInfos: ModelInfo[],
|
||||
model: string,
|
||||
): { value: string; name: string } | undefined {
|
||||
const option = configOptions.find((candidate) => candidate.id === MODEL_CONFIG_ID);
|
||||
if (option?.type !== "select") return undefined;
|
||||
|
||||
@@ -3586,7 +3586,7 @@ describe("subagent transcript replay", () => {
|
||||
return updates;
|
||||
}
|
||||
|
||||
it("sends an AIR child's model as the session's entry once the session exists, before its end", async () => {
|
||||
it("refines an ended AIR child's model after the load response, once the model list is known", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
@@ -3596,26 +3596,25 @@ describe("subagent transcript replay", () => {
|
||||
{ log: () => {}, error: () => {} },
|
||||
);
|
||||
await initializeClient(agent, AIR_NATIVE_SUBAGENTS);
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce(replayHistory);
|
||||
// A load replays while the CLI starts: the session and its model list come later.
|
||||
let sessionCreated!: () => void;
|
||||
const pending = {
|
||||
state: { cwd: "/tmp", taskState: new Map(), messageIdToUuid: new Map() },
|
||||
stopped: false,
|
||||
sessionReady: new Promise<void>((resolve) => (sessionCreated = resolve)),
|
||||
};
|
||||
const replaying = (
|
||||
agent as unknown as {
|
||||
replaySessionHistory(
|
||||
sessionId: string,
|
||||
messages: unknown[],
|
||||
pending: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
).replaySessionHistory("s1", replayHistory, pending);
|
||||
await vi.waitFor(() =>
|
||||
expect(updates.map(({ update }) => update.sessionUpdate)).toContain("tool_call"),
|
||||
);
|
||||
expect(updates.map(({ update }) => update.sessionUpdate)).not.toContain("config_option_update");
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
const child = "s1:replay-subagent:parent-agent-call";
|
||||
const models = () =>
|
||||
updates.flatMap(({ sessionId, update }) =>
|
||||
update.sessionUpdate === "config_option_update"
|
||||
? update.configOptions.map((option) => [
|
||||
sessionId,
|
||||
option.currentValue,
|
||||
(option as { options: Array<{ name: string }> }).options[0]?.name,
|
||||
])
|
||||
: [],
|
||||
);
|
||||
// Without the list, the replay sends the model id, and then ends the child.
|
||||
expect(models()).toEqual([[child, "claude-sonnet-4-5", "claude-sonnet-4-5"]]);
|
||||
expect(updates.at(-1)?.update.sessionUpdate).toBe("subagent_state_update");
|
||||
(agent.sessions as Record<string, unknown>).s1 = {
|
||||
configOptions: [
|
||||
{
|
||||
@@ -3636,29 +3635,14 @@ describe("subagent transcript replay", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
sessionCreated();
|
||||
await replaying;
|
||||
expect(updates.slice(-2)).toEqual([
|
||||
{
|
||||
sessionId: "s1:replay-subagent:parent-agent-call",
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.5" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({ sessionUpdate: "subagent_state_update" }),
|
||||
}),
|
||||
await agent.publishCurrentSubagentStates("s1");
|
||||
expect(models()).toEqual([
|
||||
[child, "claude-sonnet-4-5", "claude-sonnet-4-5"],
|
||||
[child, "sonnet", "Sonnet 4.5"],
|
||||
]);
|
||||
// Once only.
|
||||
await agent.publishCurrentSubagentStates("s1");
|
||||
expect(models()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("replays a subagent from its own transcript file", async () => {
|
||||
@@ -3817,13 +3801,13 @@ describe("subagent transcript replay", () => {
|
||||
[sessionId, "user_message_chunk", "Explore it"],
|
||||
[sessionId, "subagent_spawned", child1],
|
||||
[child1, "user_message_chunk", "Look around"],
|
||||
// The model of the child's response, as the replay reaches it.
|
||||
[child1, "config_option_update", undefined],
|
||||
[child1, "agent_message_chunk", "Child report."],
|
||||
// A launch without a subagent file still gets its child and its state.
|
||||
// Nothing names its model, so it gets none.
|
||||
[sessionId, "subagent_spawned", child2],
|
||||
[sessionId, "agent_message_chunk", "Done."],
|
||||
// The model of the child's responses goes before its end, which allows no later update.
|
||||
[child1, "config_option_update", undefined],
|
||||
[sessionId, "subagent_state_update", child2],
|
||||
[sessionId, "subagent_state_update", child1],
|
||||
[sessionId, "session_info_update", undefined],
|
||||
@@ -4455,10 +4439,10 @@ describe("subagent transcript replay", () => {
|
||||
const updates = await replay("native");
|
||||
expect(updates.map(({ sessionId, update }) => [sessionId, update.sessionUpdate])).toEqual([
|
||||
["s1", "subagent_spawned"],
|
||||
// The model of its response, as the replay reaches it.
|
||||
["s1:replay-subagent:parent-agent-call", "config_option_update"],
|
||||
["s1:replay-subagent:parent-agent-call", "agent_message_chunk"],
|
||||
["s1:replay-subagent:parent-agent-call", "tool_call"],
|
||||
// The model of its response, before its end.
|
||||
["s1:replay-subagent:parent-agent-call", "config_option_update"],
|
||||
["s1", "subagent_state_update"],
|
||||
]);
|
||||
expect(updates[0]?.update).toMatchObject({
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_output_delta":{"data":"app.ts","terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"toolu_r_bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_bash"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"rawOutput":"The file <cwd>/src/app.ts has been updated successfully.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_r_edit"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"claude-sonnet-4-6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_r_task"}},"content":{"text":"Subagent text.","type":"text"},"messageId":"msg_r_sub","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"<session>:replay-subagent:toolu_r_task"}}}
|
||||
{"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":"<session>"}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>:replay-subagent:toolu_r_task","update":{"configOptions":[{"category":"model","currentValue":"claude-sonnet-4-6","id":"model","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
|
||||
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
|
||||
|
||||
@@ -2158,6 +2158,66 @@ describe("createSession options merging", () => {
|
||||
await loading;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["without subagents", [] as Record<string, unknown>[]],
|
||||
[
|
||||
"with a subagent",
|
||||
[
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "child-message",
|
||||
session_id: "replay-probe",
|
||||
parent_tool_use_id: "agent-call",
|
||||
message: {
|
||||
id: "api-child-message",
|
||||
model: "claude-sonnet-4-6",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "child report" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
])(
|
||||
"sends the end event before the CLI start finishes for native subagent sessions (%s)",
|
||||
async (_label, history) => {
|
||||
let releaseInit!: () => void;
|
||||
initGate = new Promise((resolve) => (releaseInit = resolve));
|
||||
sessionMessages = [userMessage("u1", "first"), ...history];
|
||||
const updates: SessionNotification[] = [];
|
||||
const loading = (
|
||||
await endAgent(updates, undefined, {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: ["loadReplayEnd", "nativeSubagentSessions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClientCapabilities)
|
||||
).loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
// The child's model goes as the replay reaches it, with no model list yet.
|
||||
await vi.waitFor(() =>
|
||||
expect(kinds(updates)).toEqual(
|
||||
history.length === 0
|
||||
? ["user_message_chunk", "end"]
|
||||
: [
|
||||
"user_message_chunk",
|
||||
"subagent_spawned",
|
||||
"config_option_update",
|
||||
"agent_message_chunk",
|
||||
"subagent_state_update",
|
||||
"end",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
releaseInit();
|
||||
await loading;
|
||||
},
|
||||
);
|
||||
|
||||
it("sends the end event for an empty history", async () => {
|
||||
sessionMessages = [];
|
||||
const updates: SessionNotification[] = [];
|
||||
|
||||
@@ -452,6 +452,23 @@ describe("modelEntryFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a model again when the session replaces its option list", () => {
|
||||
const before = options("haiku");
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
// The user picks `opus[1m]`: the session replaces its option list.
|
||||
expect(modelEntryFor(options("opus[1m]"), infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus[1m]",
|
||||
name: "Opus 4.6 (1M context)",
|
||||
});
|
||||
expect(modelEntryFor(before, infos, "claude-opus-4-6")).toEqual({
|
||||
value: "opus",
|
||||
name: "Opus 4.6",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares a dated or a provider model id as it is", () => {
|
||||
// `resolvedModel` is the wire id that a response reports, date and provider prefix included.
|
||||
const dated: ModelInfo[] = [
|
||||
|
||||
@@ -2628,6 +2628,77 @@ describe("NativeSubagentRuntime child models", () => {
|
||||
expect(modelUpdates(published)).toEqual([[generation, "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it.each(["rfd", "air"] as const)(
|
||||
"%s: keeps only the latest model of a child that is not announced yet",
|
||||
async (form) => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
form,
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
undefined,
|
||||
undefined,
|
||||
models,
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification) => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) published.push(routed);
|
||||
};
|
||||
// More responses than the buffer of one parent holds (32), before the launch call.
|
||||
for (let index = 0; index < 40; index++) {
|
||||
await runtime.reportModel(
|
||||
"launch-1",
|
||||
index % 2 ? "claude-opus-5" : "claude-sonnet-4-6",
|
||||
deliver,
|
||||
);
|
||||
}
|
||||
await runtime.route(
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "working" },
|
||||
_meta: { claudeCode: { parentToolUseId: "launch-1" } },
|
||||
},
|
||||
} as AcpSessionNotification,
|
||||
deliver,
|
||||
);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(agentCall("launch-1", {}));
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "claude-opus-5", "Opus 5"]]);
|
||||
// The child's output is not crowded out of the buffer.
|
||||
expect(published.map(({ update }) => update.sessionUpdate)).toContain("agent_message_chunk");
|
||||
},
|
||||
);
|
||||
|
||||
it("air: forgets the configuration of an ended child", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.reportModel("launch-1", "claude-sonnet-4-6", deliver);
|
||||
expect(runtime.configurationOf("worker-1")).toHaveLength(1);
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
// A hook after the end stores nothing either.
|
||||
await runtime.reportConfiguration("worker-1", [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: "sonnet",
|
||||
options: [{ value: "sonnet", name: "Sonnet 4.6" }],
|
||||
},
|
||||
]);
|
||||
expect(runtime.configurationOf("worker-1")).toBeUndefined();
|
||||
expect(modelUpdates(published)).toEqual([["worker-1", "sonnet", "Sonnet 4.6"]]);
|
||||
});
|
||||
|
||||
it("air: sends no model to a child after its end", async () => {
|
||||
const { runtime, published, deliver } = await spawn("air");
|
||||
await runtime.finishTask("worker-1", "completed", deliver, "launch-1");
|
||||
|
||||
Reference in New Issue
Block a user