From 262d201b00f5ce93e6ffb89cf97315d03f9e8a40 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 05:15:01 +0800 Subject: [PATCH] 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 --- .../agent-core-v2/docs/di-scope-domains.puml | 3 + .../scripts/check-domain-layers.mjs | 1 + packages/agent-core-v2/src/errors.ts | 3 + packages/agent-core-v2/src/index.ts | 1 + packages/agent-core-v2/src/terminal/errors.ts | 13 + packages/agent-core-v2/src/terminal/index.ts | 12 + .../agent-core-v2/src/terminal/terminal.ts | 88 ++++++ .../src/terminal/terminalBackend.ts | 31 +++ .../src/terminal/terminalService.ts | 246 +++++++++++++++++ .../test/terminal/terminalService.test.ts | 252 ++++++++++++++++++ packages/protocol/src/events.ts | 2 + 11 files changed, 652 insertions(+) create mode 100644 packages/agent-core-v2/src/terminal/errors.ts create mode 100644 packages/agent-core-v2/src/terminal/index.ts create mode 100644 packages/agent-core-v2/src/terminal/terminal.ts create mode 100644 packages/agent-core-v2/src/terminal/terminalBackend.ts create mode 100644 packages/agent-core-v2/src/terminal/terminalService.ts create mode 100644 packages/agent-core-v2/test/terminal/terminalService.test.ts diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 55d7bdae0..8e2cfefc5 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -43,6 +43,7 @@ package "Session scope (per session)" #EAFAF1 { rectangle "session-activity\nSession\n ISessionActivity" as session_activity #D5F5E3 rectangle "subagentHost\nSession\n ISubagentHost" as subagentHost #D5F5E3 rectangle "process\nSession\n IProcessRunner\n IProcessBackend" as process #D5F5E3 + rectangle "terminal\nSession\n ITerminalService\n ITerminalBackend" as terminal #D5F5E3 rectangle "agent-lifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3 rectangle "session-context\nSession\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 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index f6b66d4cc..3c5dde132 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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], diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index deb9fc327..4af35fba1 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -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; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 20b49065e..d2465287b 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/terminal/errors.ts b/packages/agent-core-v2/src/terminal/errors.ts new file mode 100644 index 000000000..578c21902 --- /dev/null +++ b/packages/agent-core-v2/src/terminal/errors.ts @@ -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); diff --git a/packages/agent-core-v2/src/terminal/index.ts b/packages/agent-core-v2/src/terminal/index.ts new file mode 100644 index 000000000..03d721b4b --- /dev/null +++ b/packages/agent-core-v2/src/terminal/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/terminal/terminal.ts b/packages/agent-core-v2/src/terminal/terminal.ts new file mode 100644 index 000000000..992328e23 --- /dev/null +++ b/packages/agent-core-v2/src/terminal/terminal.ts @@ -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; + 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; + + list(): Promise; + + get(terminalId: string): Promise; + + 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; + + resize(terminalId: string, cols: number, rows: number): Promise; + + close(terminalId: string): Promise<{ closed: true }>; +} + +export const ITerminalService: ServiceIdentifier = + createDecorator('terminalService'); + +export interface ITerminalBackend { + readonly _serviceBrand: undefined; + + spawn(options: TerminalSpawnOptions): Promise; +} + +export const ITerminalBackend: ServiceIdentifier = + createDecorator('terminalBackend'); diff --git a/packages/agent-core-v2/src/terminal/terminalBackend.ts b/packages/agent-core-v2/src/terminal/terminalBackend.ts new file mode 100644 index 000000000..83d641e00 --- /dev/null +++ b/packages/agent-core-v2/src/terminal/terminalBackend.ts @@ -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 { + throw new NotImplementedError('terminalBackend'); + } +} + +registerScopedService( + LifecycleScope.Session, + ITerminalBackend, + NotImplementedTerminalBackend, + InstantiationType.Delayed, + 'terminal', +); diff --git a/packages/agent-core-v2/src/terminal/terminalService.ts b/packages/agent-core-v2/src/terminal/terminalService.ts new file mode 100644 index 000000000..0b38cbfeb --- /dev/null +++ b/packages/agent-core-v2/src/terminal/terminalService.ts @@ -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; + buffer: TerminalFrame[]; + nextSeq: number; + disposables: IDisposable[]; + closed: boolean; +} + +export class TerminalService extends Disposable implements ITerminalService { + declare readonly _serviceBrand: undefined; + + private readonly records = new Map(); + + constructor( + @ITerminalBackend private readonly backend: ITerminalBackend, + @IWorkspaceContext private readonly workspace: IWorkspaceContext, + @ISessionContext private readonly sessionContext: ISessionContext, + ) { + super(); + } + + async create(input: CreateTerminalRequest): Promise { + 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 { + return Promise.resolve( + [...this.records.values()].map((record) => ({ ...record.terminal })), + ); + } + + async get(terminalId: string): Promise { + 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 { + const record = this.requireRecord(terminalId); + record.process.write(data); + } + + async resize(terminalId: string, cols: number, rows: number): Promise { + 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): 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', +); diff --git a/packages/agent-core-v2/test/terminal/terminalService.test.ts b/packages/agent-core-v2/test/terminal/terminalService.test.ts new file mode 100644 index 000000000..f1a560fb4 --- /dev/null +++ b/packages/agent-core-v2/test/terminal/terminalService.test.ts @@ -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(); + 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 { + 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); + }); +}); diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index e105b14a0..1318bdaee 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -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',