mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-20 22:26:23 +00:00
* 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
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { PassThrough, Readable, Writable } from 'node:stream';
|
|
|
|
import { ndJsonStream } from '@agentclientprotocol/sdk';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { runAcpServerWithStream } from '../src/start';
|
|
import { CURRENT_VERSION, MIN_PROTOCOL_VERSION, negotiateVersion } from '../src/version';
|
|
|
|
interface JsonRpcMessage {
|
|
readonly jsonrpc?: string;
|
|
readonly id?: number | string;
|
|
readonly method?: string;
|
|
readonly result?: unknown;
|
|
readonly error?: unknown;
|
|
}
|
|
|
|
/** Read a single ND-JSON JSON-RPC message off a readable stream. */
|
|
async function readOneMessage(readable: Readable): Promise<JsonRpcMessage> {
|
|
let buf = '';
|
|
for await (const chunk of readable) {
|
|
buf += (chunk as Buffer).toString('utf8');
|
|
const idx = buf.indexOf('\n');
|
|
if (idx >= 0) {
|
|
return JSON.parse(buf.slice(0, idx)) as JsonRpcMessage;
|
|
}
|
|
}
|
|
throw new Error('stream closed before a full JSON-RPC message was received');
|
|
}
|
|
|
|
describe('negotiateVersion', () => {
|
|
it('returns CURRENT_VERSION when the client version is below MIN_PROTOCOL_VERSION', () => {
|
|
const result = negotiateVersion(0);
|
|
expect(result).toBe(CURRENT_VERSION);
|
|
expect(result.protocolVersion).toBe(1);
|
|
});
|
|
|
|
it('returns the matching spec when the client requests the current version', () => {
|
|
const result = negotiateVersion(1);
|
|
expect(result).toBe(CURRENT_VERSION);
|
|
expect(result.protocolVersion).toBe(1);
|
|
expect(result.specTag).toBe('v0.10.x');
|
|
expect(result.sdkVersion).toBe('0.23.0');
|
|
});
|
|
|
|
it('returns the highest supported version when the client advertises a newer one', () => {
|
|
const result = negotiateVersion(99);
|
|
expect(result).toBe(CURRENT_VERSION);
|
|
expect(result.protocolVersion).toBe(1);
|
|
});
|
|
|
|
it('exposes MIN_PROTOCOL_VERSION = 1', () => {
|
|
expect(MIN_PROTOCOL_VERSION).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('acp-server initialize handshake', () => {
|
|
it(
|
|
'boots agent-core-v2 and answers the ACP initialize request',
|
|
async () => {
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'acp-server-init-'));
|
|
// One PassThrough per direction: writes on one side appear on the other.
|
|
const toAgent = new PassThrough();
|
|
const toClient = new PassThrough();
|
|
try {
|
|
const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent));
|
|
const server = await runAcpServerWithStream(stream, { homeDir });
|
|
|
|
const request = {
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: { protocolVersion: 1, clientCapabilities: {} },
|
|
};
|
|
toAgent.write(`${JSON.stringify(request)}\n`);
|
|
|
|
const response = await readOneMessage(toClient);
|
|
expect(response.id).toBe(1);
|
|
expect(response.error).toBeUndefined();
|
|
expect(response.result).toMatchObject({
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
auth: { logout: {} },
|
|
mcpCapabilities: { http: true, sse: true },
|
|
sessionCapabilities: { additionalDirectories: {}, delete: {}, fork: {} },
|
|
},
|
|
});
|
|
|
|
await server.close();
|
|
toAgent.end();
|
|
toClient.end();
|
|
} finally {
|
|
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
it(
|
|
'negotiates down to the highest supported version when the client advertises a newer one',
|
|
async () => {
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'acp-server-neg-'));
|
|
const toAgent = new PassThrough();
|
|
const toClient = new PassThrough();
|
|
try {
|
|
const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent));
|
|
const server = await runAcpServerWithStream(stream, { homeDir });
|
|
|
|
toAgent.write(
|
|
`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: { protocolVersion: 99, clientCapabilities: {} },
|
|
})}\n`,
|
|
);
|
|
|
|
const response = await readOneMessage(toClient);
|
|
expect(response.error).toBeUndefined();
|
|
expect((response.result as { protocolVersion?: number })?.protocolVersion).toBe(1);
|
|
|
|
await server.close();
|
|
toAgent.end();
|
|
toClient.end();
|
|
} finally {
|
|
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
it(
|
|
'advertises terminal-auth with forwarded env and the legacy _meta fallback',
|
|
async () => {
|
|
const homeDir = await mkdtemp(join(tmpdir(), 'acp-server-auth-'));
|
|
const toAgent = new PassThrough();
|
|
const toClient = new PassThrough();
|
|
try {
|
|
const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent));
|
|
const server = await runAcpServerWithStream(stream, {
|
|
homeDir,
|
|
terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/sandbox' },
|
|
terminalAuthLegacyCommand: '/opt/kimi/bin/kimi',
|
|
});
|
|
|
|
toAgent.write(
|
|
`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: { protocolVersion: 1, clientCapabilities: {} },
|
|
})}\n`,
|
|
);
|
|
|
|
const response = await readOneMessage(toClient);
|
|
const authMethods = (response.result as { authMethods?: unknown[] })?.authMethods;
|
|
expect(Array.isArray(authMethods)).toBe(true);
|
|
const method = authMethods?.[0] as {
|
|
type: string;
|
|
args: string[];
|
|
env: Record<string, string>;
|
|
_meta?: { 'terminal-auth'?: { command: string; args: string[]; env: Record<string, string> } };
|
|
};
|
|
expect(method.type).toBe('terminal');
|
|
expect(method.args).toEqual(['--login']);
|
|
expect(method.env).toEqual({ KIMI_CODE_HOME: '/tmp/sandbox' });
|
|
expect(method._meta?.['terminal-auth']).toMatchObject({
|
|
command: '/opt/kimi/bin/kimi',
|
|
args: ['login'],
|
|
env: { KIMI_CODE_HOME: '/tmp/sandbox' },
|
|
});
|
|
|
|
await server.close();
|
|
toAgent.end();
|
|
toClient.end();
|
|
} finally {
|
|
await rm(homeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
}
|
|
},
|
|
30_000,
|
|
);
|
|
});
|