mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat(agent-core-v2): add session-scoped terminal domain
- add ITerminalService (create/list/get/attach/write/resize/close) and ITerminalBackend contract; Session-scoped so per-session sessionId threading is dropped - implement TerminalService with per-terminal output buffering, seq replay, sink fan-out, and dispose-time process cleanup - register a default NotImplementedTerminalBackend; the real PTY backend is supplied by the composition root - add the terminal.not_found error code (protocol + ErrorCodes) and register the terminal layer in check-domain-layers - cover the service with unit tests driven by a fake backend
This commit is contained in:
parent
0875c23014
commit
262d201b00
11 changed files with 652 additions and 0 deletions
|
|
@ -43,6 +43,7 @@ package "Session scope (per session)" #EAFAF1 {
|
|||
rectangle "<b>session-activity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
|
||||
rectangle "<b>subagentHost</b>\n<size:9><i>Session</i></size>\n ISubagentHost" as subagentHost #D5F5E3
|
||||
rectangle "<b>process</b>\n<size:9><i>Session</i></size>\n IProcessRunner\n IProcessBackend" as process #D5F5E3
|
||||
rectangle "<b>terminal</b>\n<size:9><i>Session</i></size>\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3
|
||||
rectangle "<b>agent-lifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
|
||||
rectangle "<b>session-context</b>\n<size:9><i>Session</i></size>\n ISessionContext (seed)" as session_context #D5F5E3
|
||||
}
|
||||
|
|
@ -107,6 +108,8 @@ sessionIndex --> bootstrap #34495E
|
|||
agentFs --> workspaceContext #34495E
|
||||
agentFs --> hostFs #34495E
|
||||
process --> workspaceContext #34495E
|
||||
terminal --> workspaceContext #34495E
|
||||
terminal --> session_context #34495E
|
||||
hostFolderBrowser --> hostFs #34495E
|
||||
auth --> provider #34495E
|
||||
auth --> environment #34495E
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['session-metadata', 6],
|
||||
['session-activity', 6],
|
||||
['session', 6],
|
||||
['terminal', 6],
|
||||
// L7 — boundary
|
||||
['event', 7],
|
||||
['approval', 7],
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { ProfileErrors } from '#/profile/errors';
|
|||
import { PromptErrors } from '#/prompt/errors';
|
||||
import { SessionErrors } from '#/session/errors';
|
||||
import { SkillErrors } from '#/skill/errors';
|
||||
import { TerminalErrors } from '#/terminal/errors';
|
||||
import { TurnErrors } from '#/turn/errors';
|
||||
import { WireRecordErrors } from '#/wireRecord/errors';
|
||||
|
||||
|
|
@ -40,6 +41,7 @@ export { ProfileErrors } from '#/profile/errors';
|
|||
export { PromptErrors } from '#/prompt/errors';
|
||||
export { SessionErrors } from '#/session/errors';
|
||||
export { SkillErrors } from '#/skill/errors';
|
||||
export { TerminalErrors } from '#/terminal/errors';
|
||||
export { TurnErrors } from '#/turn/errors';
|
||||
export { WireRecordErrors } from '#/wireRecord/errors';
|
||||
|
||||
|
|
@ -59,6 +61,7 @@ export const ErrorCodes = {
|
|||
...PromptErrors.codes,
|
||||
...SessionErrors.codes,
|
||||
...SkillErrors.codes,
|
||||
...TerminalErrors.codes,
|
||||
...TurnErrors.codes,
|
||||
...WireRecordErrors.codes,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export * from './workspaceRegistry/index';
|
|||
export * from './hostFolderBrowser/index';
|
||||
export * from './agentFs/index';
|
||||
export * from './process/index';
|
||||
export * from './terminal/index';
|
||||
export * from './storage/index';
|
||||
export * from './auth/index';
|
||||
|
||||
|
|
|
|||
13
packages/agent-core-v2/src/terminal/errors.ts
Normal file
13
packages/agent-core-v2/src/terminal/errors.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* `terminal` domain error codes.
|
||||
*/
|
||||
|
||||
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors';
|
||||
|
||||
export const TerminalErrors = {
|
||||
codes: {
|
||||
TERMINAL_NOT_FOUND: 'terminal.not_found',
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
registerErrorDomain(TerminalErrors);
|
||||
12
packages/agent-core-v2/src/terminal/index.ts
Normal file
12
packages/agent-core-v2/src/terminal/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* `terminal` domain barrel — re-exports the terminal contract (`terminal`),
|
||||
* its scoped service (`terminalService`), the default backend stub
|
||||
* (`terminalBackend`), and the domain error codes (`errors`). Importing this
|
||||
* barrel registers the `ITerminalService` and default `ITerminalBackend`
|
||||
* bindings into the scope registry.
|
||||
*/
|
||||
|
||||
export * from './terminal';
|
||||
export * from './errors';
|
||||
export * from './terminalService';
|
||||
export * from './terminalBackend';
|
||||
88
packages/agent-core-v2/src/terminal/terminal.ts
Normal file
88
packages/agent-core-v2/src/terminal/terminal.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* `terminal` domain (L6) — interactive terminal (PTY) contract.
|
||||
*
|
||||
* Defines the `ITerminalService` that business code (and the edge, via an
|
||||
* accessor borrow) uses to manage a session's interactive terminals, the
|
||||
* `ITerminalBackend` provider that hides the local/ssh/container split, and
|
||||
* the attach/stream types (`TerminalProcess`, `TerminalAttachSink`,
|
||||
* `TerminalFrame`) used to wire terminal I/O to a transport. Session-scoped:
|
||||
* one `ITerminalService` owns only its own session's terminals. Wire types
|
||||
* (`Terminal`, `CreateTerminalRequest`, frame messages) are sourced from
|
||||
* `@moonshot-ai/protocol`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateTerminalRequest,
|
||||
Terminal,
|
||||
TerminalExitMessage,
|
||||
TerminalOutputMessage,
|
||||
} from '@moonshot-ai/protocol';
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Event } from '#/_base/event';
|
||||
|
||||
export type { CreateTerminalRequest, Terminal, TerminalExitMessage, TerminalOutputMessage };
|
||||
|
||||
export type TerminalFrame = TerminalOutputMessage | TerminalExitMessage;
|
||||
|
||||
export interface TerminalAttachSink {
|
||||
readonly id: string;
|
||||
send(frame: TerminalFrame): void;
|
||||
}
|
||||
|
||||
export interface TerminalAttachOptions {
|
||||
readonly sinceSeq?: number;
|
||||
}
|
||||
|
||||
export interface TerminalSpawnOptions {
|
||||
readonly cwd: string;
|
||||
readonly shell: string;
|
||||
readonly cols: number;
|
||||
readonly rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalProcess {
|
||||
readonly onData: Event<string>;
|
||||
readonly onExit: Event<{ exitCode: number | null }>;
|
||||
write(data: string): void;
|
||||
resize(cols: number, rows: number): void;
|
||||
kill(): void;
|
||||
}
|
||||
|
||||
export interface ITerminalService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
create(input: CreateTerminalRequest): Promise<Terminal>;
|
||||
|
||||
list(): Promise<readonly Terminal[]>;
|
||||
|
||||
get(terminalId: string): Promise<Terminal>;
|
||||
|
||||
attach(
|
||||
terminalId: string,
|
||||
sink: TerminalAttachSink,
|
||||
options?: TerminalAttachOptions,
|
||||
): Promise<{ replayed: number }>;
|
||||
|
||||
detach(terminalId: string, sinkId: string): void;
|
||||
|
||||
detachAllForSink(sinkId: string): void;
|
||||
|
||||
write(terminalId: string, data: string): Promise<void>;
|
||||
|
||||
resize(terminalId: string, cols: number, rows: number): Promise<void>;
|
||||
|
||||
close(terminalId: string): Promise<{ closed: true }>;
|
||||
}
|
||||
|
||||
export const ITerminalService: ServiceIdentifier<ITerminalService> =
|
||||
createDecorator<ITerminalService>('terminalService');
|
||||
|
||||
export interface ITerminalBackend {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(options: TerminalSpawnOptions): Promise<TerminalProcess>;
|
||||
}
|
||||
|
||||
export const ITerminalBackend: ServiceIdentifier<ITerminalBackend> =
|
||||
createDecorator<ITerminalBackend>('terminalBackend');
|
||||
31
packages/agent-core-v2/src/terminal/terminalBackend.ts
Normal file
31
packages/agent-core-v2/src/terminal/terminalBackend.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* `terminal` domain (L6) — default `ITerminalBackend` stub.
|
||||
*
|
||||
* Placeholder backend registered so the binding graph is complete and
|
||||
* `ITerminalService` resolves out of the box. It cannot spawn a real PTY; a
|
||||
* composition root that needs interactive terminals (for example the server
|
||||
* or the desktop app, both of which already depend on `node-pty`) supplies a
|
||||
* real backend through the scope registry to override this one.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { NotImplementedError } from '#/_base/errors';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { type TerminalProcess, type TerminalSpawnOptions, ITerminalBackend } from './terminal';
|
||||
|
||||
export class NotImplementedTerminalBackend implements ITerminalBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
spawn(_options: TerminalSpawnOptions): Promise<TerminalProcess> {
|
||||
throw new NotImplementedError('terminalBackend');
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ITerminalBackend,
|
||||
NotImplementedTerminalBackend,
|
||||
InstantiationType.Delayed,
|
||||
'terminal',
|
||||
);
|
||||
246
packages/agent-core-v2/src/terminal/terminalService.ts
Normal file
246
packages/agent-core-v2/src/terminal/terminalService.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* `terminal` domain (L6) — `ITerminalService` implementation.
|
||||
*
|
||||
* Owns this session's terminal set and its per-terminal output buffers and
|
||||
* attached sinks; spawns PTYs through the injected `ITerminalBackend`,
|
||||
* resolves the working directory through `workspaceContext`, and reads the
|
||||
* session id through `session-context` to tag frames. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ErrorCodes, KimiError } from '#/errors';
|
||||
import { ISessionContext } from '#/session-context';
|
||||
import { IWorkspaceContext } from '#/workspaceContext';
|
||||
|
||||
import {
|
||||
type CreateTerminalRequest,
|
||||
type Terminal,
|
||||
type TerminalAttachOptions,
|
||||
type TerminalAttachSink,
|
||||
type TerminalExitMessage,
|
||||
type TerminalFrame,
|
||||
type TerminalOutputMessage,
|
||||
type TerminalProcess,
|
||||
ITerminalBackend,
|
||||
ITerminalService,
|
||||
} from './terminal';
|
||||
|
||||
const DEFAULT_COLS = 80;
|
||||
const DEFAULT_ROWS = 24;
|
||||
const DEFAULT_MAX_BUFFERED_FRAMES = 2000;
|
||||
|
||||
interface TerminalRecord {
|
||||
terminal: Terminal;
|
||||
process: TerminalProcess;
|
||||
sinks: Map<string, TerminalAttachSink>;
|
||||
buffer: TerminalFrame[];
|
||||
nextSeq: number;
|
||||
disposables: IDisposable[];
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export class TerminalService extends Disposable implements ITerminalService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly records = new Map<string, TerminalRecord>();
|
||||
|
||||
constructor(
|
||||
@ITerminalBackend private readonly backend: ITerminalBackend,
|
||||
@IWorkspaceContext private readonly workspace: IWorkspaceContext,
|
||||
@ISessionContext private readonly sessionContext: ISessionContext,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async create(input: CreateTerminalRequest): Promise<Terminal> {
|
||||
const cwd =
|
||||
input.cwd === undefined
|
||||
? this.workspace.workDir
|
||||
: this.workspace.assertAllowed(input.cwd, 'execute');
|
||||
const shell = input.shell ?? defaultShell();
|
||||
const cols = input.cols ?? DEFAULT_COLS;
|
||||
const rows = input.rows ?? DEFAULT_ROWS;
|
||||
const process = await this.backend.spawn({ cwd, shell, cols, rows });
|
||||
const terminal: Terminal = {
|
||||
id: `term_${randomUUID()}`,
|
||||
session_id: this.sessionContext.sessionId,
|
||||
cwd,
|
||||
shell,
|
||||
cols,
|
||||
rows,
|
||||
status: 'running',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
const record: TerminalRecord = {
|
||||
terminal,
|
||||
process,
|
||||
sinks: new Map(),
|
||||
buffer: [],
|
||||
nextSeq: 0,
|
||||
disposables: [],
|
||||
closed: false,
|
||||
};
|
||||
record.disposables.push(
|
||||
process.onData((data) => this.onData(record, data)),
|
||||
process.onExit((event) => this.onExit(record, event.exitCode)),
|
||||
);
|
||||
this.records.set(terminal.id, record);
|
||||
return { ...terminal };
|
||||
}
|
||||
|
||||
list(): Promise<readonly Terminal[]> {
|
||||
return Promise.resolve(
|
||||
[...this.records.values()].map((record) => ({ ...record.terminal })),
|
||||
);
|
||||
}
|
||||
|
||||
async get(terminalId: string): Promise<Terminal> {
|
||||
return { ...this.requireRecord(terminalId).terminal };
|
||||
}
|
||||
|
||||
async attach(
|
||||
terminalId: string,
|
||||
sink: TerminalAttachSink,
|
||||
options: TerminalAttachOptions = {},
|
||||
): Promise<{ replayed: number }> {
|
||||
const record = this.requireRecord(terminalId);
|
||||
record.sinks.set(sink.id, sink);
|
||||
const sinceSeq = options.sinceSeq ?? 0;
|
||||
const replay = record.buffer.filter((frame) => frameSeq(frame) > sinceSeq);
|
||||
for (const frame of replay) {
|
||||
sink.send(frame);
|
||||
}
|
||||
return { replayed: replay.length };
|
||||
}
|
||||
|
||||
detach(terminalId: string, sinkId: string): void {
|
||||
this.records.get(terminalId)?.sinks.delete(sinkId);
|
||||
}
|
||||
|
||||
detachAllForSink(sinkId: string): void {
|
||||
for (const record of this.records.values()) {
|
||||
record.sinks.delete(sinkId);
|
||||
}
|
||||
}
|
||||
|
||||
async write(terminalId: string, data: string): Promise<void> {
|
||||
const record = this.requireRecord(terminalId);
|
||||
record.process.write(data);
|
||||
}
|
||||
|
||||
async resize(terminalId: string, cols: number, rows: number): Promise<void> {
|
||||
const record = this.requireRecord(terminalId);
|
||||
record.terminal = { ...record.terminal, cols, rows };
|
||||
record.process.resize(cols, rows);
|
||||
}
|
||||
|
||||
async close(terminalId: string): Promise<{ closed: true }> {
|
||||
const record = this.requireRecord(terminalId);
|
||||
if (!record.closed) {
|
||||
record.closed = true;
|
||||
record.process.kill();
|
||||
this.markExited(record, null);
|
||||
}
|
||||
return { closed: true };
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
for (const record of this.records.values()) {
|
||||
disposeAll(record.disposables);
|
||||
try {
|
||||
record.process.kill();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
this.records.clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private requireRecord(terminalId: string): TerminalRecord {
|
||||
const record = this.records.get(terminalId);
|
||||
if (record === undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.TERMINAL_NOT_FOUND,
|
||||
`terminal ${terminalId} does not exist in session ${this.sessionContext.sessionId}`,
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private onData(record: TerminalRecord, data: string): void {
|
||||
const frame: TerminalOutputMessage = {
|
||||
type: 'terminal_output',
|
||||
seq: ++record.nextSeq,
|
||||
session_id: record.terminal.session_id,
|
||||
terminal_id: record.terminal.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: { data },
|
||||
};
|
||||
this.pushFrame(record, frame);
|
||||
}
|
||||
|
||||
private onExit(record: TerminalRecord, exitCode: number | null): void {
|
||||
this.markExited(record, exitCode);
|
||||
}
|
||||
|
||||
private markExited(record: TerminalRecord, exitCode: number | null): void {
|
||||
if (record.terminal.status === 'exited') return;
|
||||
record.closed = true;
|
||||
record.terminal = {
|
||||
...record.terminal,
|
||||
status: 'exited',
|
||||
exited_at: new Date().toISOString(),
|
||||
exit_code: exitCode,
|
||||
};
|
||||
const frame: TerminalExitMessage = {
|
||||
type: 'terminal_exit',
|
||||
session_id: record.terminal.session_id,
|
||||
terminal_id: record.terminal.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: { exit_code: exitCode },
|
||||
};
|
||||
this.pushFrame(record, frame);
|
||||
disposeAll(record.disposables);
|
||||
record.disposables = [];
|
||||
}
|
||||
|
||||
private pushFrame(record: TerminalRecord, frame: TerminalFrame): void {
|
||||
record.buffer.push(frame);
|
||||
if (record.buffer.length > DEFAULT_MAX_BUFFERED_FRAMES) {
|
||||
record.buffer.splice(0, record.buffer.length - DEFAULT_MAX_BUFFERED_FRAMES);
|
||||
}
|
||||
for (const sink of record.sinks.values()) {
|
||||
sink.send(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disposeAll(items: Iterable<IDisposable>): void {
|
||||
for (const item of items) {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function frameSeq(frame: TerminalFrame): number {
|
||||
return frame.type === 'terminal_output' ? frame.seq : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function defaultShell(): string {
|
||||
// Use `||` (not `??`): an EMPTY $SHELL (set but blank, as some daemon/launchd
|
||||
// envs leave it) must still fall back, or a PTY spawn fails with
|
||||
// "posix_spawnp failed".
|
||||
return process.env['SHELL'] || '/bin/sh';
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ITerminalService,
|
||||
TerminalService,
|
||||
InstantiationType.Delayed,
|
||||
'terminal',
|
||||
);
|
||||
252
packages/agent-core-v2/test/terminal/terminalService.test.ts
Normal file
252
packages/agent-core-v2/test/terminal/terminalService.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { resolve } from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { ErrorCodes } from '#/errors';
|
||||
import { ISessionContext } from '#/session-context';
|
||||
import {
|
||||
type TerminalAttachSink,
|
||||
type TerminalFrame,
|
||||
type TerminalProcess,
|
||||
type TerminalSpawnOptions,
|
||||
ITerminalBackend,
|
||||
ITerminalService,
|
||||
} from '#/terminal';
|
||||
import { TerminalService } from '#/terminal/terminalService';
|
||||
import { IWorkspaceContext } from '#/workspaceContext';
|
||||
|
||||
class FakeTerminalProcess implements TerminalProcess {
|
||||
private readonly dataEmitter = new Emitter<string>();
|
||||
private readonly exitEmitter = new Emitter<{ exitCode: number | null }>();
|
||||
readonly onData = this.dataEmitter.event;
|
||||
readonly onExit = this.exitEmitter.event;
|
||||
readonly writes: string[] = [];
|
||||
readonly resizes: Array<[number, number]> = [];
|
||||
killed = false;
|
||||
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
resize(cols: number, rows: number): void {
|
||||
this.resizes.push([cols, rows]);
|
||||
}
|
||||
kill(): void {
|
||||
this.killed = true;
|
||||
}
|
||||
emitData(data: string): void {
|
||||
this.dataEmitter.fire(data);
|
||||
}
|
||||
emitExit(exitCode: number | null): void {
|
||||
this.exitEmitter.fire({ exitCode });
|
||||
}
|
||||
dispose(): void {
|
||||
this.dataEmitter.dispose();
|
||||
this.exitEmitter.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTerminalBackend implements ITerminalBackend {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly processes: FakeTerminalProcess[] = [];
|
||||
readonly lastOptions: TerminalSpawnOptions[] = [];
|
||||
|
||||
spawn(options: TerminalSpawnOptions): Promise<TerminalProcess> {
|
||||
this.lastOptions.push(options);
|
||||
const proc = new FakeTerminalProcess();
|
||||
this.processes.push(proc);
|
||||
return Promise.resolve(proc);
|
||||
}
|
||||
}
|
||||
|
||||
function stubWorkspace(workDir = '/ws'): IWorkspaceContext {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
workDir,
|
||||
additionalDirs: [],
|
||||
setWorkDir: () => {},
|
||||
resolve: (rel) => resolve(workDir, rel),
|
||||
isWithin: () => true,
|
||||
assertAllowed: (absPath) => resolve(workDir, absPath),
|
||||
addAdditionalDir: () => {},
|
||||
removeAdditionalDir: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function stubSessionContext(sessionId = 's1'): ISessionContext {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
sessionId,
|
||||
workspaceId: 'w1',
|
||||
sessionDir: '/ws/.session',
|
||||
metaScope: `session:${sessionId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function collectSink(id = 'sink-1'): { sink: TerminalAttachSink; frames: TerminalFrame[] } {
|
||||
const frames: TerminalFrame[] = [];
|
||||
return { sink: { id, send: (frame) => frames.push(frame) }, frames };
|
||||
}
|
||||
|
||||
describe('TerminalService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let backend: FakeTerminalBackend;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
backend = new FakeTerminalBackend();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.define(ITerminalService, TerminalService);
|
||||
reg.defineInstance(ITerminalBackend, backend);
|
||||
reg.defineInstance(IWorkspaceContext, stubWorkspace());
|
||||
reg.defineInstance(ISessionContext, stubSessionContext());
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
it('creates a terminal and resolves cwd through the workspace', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({ cwd: 'sub', cols: 100, rows: 40 });
|
||||
|
||||
expect(terminal.status).toBe('running');
|
||||
expect(terminal.session_id).toBe('s1');
|
||||
expect(terminal.cwd).toBe(resolve('/ws', 'sub'));
|
||||
expect(terminal.cols).toBe(100);
|
||||
expect(terminal.rows).toBe(40);
|
||||
expect(backend.processes).toHaveLength(1);
|
||||
expect(backend.lastOptions[0]?.cwd).toBe(resolve('/ws', 'sub'));
|
||||
});
|
||||
|
||||
it('uses the workspace workDir when cwd is omitted', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
expect(terminal.cwd).toBe('/ws');
|
||||
expect(terminal.cols).toBe(80);
|
||||
expect(terminal.rows).toBe(24);
|
||||
});
|
||||
|
||||
it('lists and gets terminals', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const created = await svc.create({});
|
||||
const listed = await svc.list();
|
||||
expect(listed).toHaveLength(1);
|
||||
expect(listed[0]?.id).toBe(created.id);
|
||||
|
||||
const fetched = await svc.get(created.id);
|
||||
expect(fetched.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('throws TERMINAL_NOT_FOUND for an unknown terminal', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
await expect(svc.get('nope')).rejects.toMatchObject({
|
||||
code: ErrorCodes.TERMINAL_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches a sink, replays buffered frames, then streams live output', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
|
||||
proc.emitData('hello');
|
||||
const { sink, frames } = collectSink();
|
||||
const { replayed } = await svc.attach(terminal.id, sink);
|
||||
expect(replayed).toBe(1);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]).toMatchObject({
|
||||
type: 'terminal_output',
|
||||
seq: 1,
|
||||
session_id: 's1',
|
||||
terminal_id: terminal.id,
|
||||
payload: { data: 'hello' },
|
||||
});
|
||||
|
||||
proc.emitData('world');
|
||||
expect(frames).toHaveLength(2);
|
||||
expect(frames[1]).toMatchObject({ type: 'terminal_output', seq: 2, payload: { data: 'world' } });
|
||||
});
|
||||
|
||||
it('replays only frames after sinceSeq', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
proc.emitData('a');
|
||||
proc.emitData('b');
|
||||
proc.emitData('c');
|
||||
|
||||
const { sink, frames } = collectSink();
|
||||
const { replayed } = await svc.attach(terminal.id, sink, { sinceSeq: 1 });
|
||||
expect(replayed).toBe(2);
|
||||
expect(frames.map((f) => (f as { seq?: number }).seq)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
it('emits an exit frame and marks the terminal exited on process exit', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
const { sink, frames } = collectSink();
|
||||
await svc.attach(terminal.id, sink);
|
||||
|
||||
proc.emitExit(7);
|
||||
|
||||
const exitFrame = frames.find((f) => f.type === 'terminal_exit');
|
||||
expect(exitFrame).toMatchObject({
|
||||
type: 'terminal_exit',
|
||||
terminal_id: terminal.id,
|
||||
payload: { exit_code: 7 },
|
||||
});
|
||||
const fetched = await svc.get(terminal.id);
|
||||
expect(fetched.status).toBe('exited');
|
||||
expect(fetched.exit_code).toBe(7);
|
||||
});
|
||||
|
||||
it('delegates write and resize to the process', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
|
||||
await svc.write(terminal.id, 'ls\n');
|
||||
await svc.resize(terminal.id, 120, 50);
|
||||
|
||||
expect(proc.writes).toEqual(['ls\n']);
|
||||
expect(proc.resizes).toEqual([[120, 50]]);
|
||||
expect((await svc.get(terminal.id)).cols).toBe(120);
|
||||
});
|
||||
|
||||
it('closes a terminal by killing the process and marking it exited', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
|
||||
const result = await svc.close(terminal.id);
|
||||
expect(result).toEqual({ closed: true });
|
||||
expect(proc.killed).toBe(true);
|
||||
expect((await svc.get(terminal.id)).status).toBe('exited');
|
||||
});
|
||||
|
||||
it('detaches a sink so it stops receiving frames', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
const terminal = await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
const { sink, frames } = collectSink();
|
||||
await svc.attach(terminal.id, sink);
|
||||
|
||||
svc.detach(terminal.id, sink.id);
|
||||
proc.emitData('after-detach');
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('kills every live process when the service is disposed', async () => {
|
||||
const svc = ix.get(ITerminalService);
|
||||
await svc.create({});
|
||||
const proc = backend.processes[0]!;
|
||||
|
||||
disposables.dispose();
|
||||
expect(proc.killed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -224,6 +224,7 @@ export type KimiErrorCode =
|
|||
| 'request.prompt_input_empty'
|
||||
| 'shell.git_bash_not_found'
|
||||
| 'workspace.not_found'
|
||||
| 'terminal.not_found'
|
||||
| 'fs.path_not_found'
|
||||
| 'fs.permission_denied'
|
||||
| 'validation.failed'
|
||||
|
|
@ -855,6 +856,7 @@ export const kimiErrorCodeSchema = z.enum([
|
|||
'request.prompt_input_empty',
|
||||
'shell.git_bash_not_found',
|
||||
'workspace.not_found',
|
||||
'terminal.not_found',
|
||||
'fs.path_not_found',
|
||||
'fs.permission_denied',
|
||||
'validation.failed',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue