kimi-code/packages/acp-server/test/config.test.ts
liruifengv ad12ad8a14
feat(kimi-code): show live background agent activity in the /tasks panel (#2816)
* feat(kimi-code): show live background agent activity in the /tasks panel

Background agents (run_in_background or Ctrl+B) showed no run details:
the /tasks panel only had static metadata, and its output view stays
"[no output captured]" until completion because agent tasks capture
output only once at the end.

Tee child-agent events into a bounded in-memory per-agent activity
store segmented by the engine's own turn.step.started events (recent
10 steps, bounded text/output tails). The /tasks preview pane now
shows a live activity preview for agent tasks, and Enter/O opens a
full-screen detail view rendering step-grouped Markdown text and
per-tool results through the main transcript's renderers, with Ctrl+O
to expand. Agent tasks without an in-memory record (e.g. lost after
resume) fall back to the captured-output view.

* feat(kimi-code): retain 20 recent steps in the background agent activity view

* fix(kimi-code): cap the streaming-args buffer in the subagent activity store

* chore(kimi-code): simplify the background agent activity changeset

* fix(kimi-code): drop activity records of foreground-only subagents at terminal state

* fix(kimi-code): cap retained tool argument strings in the subagent activity store

* test(acp-server): retry temp-dir cleanup to deflake ENOTEMPTY on CI

* fix(kimi-code): tighten subagent activity store lifecycle edges

- drop delta-only arg buffers when their step is evicted
- keep records of spawn-time background agents even when the task sync lags
- mark records terminal on background.task.terminated for stopped agents
  that never emit subagent.failed

* fix(kimi-code): release leftover arg buffers when an activity record turns terminal

* fix(kimi-code): prune foreground-only activity records when the main turn ends
2026-08-11 21:32:39 +08:00

525 lines
18 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { AcpSession } from '../src/session';
import { createTestClient, type TestClient } from './_helpers/acpClient';
import { FAKE_MODEL_ALT_ID, writeFakeModelConfig } from './_helpers/fakeModelConfig';
interface ConfigOption {
readonly id: string;
readonly currentValue: string;
readonly options?: ReadonlyArray<{ readonly value: string; readonly name?: string }>;
}
interface ModesState {
readonly currentModeId: string;
readonly availableModes: ReadonlyArray<{ readonly id: string }>;
}
interface NewSessionResult {
readonly sessionId: string;
readonly configOptions: readonly ConfigOption[];
readonly modes?: ModesState;
}
describe('acp-server config surface', () => {
let homeDir: string | undefined;
let client: TestClient | undefined;
afterEach(async () => {
if (client !== undefined) {
await client.close();
client = undefined;
}
if (homeDir !== undefined) {
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
homeDir = undefined;
}
});
async function boot(opts?: {
fakeModel?: boolean;
thinking?: boolean;
supportEfforts?: readonly string[];
defaultEffort?: string;
altThinking?: boolean;
altSupportEfforts?: readonly string[];
altDefaultEffort?: string;
}): Promise<TestClient> {
homeDir = await mkdtemp(join(tmpdir(), 'acp-config-'));
if (opts?.fakeModel === true) {
await writeFakeModelConfig(homeDir, {
thinking: opts?.thinking === true,
supportEfforts: opts?.supportEfforts,
defaultEffort: opts?.defaultEffort,
altThinking: opts?.altThinking === true,
altSupportEfforts: opts?.altSupportEfforts,
altDefaultEffort: opts?.altDefaultEffort,
});
}
client = await createTestClient({ homeDir });
await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
return client;
}
async function newSession(): Promise<NewSessionResult> {
return (await client!.send('session/new', {
cwd: homeDir,
mcpServers: [],
})) as NewSessionResult;
}
it(
'session/new advertises mode + model pickers (no thinking without a model)',
async () => {
await boot();
const { configOptions } = await newSession();
const ids = configOptions.map((o) => o.id);
expect(ids).toContain('mode');
expect(ids).toContain('model');
expect(ids).not.toContain('thinking');
const mode = configOptions.find((o) => o.id === 'mode')!;
expect(mode.currentValue).toBe('default');
},
30_000,
);
it(
'session/new advertises the first-class modes state',
async () => {
await boot();
const { modes } = await newSession();
expect(modes?.currentModeId).toBe('default');
expect(modes?.availableModes.map((m) => m.id)).toEqual([
'default',
'plan',
'auto',
'yolo',
]);
},
30_000,
);
it(
'session/set_mode pushes current_mode_update alongside config_option_update',
async () => {
await boot();
const { sessionId } = await newSession();
const modeUpdatePromise = client!.waitForSessionUpdate('current_mode_update');
const configUpdatePromise = client!.waitForSessionUpdate('config_option_update');
await client!.send('session/set_mode', { sessionId, modeId: 'yolo' });
const modeNotification = await modeUpdatePromise;
const modeUpdate = (modeNotification.params as { update?: { currentModeId?: string } })
.update;
expect(modeUpdate?.currentModeId).toBe('yolo');
// The configOptions arm still refreshes for config-option clients.
const configNotification = await configUpdatePromise;
const configUpdate = (
configNotification.params as { update?: { configOptions?: readonly ConfigOption[] } }
).update;
expect(configUpdate?.configOptions?.find((o) => o.id === 'mode')?.currentValue).toBe('yolo');
},
30_000,
);
it(
'session/set_mode propagates plan errors without reporting a new mode',
async () => {
const session = Object.create(AcpSession.prototype) as AcpSession;
const updates: unknown[] = [];
const agent = {
enterPlan: async () => {
throw new Error('plan toggle failed');
},
setPermission: async () => {},
};
Object.assign(session as unknown as Record<string, unknown>, {
agent,
conn: { sessionUpdate: async (update: unknown) => updates.push(update) },
sessionId: 'session-test',
currentModeId: 'default',
});
await expect(session.setMode('plan')).rejects.toThrow('plan toggle failed');
expect((session as unknown as { currentModeId: string }).currentModeId).toBe('default');
expect(updates).toEqual([]);
},
30_000,
);
it(
'session/set_config_option mode also pushes current_mode_update',
async () => {
await boot();
const { sessionId } = await newSession();
const modeUpdatePromise = client!.waitForSessionUpdate('current_mode_update');
await client!.send('session/set_config_option', {
sessionId,
configId: 'mode',
value: 'plan',
});
const modeNotification = await modeUpdatePromise;
const modeUpdate = (modeNotification.params as { update?: { currentModeId?: string } })
.update;
expect(modeUpdate?.currentModeId).toBe('plan');
},
30_000,
);
it(
'session/set_config_option mode updates the returned snapshot',
async () => {
await boot();
const { sessionId } = await newSession();
const result = (await client!.send('session/set_config_option', {
sessionId,
configId: 'mode',
value: 'yolo',
})) as { configOptions: readonly ConfigOption[] };
const mode = result.configOptions.find((o) => o.id === 'mode')!;
expect(mode.currentValue).toBe('yolo');
},
30_000,
);
it(
'session/set_config_option rejects an unknown modeId',
async () => {
await boot();
const { sessionId } = await newSession();
await expect(
client!.send('session/set_config_option', {
sessionId,
configId: 'mode',
value: 'bogus',
}),
).rejects.toThrow();
},
30_000,
);
it(
'session/set_config_option rejects an unknown configId',
async () => {
await boot();
const { sessionId } = await newSession();
await expect(
client!.send('session/set_config_option', {
sessionId,
configId: 'nope',
value: 'x',
}),
).rejects.toThrow();
},
30_000,
);
it(
'session/set_model switches the model and pushes config_option_update',
async () => {
await boot({ fakeModel: true });
const { sessionId } = await newSession();
// `session/set_model` was dropped from the SDK's legacy Agent interface
// in 1.x; the server keeps it as an extMethod special-case.
const updatePromise = client!.waitForSessionUpdate('config_option_update');
const result = await client!.send('session/set_model', {
sessionId,
modelId: FAKE_MODEL_ALT_ID,
});
expect(result).toEqual({});
// The switch reached the engine, not just the ACP surface.
await expect(
client!.server.klient.session(sessionId).agent('main').getModel(),
).resolves.toBe(FAKE_MODEL_ALT_ID);
const notification = await updatePromise;
const update = (
notification.params as { update?: { configOptions?: readonly ConfigOption[] } }
).update;
const model = update?.configOptions?.find((o) => o.id === 'model');
expect(model?.currentValue).toBe(FAKE_MODEL_ALT_ID);
},
30_000,
);
it(
'session/set_model rejects malformed params',
async () => {
await boot({ fakeModel: true });
const { sessionId } = await newSession();
await expect(
client!.send('session/set_model', { sessionId, modelId: 42 }),
).rejects.toThrow();
await expect(
client!.send('session/set_model', { sessionId: 'nope', modelId: FAKE_MODEL_ALT_ID }),
).rejects.toThrow();
},
30_000,
);
it(
'the merged "<id>,thinking" form sets the bare model and flips thinking on at its default effort',
async () => {
await boot({
fakeModel: true,
altThinking: true,
altSupportEfforts: ['low', 'high'],
altDefaultEffort: 'high',
});
const { sessionId } = await newSession();
const agent = client!.server.klient.session(sessionId).agent('main');
const result = await client!.send('session/set_model', {
sessionId,
modelId: `${FAKE_MODEL_ALT_ID},thinking`,
});
expect(result).toEqual({});
// The bare id reached the engine; thinking flipped on at the NEW
// model's declared default effort.
await expect(agent.getModel()).resolves.toBe(FAKE_MODEL_ALT_ID);
await expect(agent.getThinking()).resolves.toBe('high');
// The picker snapshot never carries the `,thinking` suffix.
const listed = (await client!.send('session/set_config_option', {
sessionId,
configId: 'model',
value: `${FAKE_MODEL_ALT_ID},thinking`,
})) as { configOptions: readonly ConfigOption[] };
expect(listed.configOptions.find((o) => o.id === 'model')?.currentValue).toBe(
FAKE_MODEL_ALT_ID,
);
await expect(agent.getModel()).resolves.toBe(FAKE_MODEL_ALT_ID);
await expect(agent.getThinking()).resolves.toBe('high');
},
30_000,
);
it(
'a bare set_model id does not turn thinking off',
async () => {
await boot({ fakeModel: true, thinking: true, altThinking: true });
const { sessionId } = await newSession();
const agent = client!.server.klient.session(sessionId).agent('main');
// The default model is thinking-capable → thinking starts on.
await expect(agent.getThinking()).resolves.not.toBe('off');
await client!.send('session/set_model', { sessionId, modelId: FAKE_MODEL_ALT_ID });
await expect(agent.getModel()).resolves.toBe(FAKE_MODEL_ALT_ID);
// Model and thinking stay orthogonal: the requested level survives the
// switch (the engine re-resolves it against the new thinking-capable
// model — it must not land on 'off').
await expect(agent.getThinking()).resolves.not.toBe('off');
},
30_000,
);
it(
'session/new advertises the thinking toggle for a thinking-capable model',
async () => {
await boot({ fakeModel: true, thinking: true });
const { configOptions } = await newSession();
const thinking = configOptions.find((o) => o.id === 'thinking');
// A thinking-capable model defaults to thinking on (the engine resolves
// the model's default effort when nothing is configured).
expect(thinking?.currentValue).toBe('on');
},
30_000,
);
it(
'session/new omits the thinking toggle for a non-thinking model',
async () => {
await boot({ fakeModel: true });
const { configOptions } = await newSession();
expect(configOptions.map((o) => o.id)).not.toContain('thinking');
},
30_000,
);
it(
'session/set_config_option thinking takes effect and pushes config_option_update',
async () => {
await boot({ fakeModel: true, thinking: true });
const { sessionId } = await newSession();
const updatePromise = client!.waitForSessionUpdate('config_option_update');
const result = (await client!.send('session/set_config_option', {
sessionId,
configId: 'thinking',
value: 'off',
})) as { configOptions: readonly ConfigOption[] };
expect(result.configOptions.find((o) => o.id === 'thinking')?.currentValue).toBe('off');
// The toggle reached the engine, not just the ACP surface.
await expect(
client!.server.klient.session(sessionId).agent('main').getThinking(),
).resolves.toBe('off');
const notification = await updatePromise;
const update = (
notification.params as { update?: { configOptions?: readonly ConfigOption[] } }
).update;
expect(update?.configOptions?.find((o) => o.id === 'thinking')?.currentValue).toBe('off');
},
30_000,
);
it(
'session/set_config_option rejects an unknown thinking value',
async () => {
await boot({ fakeModel: true, thinking: true });
const { sessionId } = await newSession();
await expect(
client!.send('session/set_config_option', {
sessionId,
configId: 'thinking',
value: 'bogus',
}),
).rejects.toThrow();
},
30_000,
);
it(
'an effort-capable model advertises off + every declared effort',
async () => {
await boot({
fakeModel: true,
thinking: true,
supportEfforts: ['low', 'high'],
defaultEffort: 'high',
});
const { configOptions } = await newSession();
const thinking = configOptions.find((o) => o.id === 'thinking');
expect(thinking?.options?.map((o) => o.value)).toEqual(['off', 'low', 'high']);
// Nothing configured → the engine resolves the model's default effort.
expect(thinking?.currentValue).toBe('high');
},
30_000,
);
it(
'setting a declared effort level takes effect and pushes config_option_update',
async () => {
await boot({
fakeModel: true,
thinking: true,
supportEfforts: ['low', 'high'],
defaultEffort: 'high',
});
const { sessionId } = await newSession();
const updatePromise = client!.waitForSessionUpdate('config_option_update');
const result = (await client!.send('session/set_config_option', {
sessionId,
configId: 'thinking',
value: 'low',
})) as { configOptions: readonly ConfigOption[] };
expect(result.configOptions.find((o) => o.id === 'thinking')?.currentValue).toBe('low');
// The level reached the engine, not just the ACP surface.
await expect(
client!.server.klient.session(sessionId).agent('main').getThinking(),
).resolves.toBe('low');
const notification = await updatePromise;
const update = (
notification.params as { update?: { configOptions?: readonly ConfigOption[] } }
).update;
expect(update?.configOptions?.find((o) => o.id === 'thinking')?.currentValue).toBe('low');
},
30_000,
);
it(
"an effort-capable model maps the legacy 'on' to its default effort",
async () => {
await boot({
fakeModel: true,
thinking: true,
supportEfforts: ['low', 'high'],
defaultEffort: 'high',
});
const { sessionId } = await newSession();
const result = (await client!.send('session/set_config_option', {
sessionId,
configId: 'thinking',
value: 'on',
})) as { configOptions: readonly ConfigOption[] };
expect(result.configOptions.find((o) => o.id === 'thinking')?.currentValue).toBe('high');
await expect(
client!.server.klient.session(sessionId).agent('main').getThinking(),
).resolves.toBe('high');
},
30_000,
);
it(
'an effort-capable model rejects an undeclared effort with invalid_params',
async () => {
await boot({
fakeModel: true,
thinking: true,
supportEfforts: ['low', 'high'],
defaultEffort: 'high',
});
const { sessionId } = await newSession();
await expect(
client!.send('session/set_config_option', {
sessionId,
configId: 'thinking',
value: 'banana',
}),
).rejects.toThrow(/-32602/);
},
30_000,
);
it(
'a Claude model on an Anthropic-typed provider advertises the thinking toggle via engine-derived capabilities',
async () => {
homeDir = await mkdtemp(join(tmpdir(), 'acp-config-'));
// No declared capabilities: the engine's catalog (`effectiveModelConfig`
// with the provider type) infers the Anthropic thinking profile for the
// Claude wire name, so the wire `capabilities`/`support_efforts` the ACP
// host reads already carry the derivation.
await writeFile(
join(homeDir, 'config.toml'),
`defaultModel = "claude"
[providers.anthro]
type = "anthropic"
baseUrl = "http://localhost"
apiKey = "test-token"
[models.claude]
provider = "anthro"
name = "claude-sonnet-4-5"
maxContextSize = 200000
`,
'utf8',
);
client = await createTestClient({ homeDir });
await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} });
const { configOptions } = await newSession();
const thinking = configOptions.find((o) => o.id === 'thinking');
expect(thinking).toBeDefined();
// The inferred profile is effort-granular, not a bare off/on toggle.
expect(thinking?.options?.length).toBeGreaterThan(2);
expect(thinking?.options?.some((o) => o.value === 'off')).toBe(true);
},
30_000,
);
it(
'a $/cancel_request notification is accepted without error',
async () => {
await boot();
await newSession();
// The SDK handles the JSON-RPC-level cancel notification internally; a
// stray one (unknown id) must be a no-op, not a connection error.
client!.notify('$/cancel_request', { id: 999_999 });
const list = (await client!.send('session/list', {})) as { sessions: unknown[] };
expect(list.sessions.length).toBeGreaterThan(0);
},
30_000,
);
});