kimi-code/packages/node-sdk/test/session-cancel.test.ts
Haozhe 4603d8ad6e
feat(protocol): extract shared protocol package from agent-core (#612)
* feat(protocol): extract shared protocol package from agent-core

- add `@moonshot-ai/protocol` package with REST/WS schemas, envelopes, error codes, event types, and display schemas\n- migrate agent-core `events.ts` and `display/schemas.ts` to re-export from protocol
- add centralized `onUnexpectedError` handler for safe emitter listener callbacks
- reject forkSession when source session has an active running turn
- add protocol schema tests and unexpectedError handler tests
2026-06-10 14:03:38 +08:00

193 lines
6.1 KiB
TypeScript

import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import type * as KosongModule from '@moonshot-ai/kosong';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createKimiHarness, type KimiError, type Event } from '#/index';
import { makeTempDir, removeTempDirs, waitForSDKEvent } from './session-runtime-helpers';
import { TEST_IDENTITY } from './test-identity';
vi.mock('@moonshot-ai/kosong', async (importOriginal) => {
const actual = await importOriginal<typeof KosongModule>();
return {
...actual,
createProvider: () => ({
name: 'fake',
modelName: 'fake-model',
thinkingEffort: null,
async generate(
_systemPrompt: string,
_tools: unknown,
_history: unknown,
options?: { readonly signal?: AbortSignal },
) {
await waitForAbort(options?.signal);
throwAbortError();
},
withThinking() {
return this;
},
}),
};
});
const tempDirs: string[] = [];
afterEach(async () => {
await removeTempDirs(tempDirs);
});
describe('Session.cancel', () => {
it('cancels an active streaming turn and emits turn_ended(cancelled)', async () => {
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-home-');
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-work-');
await writeFakeModelConfig(homeDir);
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
try {
const session = await harness.createSession({ id: 'ses_cancel_active_turn', workDir });
const events: Event[] = [];
const unsubscribe = session.onEvent((event) => {
events.push(event);
});
const started = waitForSDKEvent(session, (event) => event.type === 'turn.started');
const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended');
await session.prompt('start a turn that will be cancelled');
const startedEvent = await started;
await session.cancel();
const endedEvent = await ended;
unsubscribe();
expect(startedEvent).toMatchObject({
type: 'turn.started',
sessionId: session.id,
});
expect(endedEvent).toMatchObject({
type: 'turn.ended',
sessionId: session.id,
turnId: startedEvent.type === 'turn.started' ? startedEvent.turnId : undefined,
reason: 'cancelled',
});
expect(events).toContainEqual(expect.objectContaining({ type: 'turn.started' }));
expect(events).toContainEqual(expect.objectContaining({ type: 'turn.ended' }));
} finally {
await harness.close();
}
});
it('rejects manual compaction on an empty session with compaction.unable', async () => {
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-compact-home-');
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-compact-work-');
await writeFakeModelConfig(homeDir);
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
try {
const session = await harness.createSession({ id: 'ses_cancel_compaction', workDir });
await expect(session.compact({ instruction: 'Keep the compact test pending.' })).rejects.toMatchObject({
name: 'KimiError',
code: 'compaction.unable',
} satisfies Partial<KimiError>);
} finally {
await harness.close();
}
});
it('rejects after the session is closed', async () => {
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-home-');
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-cancel-work-');
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
try {
const session = await harness.createSession({ id: 'ses_cancel_closed', workDir });
await session.close();
await expect(session.cancel()).rejects.toMatchObject({
name: 'KimiError',
code: 'session.closed',
} satisfies Partial<KimiError>);
await expect(session.cancelCompaction()).rejects.toMatchObject({
name: 'KimiError',
code: 'session.closed',
} satisfies Partial<KimiError>);
} finally {
await harness.close();
}
});
});
describe('KimiHarness.forkSession', () => {
it('rejects while the source session has an active turn', async () => {
const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-home-');
const workDir = await makeTempDir(tempDirs, 'kimi-sdk-fork-active-work-');
await writeFakeModelConfig(homeDir);
const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY });
try {
const session = await harness.createSession({ id: 'ses_fork_active_turn', workDir });
const started = waitForSDKEvent(session, (event) => event.type === 'turn.started');
const ended = waitForSDKEvent(session, (event) => event.type === 'turn.ended');
await session.prompt('keep this turn active');
await started;
try {
await expect(
harness.forkSession({
id: session.id,
forkId: 'ses_fork_active_child',
}),
).rejects.toMatchObject({
name: 'KimiError',
code: 'session.fork_active_turn',
} satisfies Partial<KimiError>);
} finally {
await session.cancel().catch(() => undefined);
await ended.catch(() => undefined);
}
} finally {
await harness.close();
}
});
});
async function writeFakeModelConfig(homeDir: string): Promise<void> {
await writeFile(
join(homeDir, 'config.toml'),
`
default_model = "fake-model"
[providers.local]
type = "kimi"
base_url = "https://example.test/v1"
api_key = "sk-test"
[models.fake-model]
provider = "local"
model = "fake-model"
max_context_size = 1000
`,
'utf-8',
);
}
function waitForAbort(signal: AbortSignal | undefined): Promise<void> {
if (signal?.aborted === true) {
return Promise.resolve();
}
return new Promise((resolve) => {
signal?.addEventListener(
'abort',
() => {
resolve();
},
{ once: true },
);
});
}
function throwAbortError(): never {
throw new DOMException('The operation was aborted.', 'AbortError');
}