agent-ecosystem/test/features/workspace-trust/core/PtyDialogEngine.test.ts
2026-05-13 17:56:00 +03:00

167 lines
5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { PTY_KEY_ACTIONS, runPtyDialogEngine } from '@features/workspace-trust/core/application';
import type {
PtyKeyAction,
PtySessionPort,
TerminalSnapshot,
} from '@features/workspace-trust/core/application';
class FakePtySession implements PtySessionPort {
readonly actions: PtyKeyAction[] = [];
constructor(private readonly snapshots: string[]) {}
async readSnapshot(): Promise<TerminalSnapshot | null> {
const text = this.snapshots.shift() ?? this.snapshots.at(-1) ?? '';
return { text, capturedAtMs: Date.now() };
}
async writeAction(action: PtyKeyAction): Promise<void> {
this.actions.push(action);
}
async kill(): Promise<void> {
return undefined;
}
}
describe('PtyDialogEngine', () => {
it('sends allowlisted dialog actions once and stops when post-action verification succeeds', async () => {
const session = new FakePtySession(['Quick safety check\ntrust this folder']);
const result = await runPtyDialogEngine({
session,
timeoutMs: 200,
pollIntervalMs: 1,
isCancelled: () => false,
detect: () => ({
phase: 'dialog',
ruleId: 'claude.workspace_trust',
actions: [PTY_KEY_ACTIONS.enter],
retryPolicy: 'once',
evidence: ['trust prompt'],
}),
afterDialogAction: async () => ({ action: 'stop', reason: 'workspace_trust_persisted' }),
});
expect(result).toMatchObject({
status: 'ok',
reason: 'workspace_trust_persisted',
actions: ['claude.workspace_trust:enter'],
});
expect(session.actions).toEqual([PTY_KEY_ACTIONS.enter]);
});
it('does not repeat once-only actions against stale terminal text', async () => {
const session = new FakePtySession(['trust', 'trust', 'trust']);
const result = await runPtyDialogEngine({
session,
timeoutMs: 20,
pollIntervalMs: 1,
settleDelayMs: 1,
isCancelled: () => false,
detect: () => ({
phase: 'dialog',
ruleId: 'claude.workspace_trust',
actions: [PTY_KEY_ACTIONS.enter],
retryPolicy: 'once',
evidence: ['trust prompt'],
}),
});
expect(result.status).toBe('timeout');
expect(session.actions).toEqual([PTY_KEY_ACTIONS.enter]);
});
it('blocks when retryable dialogs exceed the configured action budget', async () => {
const session = new FakePtySession(['confirm', 'confirm', 'confirm']);
const result = await runPtyDialogEngine({
session,
timeoutMs: 100,
pollIntervalMs: 1,
settleDelayMs: 1,
maxActions: 2,
isCancelled: () => false,
detect: () => ({
phase: 'dialog',
ruleId: 'claude.bypass_permissions',
actions: [PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter],
retryPolicy: 'typed_retry',
evidence: ['bypass prompt'],
}),
});
expect(result).toMatchObject({
status: 'blocked',
code: 'workspace_trust_too_many_dialog_actions',
actions: ['claude.bypass_permissions:down', 'claude.bypass_permissions:enter'],
});
expect(session.actions).toEqual([PTY_KEY_ACTIONS.down, PTY_KEY_ACTIONS.enter]);
});
it('returns cancelled before writing actions when cancellation is requested after detection', async () => {
const session = new FakePtySession(['Quick safety check\ntrust this folder']);
let cancelled = false;
const result = await runPtyDialogEngine({
session,
timeoutMs: 100,
pollIntervalMs: 1,
isCancelled: () => {
const current = cancelled;
cancelled = true;
return current;
},
detect: () => ({
phase: 'dialog',
ruleId: 'claude.workspace_trust',
actions: [PTY_KEY_ACTIONS.enter],
retryPolicy: 'once',
evidence: ['trust prompt'],
}),
});
expect(result).toMatchObject({
status: 'cancelled',
matchedRuleIds: ['claude.workspace_trust'],
actions: [],
});
expect(session.actions).toEqual([]);
});
it('keeps the last non-empty terminal snapshot for timeout diagnostics', async () => {
const session = new FakePtySession(['Unknown Claude startup screen', '', '']);
const result = await runPtyDialogEngine({
session,
timeoutMs: 20,
pollIntervalMs: 1,
settleDelayMs: 1,
isCancelled: () => false,
detect: () => ({ phase: 'loading' }),
});
expect(result.status).toBe('timeout');
expect(result.lastSnapshot?.text).toBe('Unknown Claude startup screen');
});
it('blocks setup-required screens without sending actions', async () => {
const session = new FakePtySession(['Log in to Claude']);
const result = await runPtyDialogEngine({
session,
timeoutMs: 20,
pollIntervalMs: 1,
isCancelled: () => false,
detect: () => ({
phase: 'setup_required',
code: 'provider_auth_required',
evidence: ['auth'],
}),
});
expect(result).toMatchObject({
status: 'blocked',
code: 'provider_auth_required',
});
expect(session.actions).toEqual([]);
});
});