* feat: Migrated headless orchestrator to use execution_complete events,… - "src/headless.ts" - "src/headless-ui.ts" - "src/tests/headless-v2-migration.test.ts" GSD-Task: S06/T02 * test: Wired pi-coding-agent to re-export JSONL utils from @gsd/rpc-clie… - "packages/pi-coding-agent/src/modes/rpc/jsonl.ts" - "packages/pi-coding-agent/package.json" - "packages/rpc-client/src/index.ts" - "packages/rpc-client/src/jsonl.ts" - "packages/rpc-client/src/rpc-client.ts" - "packages/rpc-client/src/rpc-types.ts" - "packages/rpc-client/src/rpc-client.test.ts" - "packages/rpc-client/package.json" GSD-Task: S06/T03 * feat: Wire --resume flag to resolve session IDs via prefix matching and… - "src/headless.ts" - "dist/headless.js" GSD-Task: S01/T01 * test: Added 5 e2e integration tests proving headless JSON batch, SIGINT… - "src/tests/integration/e2e-headless.test.ts" GSD-Task: S01/T02 * test: Updated @gsd/rpc-client and @gsd/mcp-server to 2.52.0 with publis… - "packages/rpc-client/package.json" - "packages/mcp-server/package.json" - "packages/rpc-client/.npmignore" - "packages/mcp-server/.npmignore" GSD-Task: S02/T01 * chore: auto-commit after complete-milestone GSD-Unit: M002-gzq23a * fix: revert jsonl.ts to inline implementation — @gsd-build/rpc-client not available at source-level test time in CI The re-export from @gsd-build/rpc-client fails in CI because tests run against TypeScript source (--experimental-strip-types) before any build step. The npm dependency resolves to node_modules/ which requires dist/ to exist. Reverting to the original inline implementation eliminates the cross-package dependency for source-level imports.
628 lines
21 KiB
TypeScript
628 lines
21 KiB
TypeScript
/**
|
|
* @gsd-build/mcp-server — Integration and unit tests.
|
|
*
|
|
* Strategy: We cannot mock @gsd-build/rpc-client at the module level without
|
|
* --experimental-test-module-mocks. Instead we test by:
|
|
*
|
|
* 1. Subclassing SessionManager to inject a mock client factory
|
|
* 2. Testing event handling, state transitions, and error paths
|
|
* 3. Testing tool registration via createMcpServer
|
|
* 4. Testing CLI path resolution via static method
|
|
*/
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { resolve } from 'node:path';
|
|
import { EventEmitter } from 'node:events';
|
|
|
|
import { SessionManager } from './session-manager.js';
|
|
import { createMcpServer } from './server.js';
|
|
import { MAX_EVENTS } from './types.js';
|
|
import type { ManagedSession, CostAccumulator, PendingBlocker } from './types.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock RpcClient (duck-typed to match RpcClient interface)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class MockRpcClient {
|
|
started = false;
|
|
stopped = false;
|
|
aborted = false;
|
|
prompted: string[] = [];
|
|
private eventListeners: Array<(event: Record<string, unknown>) => void> = [];
|
|
uiResponses: Array<{ requestId: string; response: Record<string, unknown> }> = [];
|
|
|
|
/** Control — set to make start() reject */
|
|
startError: Error | null = null;
|
|
/** Control — set to make init() reject */
|
|
initError: Error | null = null;
|
|
/** Control — override sessionId from init */
|
|
initSessionId = 'mock-session-001';
|
|
|
|
cwd: string;
|
|
args: string[];
|
|
|
|
constructor(options?: Record<string, unknown>) {
|
|
this.cwd = (options?.cwd as string) ?? '';
|
|
this.args = (options?.args as string[]) ?? [];
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
if (this.startError) throw this.startError;
|
|
this.started = true;
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
this.stopped = true;
|
|
}
|
|
|
|
async init(): Promise<{ sessionId: string; version: string }> {
|
|
if (this.initError) throw this.initError;
|
|
return { sessionId: this.initSessionId, version: '2.51.0' };
|
|
}
|
|
|
|
onEvent(listener: (event: Record<string, unknown>) => void): () => void {
|
|
this.eventListeners.push(listener);
|
|
return () => {
|
|
const idx = this.eventListeners.indexOf(listener);
|
|
if (idx >= 0) this.eventListeners.splice(idx, 1);
|
|
};
|
|
}
|
|
|
|
async prompt(message: string): Promise<void> {
|
|
this.prompted.push(message);
|
|
}
|
|
|
|
async abort(): Promise<void> {
|
|
this.aborted = true;
|
|
}
|
|
|
|
sendUIResponse(requestId: string, response: Record<string, unknown>): void {
|
|
this.uiResponses.push({ requestId, response });
|
|
}
|
|
|
|
/** Test helper — emit an event to all listeners */
|
|
emitEvent(event: Record<string, unknown>): void {
|
|
for (const listener of this.eventListeners) {
|
|
listener(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TestableSessionManager — injects mock clients without module mocking
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Subclass that overrides startSession to use MockRpcClient instead of the
|
|
* real RpcClient. We directly construct the session object, mirroring the
|
|
* parent's logic but with our mock.
|
|
*/
|
|
class TestableSessionManager extends SessionManager {
|
|
/** The last mock client created */
|
|
lastClient: MockRpcClient | null = null;
|
|
/** All mock clients */
|
|
allClients: MockRpcClient[] = [];
|
|
/** Counter for unique session IDs across multiple sessions */
|
|
private sessionCounter = 0;
|
|
/** Control: set to make startSession fail during init */
|
|
nextInitError: Error | null = null;
|
|
/** Control: set to make startSession fail during start */
|
|
nextStartError: Error | null = null;
|
|
|
|
override async startSession(projectDir: string, options: { cliPath?: string; command?: string; model?: string; bare?: boolean } = {}): Promise<string> {
|
|
if (!projectDir || projectDir.trim() === '') {
|
|
throw new Error('projectDir is required and cannot be empty');
|
|
}
|
|
|
|
const resolvedDir = resolve(projectDir);
|
|
|
|
// Check duplicate via getSessionByDir
|
|
const existing = this.getSessionByDir(resolvedDir);
|
|
if (existing) {
|
|
throw new Error(
|
|
`Session already active for ${resolvedDir} (sessionId: ${existing.sessionId}, status: ${existing.status})`
|
|
);
|
|
}
|
|
|
|
const client = new MockRpcClient({ cwd: resolvedDir, args: [] });
|
|
if (this.nextStartError) {
|
|
client.startError = this.nextStartError;
|
|
this.nextStartError = null;
|
|
}
|
|
if (this.nextInitError) {
|
|
client.initError = this.nextInitError;
|
|
this.nextInitError = null;
|
|
}
|
|
|
|
this.sessionCounter++;
|
|
client.initSessionId = `mock-session-${String(this.sessionCounter).padStart(3, '0')}`;
|
|
this.lastClient = client;
|
|
this.allClients.push(client);
|
|
|
|
// Create the session shell
|
|
const session: ManagedSession = {
|
|
sessionId: '',
|
|
projectDir: resolvedDir,
|
|
status: 'starting',
|
|
client: client as any, // duck-typed mock
|
|
events: [],
|
|
pendingBlocker: null,
|
|
cost: { totalCost: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
startTime: Date.now(),
|
|
};
|
|
|
|
// Insert into internal sessions map — access via protected method
|
|
this._putSession(resolvedDir, session);
|
|
|
|
try {
|
|
await client.start();
|
|
|
|
const initResult = await client.init();
|
|
session.sessionId = initResult.sessionId;
|
|
session.status = 'running';
|
|
|
|
// Wire event tracking using the same handleEvent logic as parent
|
|
session.unsubscribe = client.onEvent((event: Record<string, unknown>) => {
|
|
this._handleEvent(session, event);
|
|
});
|
|
|
|
// Kick off auto-mode
|
|
const command = options.command ?? '/gsd auto';
|
|
await client.prompt(command);
|
|
|
|
return session.sessionId;
|
|
} catch (err) {
|
|
session.status = 'error';
|
|
session.error = err instanceof Error ? err.message : String(err);
|
|
try { await client.stop(); } catch { /* swallow */ }
|
|
throw new Error(`Failed to start session for ${resolvedDir}: ${session.error}`);
|
|
}
|
|
}
|
|
|
|
/** Expose internal session map insertion for testing */
|
|
_putSession(key: string, session: ManagedSession): void {
|
|
// Access the private sessions map via any cast
|
|
(this as any).sessions.set(key, session);
|
|
}
|
|
|
|
/** Expose handleEvent for testing */
|
|
_handleEvent(session: ManagedSession, event: Record<string, unknown>): void {
|
|
(this as any).handleEvent(session, event);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let allManagers: TestableSessionManager[] = [];
|
|
|
|
function createManager(): TestableSessionManager {
|
|
const mgr = new TestableSessionManager();
|
|
allManagers.push(mgr);
|
|
return mgr;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SessionManager unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('SessionManager', () => {
|
|
let sm: TestableSessionManager;
|
|
|
|
beforeEach(() => {
|
|
sm = createManager();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const mgr of allManagers) {
|
|
await mgr.cleanup();
|
|
}
|
|
allManagers = [];
|
|
});
|
|
|
|
it('startSession creates session and returns sessionId', async () => {
|
|
const sessionId = await sm.startSession('/tmp/test-project', { cliPath: '/usr/bin/gsd' });
|
|
assert.equal(sessionId, 'mock-session-001');
|
|
|
|
const session = sm.getSession(sessionId);
|
|
assert.ok(session);
|
|
assert.equal(session.status, 'running');
|
|
assert.equal(session.projectDir, resolve('/tmp/test-project'));
|
|
});
|
|
|
|
it('startSession sends /gsd auto by default', async () => {
|
|
await sm.startSession('/tmp/test-prompt', { cliPath: '/usr/bin/gsd' });
|
|
assert.ok(sm.lastClient);
|
|
assert.deepEqual(sm.lastClient.prompted, ['/gsd auto']);
|
|
});
|
|
|
|
it('startSession sends custom command when provided', async () => {
|
|
await sm.startSession('/tmp/test-cmd', { cliPath: '/usr/bin/gsd', command: '/gsd auto --resume' });
|
|
assert.ok(sm.lastClient);
|
|
assert.deepEqual(sm.lastClient.prompted, ['/gsd auto --resume']);
|
|
});
|
|
|
|
it('startSession rejects duplicate projectDir', async () => {
|
|
await sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' });
|
|
await assert.rejects(
|
|
() => sm.startSession('/tmp/dup-test', { cliPath: '/usr/bin/gsd' }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Session already active'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('startSession rejects empty projectDir', async () => {
|
|
await assert.rejects(
|
|
() => sm.startSession('', { cliPath: '/usr/bin/gsd' }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('projectDir is required'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('startSession sets error status on start() failure', async () => {
|
|
sm.nextStartError = new Error('spawn failed');
|
|
|
|
await assert.rejects(
|
|
() => sm.startSession('/tmp/fail-start', { cliPath: '/usr/bin/gsd' }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Failed to start session'));
|
|
assert.ok(err.message.includes('spawn failed'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('startSession sets error status on init() failure', async () => {
|
|
sm.nextInitError = new Error('handshake failed');
|
|
|
|
await assert.rejects(
|
|
() => sm.startSession('/tmp/fail-init', { cliPath: '/usr/bin/gsd' }),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Failed to start session'));
|
|
assert.ok(err.message.includes('handshake failed'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('getSession returns undefined for unknown sessionId', () => {
|
|
const result = sm.getSession('nonexistent-id');
|
|
assert.equal(result, undefined);
|
|
});
|
|
|
|
it('getSessionByDir returns session for known dir', async () => {
|
|
await sm.startSession('/tmp/by-dir', { cliPath: '/usr/bin/gsd' });
|
|
const session = sm.getSessionByDir('/tmp/by-dir');
|
|
assert.ok(session);
|
|
assert.equal(session.sessionId, 'mock-session-001');
|
|
});
|
|
|
|
it('resolveBlocker errors when no pending blocker', async () => {
|
|
const sessionId = await sm.startSession('/tmp/no-blocker', { cliPath: '/usr/bin/gsd' });
|
|
await assert.rejects(
|
|
() => sm.resolveBlocker(sessionId, 'some response'),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('No pending blocker'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('resolveBlocker errors for unknown session', async () => {
|
|
await assert.rejects(
|
|
() => sm.resolveBlocker('unknown-session', 'some response'),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Session not found'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('resolveBlocker clears pendingBlocker and sends UI response', async () => {
|
|
const sessionId = await sm.startSession('/tmp/blocker-resolve', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
// Simulate a blocking UI request event
|
|
client.emitEvent({
|
|
type: 'extension_ui_request',
|
|
id: 'req-42',
|
|
method: 'select',
|
|
title: 'Pick an option',
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.ok(session.pendingBlocker);
|
|
assert.equal(session.status, 'blocked');
|
|
|
|
// Resolve the blocker
|
|
await sm.resolveBlocker(sessionId, 'option-a');
|
|
|
|
assert.equal(session.pendingBlocker, null);
|
|
assert.equal(session.status, 'running');
|
|
assert.equal(client.uiResponses.length, 1);
|
|
assert.equal(client.uiResponses[0].requestId, 'req-42');
|
|
});
|
|
|
|
it('cancelSession calls abort + stop on client', async () => {
|
|
const sessionId = await sm.startSession('/tmp/cancel-test', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
await sm.cancelSession(sessionId);
|
|
|
|
assert.ok(client.aborted);
|
|
assert.ok(client.stopped);
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'cancelled');
|
|
});
|
|
|
|
it('cancelSession errors for unknown session', async () => {
|
|
await assert.rejects(
|
|
() => sm.cancelSession('unknown'),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Session not found'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('cleanup stops all active sessions', async () => {
|
|
await sm.startSession('/tmp/cleanup-1', { cliPath: '/usr/bin/gsd' });
|
|
await sm.startSession('/tmp/cleanup-2', { cliPath: '/usr/bin/gsd' });
|
|
|
|
assert.equal(sm.allClients.length, 2);
|
|
|
|
await sm.cleanup();
|
|
|
|
for (const client of sm.allClients) {
|
|
assert.ok(client.stopped, 'Client should be stopped after cleanup');
|
|
}
|
|
});
|
|
|
|
it('event ring buffer caps at MAX_EVENTS', async () => {
|
|
const sessionId = await sm.startSession('/tmp/ring-buffer', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
for (let i = 0; i < MAX_EVENTS + 20; i++) {
|
|
client.emitEvent({ type: 'tool_use', index: i });
|
|
}
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.events.length, MAX_EVENTS);
|
|
// Oldest events trimmed — first event index should be 20
|
|
assert.equal((session.events[0] as Record<string, unknown>).index, 20);
|
|
});
|
|
|
|
it('blocker detection: non-fire-and-forget extension_ui_request sets pendingBlocker', async () => {
|
|
const sessionId = await sm.startSession('/tmp/blocker-detect', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
// 'select' is not in FIRE_AND_FORGET_METHODS
|
|
client.emitEvent({
|
|
type: 'extension_ui_request',
|
|
id: 'req-99',
|
|
method: 'select',
|
|
title: 'Choose wisely',
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'blocked');
|
|
assert.ok(session.pendingBlocker);
|
|
assert.equal(session.pendingBlocker.id, 'req-99');
|
|
assert.equal(session.pendingBlocker.method, 'select');
|
|
});
|
|
|
|
it('fire-and-forget methods do not set pendingBlocker', async () => {
|
|
const sessionId = await sm.startSession('/tmp/fire-forget', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
// 'notify' is fire-and-forget — on its own (no terminal prefix) should not block
|
|
client.emitEvent({
|
|
type: 'extension_ui_request',
|
|
id: 'req-100',
|
|
method: 'notify',
|
|
message: 'Just a notification',
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'running');
|
|
assert.equal(session.pendingBlocker, null);
|
|
});
|
|
|
|
it('terminal detection: auto-mode stopped sets status to completed', async () => {
|
|
const sessionId = await sm.startSession('/tmp/terminal', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
client.emitEvent({
|
|
type: 'extension_ui_request',
|
|
method: 'notify',
|
|
message: 'Auto-mode stopped — all tasks complete',
|
|
id: 'term-1',
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'completed');
|
|
});
|
|
|
|
it('terminal detection with blocked: message sets status to blocked', async () => {
|
|
const sessionId = await sm.startSession('/tmp/terminal-blocked', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
client.emitEvent({
|
|
type: 'extension_ui_request',
|
|
method: 'notify',
|
|
message: 'Auto-mode stopped — blocked: needs user input',
|
|
id: 'block-1',
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'blocked');
|
|
assert.ok(session.pendingBlocker);
|
|
});
|
|
|
|
it('cost tracking: cumulative-max from cost_update events', async () => {
|
|
const sessionId = await sm.startSession('/tmp/cost-track', { cliPath: '/usr/bin/gsd' });
|
|
const client = sm.lastClient!;
|
|
|
|
client.emitEvent({
|
|
type: 'cost_update',
|
|
cumulativeCost: 0.05,
|
|
tokens: { input: 1000, output: 500, cacheRead: 200, cacheWrite: 100 },
|
|
});
|
|
|
|
client.emitEvent({
|
|
type: 'cost_update',
|
|
cumulativeCost: 0.12,
|
|
tokens: { input: 2500, output: 800, cacheRead: 150, cacheWrite: 300 },
|
|
});
|
|
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.cost.totalCost, 0.12);
|
|
assert.equal(session.cost.tokens.input, 2500);
|
|
assert.equal(session.cost.tokens.output, 800);
|
|
assert.equal(session.cost.tokens.cacheRead, 200); // First was higher
|
|
assert.equal(session.cost.tokens.cacheWrite, 300); // Second was higher
|
|
});
|
|
|
|
it('getResult returns HeadlessJsonResult-shaped object', async () => {
|
|
const sessionId = await sm.startSession('/tmp/result-shape', { cliPath: '/usr/bin/gsd' });
|
|
const result = sm.getResult(sessionId);
|
|
|
|
assert.equal(result.sessionId, sessionId);
|
|
assert.equal(result.projectDir, resolve('/tmp/result-shape'));
|
|
assert.equal(result.status, 'running');
|
|
assert.equal(typeof result.durationMs, 'number');
|
|
assert.ok(result.cost);
|
|
assert.ok(Array.isArray(result.recentEvents));
|
|
assert.equal(result.pendingBlocker, null);
|
|
assert.equal(result.error, null);
|
|
});
|
|
|
|
it('getResult errors for unknown session', () => {
|
|
assert.throws(
|
|
() => sm.getResult('unknown'),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Session not found'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI path resolution tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('SessionManager.resolveCLIPath', () => {
|
|
const originalGsdPath = process.env['GSD_CLI_PATH'];
|
|
const originalPath = process.env['PATH'];
|
|
|
|
afterEach(() => {
|
|
if (originalGsdPath !== undefined) {
|
|
process.env['GSD_CLI_PATH'] = originalGsdPath;
|
|
} else {
|
|
delete process.env['GSD_CLI_PATH'];
|
|
}
|
|
if (originalPath !== undefined) {
|
|
process.env['PATH'] = originalPath;
|
|
}
|
|
});
|
|
|
|
it('GSD_CLI_PATH env var takes precedence', () => {
|
|
process.env['GSD_CLI_PATH'] = '/custom/path/to/gsd';
|
|
const result = SessionManager.resolveCLIPath();
|
|
assert.equal(result, resolve('/custom/path/to/gsd'));
|
|
});
|
|
|
|
it('throws when GSD_CLI_PATH not set and which fails', () => {
|
|
delete process.env['GSD_CLI_PATH'];
|
|
process.env['PATH'] = '/nonexistent';
|
|
assert.throws(
|
|
() => SessionManager.resolveCLIPath(),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Cannot find GSD CLI'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool registration tests (via createMcpServer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('createMcpServer tool registration', () => {
|
|
let sm: TestableSessionManager;
|
|
|
|
beforeEach(() => {
|
|
sm = createManager();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const mgr of allManagers) {
|
|
await mgr.cleanup();
|
|
}
|
|
allManagers = [];
|
|
});
|
|
|
|
it('creates server successfully with all required methods', async () => {
|
|
const { server } = await createMcpServer(sm);
|
|
assert.ok(server);
|
|
assert.ok(typeof server.connect === 'function');
|
|
assert.ok(typeof server.close === 'function');
|
|
});
|
|
|
|
it('gsd_execute flow returns sessionId on success', async () => {
|
|
const sessionId = await sm.startSession('/tmp/tool-exec', { cliPath: '/usr/bin/gsd' });
|
|
assert.equal(typeof sessionId, 'string');
|
|
assert.ok(sessionId.length > 0);
|
|
});
|
|
|
|
it('gsd_status flow returns correct shape', async () => {
|
|
const sessionId = await sm.startSession('/tmp/tool-status', { cliPath: '/usr/bin/gsd' });
|
|
const session = sm.getSession(sessionId)!;
|
|
|
|
assert.equal(typeof session.status, 'string');
|
|
assert.ok(Array.isArray(session.events));
|
|
assert.ok(session.cost);
|
|
assert.equal(typeof session.startTime, 'number');
|
|
});
|
|
|
|
it('gsd_resolve_blocker flow returns error when no blocker', async () => {
|
|
const sessionId = await sm.startSession('/tmp/tool-resolve', { cliPath: '/usr/bin/gsd' });
|
|
await assert.rejects(
|
|
() => sm.resolveBlocker(sessionId, 'fix'),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('No pending blocker'));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('gsd_result flow returns HeadlessJsonResult shape', async () => {
|
|
const sessionId = await sm.startSession('/tmp/tool-result', { cliPath: '/usr/bin/gsd' });
|
|
const result = sm.getResult(sessionId);
|
|
|
|
assert.ok('sessionId' in result);
|
|
assert.ok('projectDir' in result);
|
|
assert.ok('status' in result);
|
|
assert.ok('durationMs' in result);
|
|
assert.ok('cost' in result);
|
|
assert.ok('recentEvents' in result);
|
|
assert.ok('pendingBlocker' in result);
|
|
assert.ok('error' in result);
|
|
});
|
|
|
|
it('gsd_cancel flow marks session as cancelled', async () => {
|
|
const sessionId = await sm.startSession('/tmp/tool-cancel', { cliPath: '/usr/bin/gsd' });
|
|
await sm.cancelSession(sessionId);
|
|
const session = sm.getSession(sessionId)!;
|
|
assert.equal(session.status, 'cancelled');
|
|
});
|
|
});
|