mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-26 22:51:47 +02:00
* fix: acknowledge seeded plans before invoking review skills * fix: distinguish current plan input from conversation history * fix: keep hermetic plan reviews on manual permissions * fix: distinguish tool discovery from file permission ownership * fix: preserve initial plan mode in observation tests * fix: wait for scope decisions before writing review findings * fix: carry autoplan decisions consistently into review artifacts * test: retain native failure context in periodic assertions * fix: advance active file permissions before queued questions * fix: finish red-team attempts before retry and cleanup * fix: finalize plan format captures and judges before retry * fix: cancel setup-gbrain SDK attempts before fixture cleanup * test: select periodic consumers of the bounded attempt helper * fix native Bash permission cards and queued questions * fix: preserve independent decisions and review scope Keep CEO approach, engineering scope and outside-review choices from approving independent remedies together. Carry declared contracts through DX polish and resolve new gaps before editing the plan. Regenerate every host and retain existing stop boundaries. Validation: 654 focused tests passed across nine files; all-host generation passed. Full free and periodic validation pending. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: require approval before design plan amendments Align the Design review philosophy and rating recipe with its section protocol: resolve one proposed fix, then apply only that approved decision and retain honest scores for declined fixes. Validation: 469 focused tests passed across four files; all-host generation passed. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix: observe native question completion before transcript persistence Match owned completion hooks to submitted choices, reject conflicting or late answers, and retain bounded failure evidence. * test: recognize review posture in acknowledged native questions Require the selected mode acknowledgement, a completed follow-up question, and its current decoded display while preserving existing posture assertions. * fix: preserve settled CEO choices and isolate pending remedies Resolve established approach gates with cited authority and keep independent fixes out of unrelated option commitments and plan amendments. * fix: carry approved DX choices through later review steps Choose documentation approaches within the accepted scope and map resolved confusion points without reopening them through a bulk menu. * test: handle native settings-file edit prompts Keep one-time owned-file approvals and retain the actual sampled Autoplan permission frame with its matching barrier state. * test: accept standard CEO reply directives with tuning footers Recognize the exact trailing preference footer and letter-list directive while preserving current-display and exact acknowledgement checks. * test: scope split reviewers to their generated plan artifacts * test: observe native Bash permissions and invocation results * test: handle owned Bash prompts during mode preference checks * test: preserve synchronous subprocess rejection in Codex fixture * Fix periodic review handoff navigation Recognize review-first and explicit manual-next-step labels while preserving exact action families, manual preference, and ambiguous-menu rejection. Co-authored-by: OpenAI Codex <noreply@openai.com> * Bind pending file permissions to distinct current targets Allow one captured file request to own the complete current dialog while unrelated file work is pending. Preserve same-path ambiguity, exact input ownership, and one-time grant checks. Co-authored-by: OpenAI Codex <noreply@openai.com> * Make paired CEO verification choices genuinely unresolved Start the positive control with proposed manual checks so its unchanged oracle measures two new coverage decisions. Preserve runtime contracts, targets, count bounds, and all assertions. Co-authored-by: OpenAI Codex <noreply@openai.com> * Keep CEO review options and verification within approved scope Audit every offered option for independent add-ons and keep new verification depth pending until accepted. Preserve already requested coverage and trace plan changes to the actual decision. Co-authored-by: OpenAI Codex <noreply@openai.com> * Assemble DX review artifacts before appending the final report Keep early DX evidence above decisions, update artifact sections in place, and append the report using the actual current file suffix. Re-read after deleting an existing report before choosing the append anchor. Co-authored-by: OpenAI Codex <noreply@openai.com> * Keep outside plan reviews exclusive and invocation-owned Follow one preflight-selected backend, terminate failed Codex work before fallback, and allocate extra prompt/output files uniquely. Consume only the current invocation’s completed output. Co-authored-by: OpenAI Codex <noreply@openai.com> * Select periodic completion evaluations for report writer changes Register the shared review resolver for eight missing consumers and regress selection for all nine completion cases without changing their IDs or tiers. Co-authored-by: OpenAI Codex <noreply@openai.com> * Keep permission ambiguity fixtures on the same normalized target Use distinct raw spellings of one target in the four negative fixtures so they exercise the normalized duplicate-owner guard after exact current-file disambiguation. Preserve the existing exception, no-input, diagnostic and cleanup assertions. Co-authored-by: OpenAI Codex <noreply@openai.com> * Clarify preserved contracts in engineering review fixture Co-authored-by: OpenAI Codex <noreply@openai.com> * Recognize the offered DX follow-up handoff Co-authored-by: OpenAI Codex <noreply@openai.com> * Check independent commitments before presenting review options Co-authored-by: OpenAI Codex <noreply@openai.com> * Keep Codex review output and status in one shell invocation Co-authored-by: OpenAI Codex <noreply@openai.com> * Distinguish seeded plans from reports written by a test attempt Co-authored-by: OpenAI Codex <noreply@openai.com> * Recover clipped Autoplan file approvals with bounded viewport resizing Co-authored-by: OpenAI Codex <noreply@openai.com> * Recover clipped Bash approvals before binding the complete command Co-authored-by: OpenAI Codex <noreply@openai.com> * Isolate setup message tests from the shared checkout Run the real installer in a temporary payload with private config, require successful completion, and guard source and binary contents and mtimes. Co-authored-by: OpenAI Codex <noreply@openai.com> * Fix periodic native permission and report completion handling Match the pinned CLI's soft wraps and clipped headings without granting from incomplete frames. Retire completed file requests, retain mode annotations, and ask section captures for a short final acknowledgement after their full report is saved. Co-authored-by: OpenAI Codex <noreply@openai.com> * Preserve review approvals and validate DX comparison artifacts Keep independent remedies and approved amendments explicit. Give the synthetic DX review its existing documentation and validate peer comparison as required analysis alongside four native decisions. Add positive and negative semantic calibrations while preserving review counts, model budgets and prompt size limits. Co-authored-by: OpenAI Codex <noreply@openai.com> * Make the five-finding CEO fixture's application boundary explicit Materialize the request adapter and service composition used by the synthetic payment application. Explicitly declare the revised unregistered-event and mail-telemetry assumptions while preserving uncaught handler errors, the original invoice path and all five unresolved findings. Co-authored-by: OpenAI Codex <noreply@openai.com> * Keep CEO state-path checks scoped to directory preparation Co-authored-by: OpenAI Codex <noreply@openai.com> * Use checked ports and bounded cleanup in pair-agent tests Discover the daemon port from its owned state file, retain startup diagnostics, and await failed-start cleanup. Add occupied-port, early-exit, deadline, and foreign-state regressions while preserving the existing HTTP assertions and hook budgets. Co-authored-by: Codex <noreply@openai.com> * Preserve queued edit identity and recover clipped Bash permissions Distinguish separately queued unfinished edits from mutation of one native tool ID. Keep grants bound to an exact owned request and reject reused IDs, ambiguous inputs, and competing owners. Support the pinned renderer's literal em dash and request a repaint when only the Bash card's top rule is clipped. Grants still require the complete fresh card and an exact native acknowledgment. Validation: 413 integrated parser/event tests passed; private repaint controls and joint source review passed. Full canonical suite and native periodic rerun remain pending. Co-authored-by: Codex <noreply@openai.com> * Keep periodic reviews within their approved contracts and deliverables Carry exact approvals through engineering review, preserve declared contracts when amending CEO plans, and keep prioritization at the requested decision level. Materialize the revised synthetic SDK reference contract while retaining the five original documentation gaps. Accept the observed semicolon in the finite DX handoff menu and register the direct source dependencies used by the engineering cases. Regenerate canonical review documents without changing model budgets, retries, count bands, or native completion assertions. Validation: all-host generation and 275 review, fixture, selection and parity tests passed. Full free-suite and native periodic validation remain pending. Co-authored-by: Codex <noreply@openai.com> * Keep Eng approval cadence and independence guards explicit * Accept ordinary punctuation in manual review handoffs * Recover file permissions alongside queued Bash calls * Carry approved DX work through later review findings * Clarify the synthetic auth internal failure decision * Bound the periodic DX fixture to onboarding changes * Recognize native Design review handoff labels * Hold scope in the integration-choice review fixture * Carry approved Design decisions through review evidence * Capture listener state when feedback reload fails * Exclude workspace caches before checking deprecated flags * Verify Design UI scope against a seeded review plan * Clarify plan review decisions and outside-voice approval flow * Reject setup menus in the Design UI gate * docs: require focused repair validation before final acceptance * fix: separate review commitments within existing prompt budgets * docs: align generation and contributor validation guidance * fix: advance native review prompts and count acknowledged findings * chore: bump version and changelog (v1.87.1.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: enforce cheap checks and side-effect-free validation previews * fix: handle owned Fetch permissions and oversized native cards * test: ground review fixtures in independent executable contracts * fix: preserve review decisions and verify reports before completion * test: construct the synthetic credential URL without a scanner false positive * test: materialize DX examples and verify their actual local behavior * fix: clarify CEO review decisions and execution order * fix: clarify review workflow ordering and select Design quality checks * Fix review decision gates and incomplete evaluation fixtures Persist CEO and engineering commitment ledgers before menus, preserve exact approvals, and distinguish implementation structure from feature scope. Route Autoplan through the canonical CEO Step 0 ordering. Classify DX findings before requesting approval and ground runtime claims in actual evidence. Complete neutral non-target fixture contracts and accept the captured Design handoff purpose without relaxing its ownership or acknowledgment checks. Record runtime-capability verification in AGENTS.md validation discipline. Validation: 1,335 focused tests passed across 21 files; build, all-host freshness, skill validation (647 artifacts / 107 tracked), and credential checks passed. Prior paid failures are preserved; behavioral acceptance remains pending. * Fix review decision boundaries and owned Read prompts Preserve exact approvals across review options, compare consistent DX milestones, and keep proposed implementation separate from review evidence. Bind modern Read prompts to one immutable native request and wait for its result. Retain captured regression verdicts, correct fixture error names, improve import probe diagnostics, and record focused-first validation discipline in AGENTS.md. * Clarify CEO and engineering review decisions Use explicit decision steps, one engineering ledger, and clear scope/write transitions. Preserve exact approvals and distinguish pending test requirements. Keep unrelated generated content unchanged. * Fix review decision ordering and native evaluation interactions * Clarify engineering decisions and test artifact order * Clarify pending choices and approvals in CEO reviews * Make CEO review phases sequential and clarify completion * Fix Design board submission intent matching * Seed an existing browser test baseline for Autoplan * Document decision-log payloads before state initialization * Preserve exact review scope and decide one change before drafting options * Require input identity before repeating passing model judges * Honor permitted storage throughout CEO review completion * Match complete native permission text within the pinned renderer contract * Align review approvals, independent choices, and bounded validation * fix: preserve reopened approvals and declare fixture interfaces * fix: isolate review artifacts and audit complete questions * fix: match detector artifact permissions to configured storage * fix: complete native permissions and review fixture workflows * fix: order CEO review work and separate engineering guarantees * fix: preserve native validation and separate review choices * fix: clarify review decisions and judge complete report context * fix: constrain review judgments and retain parse failures * fix: compare each affected value before review decisions * fix: make engineering review decisions and completion order explicit * fix: give the complete Autoplan evaluation a bounded chain budget * fix(cso): diagnose forbidden Docker endpoints before tool lookup * fix(reviews): reconcile workflow contracts and generated artifacts after main integration * fix(evals): migrate retained regressions to the native review harness * fix(tests): close native harness and workflow integration regressions * fix(evals): preserve complete permission context and native menu contracts * fix(tests): capture synchronous command output without pipe drain stalls * fix(reviews): clarify decision and completion ordering * fix(reviews): separate decision readiness from final completion checks * refactor(reviews): consolidate decision rules and completion branches * fix(plan-eng-review): order preparation and clarify decision routing * fix(plan-eng-review): restore size and question-format guard parity * fix(plan-eng-review): clarify scope phases and blocked completion * fix(plan-eng-review): unify review flow and report destination * fix(plan-eng-review): define bootstrap and question stage ownership * fix(plan-eng-review): clarify review structure and design lookup * fix(plan-eng-review): render report examples and show saved decisions * fix: consolidate Eng review decisions and select their evaluations * test: cover overlapping terminal attachments and clean merged runner type * fix: preserve Office Hours relationship closings during review updates * fix: retain pasted review targets across slash invocations * docs: preserve validation traces and correct release scope * test: cover pasted targets in both review skills * fix: validate report artifacts before recording success * fix: redact source roots at CSO report boundaries * fix: bind native Design questions before answering * test: select report privacy and native recovery regressions * test: bind rejection predicate in extracted observers * fix: bind complete boxed native questions * test: keep the Design UI fixture on native review * fix: preserve review decisions and evaluation completion outcomes * fix: clarify CEO approval and report completion order * fix: align native review evaluation ownership and completion * fix: bind review evaluators to native decisions and owned artifacts * fix: validate review decisions against native outcomes * fix: preserve review evidence and Autoplan phase handoffs * test: bind review evidence to owned decisions and completion * fix: retain owned native history across compaction * fix(evals): validate current review decisions and setup choices * fix: bind Autoplan reviews and phase completion to current amended input * fix: reconcile native review evidence and close Autoplan phases * test: recognize owned whole-candidate complexity decisions * test: preserve report freshness for approved investigation handoffs * fix: recognize scoped review findings and isolate dual voice fixtures * fix: make review handoffs and question dispatch self-contained * test: recognize complete CEO decisions and procedural pauses * fix: bind current CEO comparison options and risk intervals * test: bind engineering decisions and completion to owned evidence * fix: publish Autoplan phase reports before continuing tools * test: verify actual Autoplan dual-review dispatch evidence * test: select dual review when shared evidence fixtures change * fix: clarify plan review decisions and completion gates * fix: make CEO review decisions and return paths explicit * test: keep Autoplan prompt files inside attempt state * test: preserve source whitespace across permission dialog wraps * fix: publish Autoplan phase reports before continuing * test: recognize current CEO comparisons and reject inactive records * fix: reconcile engineering decision states before completion * test: recognize complete Design decisions and reports * test: verify current engineering decisions before navigation * Recognize source-owned component reduction choices * fix: recognize current CEO ledger and commitment grids * test: supply RequestPolicy context to Eng count fixture * fix: save complete engineering decisions before asking * fix: bind Autoplan publication to the complete phase readback * chore: prepare 1.87.5.0 reliability release * fix: clarify engineering review completion and preserve log failures * fix: bind CEO saved choices and current section ancestry * fix(evals): bind review execution and completion evidence * fix(plan-ceo-review): verify complete decisions before asking * fix(evals): preserve complete engineering choice records * fix(evals): preserve complete review outcomes and bounded fixtures * fix(autoplan): publish phase reports before advancing * fix(plan-ceo-review): validate option fields before asking * fix(plan-eng-review): verify current decisions after answers * fix(evals): bind review decisions and bound fixture scope * fix(plan-ceo-review): verify decision rows and edit saved checkpoints * fix(evals): bind review evidence and scope document lookup * fix(plan-eng-review): update resolution state with its answer * fix(reviews): preserve complete questions through dispatch * fix(evals): recognize completed mode declarations * fix(evals): define cache consistency at wrapper completion * fix(evals): validate owned initial scope and completed review handoffs * fix: assemble complete CEO decision fields before saving * fix: authenticate automatic mode decisions without guessing selectors * fix: bind engineering coverage to approved regression contracts * fix(evals): supply review helpers to native Eng capture * fix(plan-eng-review): preserve the full selected option scope * fix(evals): recognize owned engineering seed and regression evidence * fix(evals): bind engineering retry reports to native approvals * docs: clarify release guarantees (v1.87.5.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(evals): recognize owned engineering decisions and handoffs * fix(evals): bind engineering decisions and completion evidence * fix(tests): align review contracts and selection fixtures * fix(skills): restore review prompt size limits * fix(plan-eng-review): clarify review execution and completion * fix(evals): preserve configured retries through all supervision layers * Clarify Engineering decisions and report completion * Keep native decision assertions within their source boundary * fix: recognize owned engineering decisions and completed navigation * fix: bind completed auto decisions to their current review * fix: recognize explicit CEO source attribution * fix: dispatch verified CEO decisions without recomposing fields * test: expose existing execution deadlines to review actors * fix: distinguish CEO decision records from incidental headings * test: bind split-scope choices to the registered native actor * test: connect reviewed regressions to required evaluation coverage * Clarify CEO decision routing and completion stages * test: expose existing section review deadlines to fixture actors * test: recognize complete native CEO pacing inventories * test: exclude answered history from current CEO payloads * test: detect phase entry through owned skill HOME aliases * test: validate native review completion and owned report permissions * fix: make Autoplan close packets carry the parent handoff steps * test: assess source-bound HOLD decisions within the existing deadline * fix: keep CEO native decision fields under one formatting authority * test: register integrated review and permission dependencies * test: align native review adapters and finding coverage Preserve explicit AUTO decisions, apply native single-select defaults, and bind complete cropped questions and report permissions to their owned requests. Require seeded review findings instead of crediting setup menus. Keep captured failure controls and additive selection dependencies. The integrated candidate passed 3,099 focused tests across 65 files; affected paid validation remains required before publication. * fix(autoplan): require phase reports before advancing * fix(evals): bind setup and evidence to complete attempts * fix(evals): bind native answers and pending writes to fixture scope Preserve complete option rows when native descriptions wrap, retain current owned Write arguments before journal publication, and keep engineering and DX answers within their declared fixture interfaces. Add captured free regressions without increasing model budgets or relaxing completion checks. * fix(autoplan): verify phase reports across native tool paths Guard owned methodology reads and reviewer dispatches, detect complete driver loads through Bash, and distinguish report-only edits from implementation changes. Follow authenticated native UUID ancestry when journal writes arrive out of order and verify earlier native content for cached phase reads. Keep current close acknowledgment and parent publication in order, require CEO entry before later phases, and register captured failure regressions. * fix(evals): honor native input and collection lifecycles Match complete native Edit panes and truncated question borders, reject stderr close before EOF, and stop the CEO split fixture once its acknowledged scope decisions are collected. Keep semantic validation, process failures, report requirements, and absolute deadlines authoritative. Add captured-event and real-process regressions with selection dependencies. Focused checks pass; final integrated paid and full-suite acceptance remain pending. * fix(autoplan): retain native session ownership across directory changes Recover missed native UUID ancestry through the existing strict graph while preserving ordinary event order and legacy scoping. Bind publication hooks to Claude's original project directory while retaining current cwd for requested file paths. Captured public-event regressions, existing caller checks, and a pinned native CLI loopback verify both fixes. Preserve failed attempts and require fresh paid and final full-suite acceptance. * docs: align evaluation limits and completion version * fix(autoplan): allow authenticated phase reads during journal streaming * fix(evals): bind clipped native questions and owned edit dialogs * fix: preserve overlay retries and bounded cleanup * fix: recognize owned planning preludes in native questions * docs: explain overlay scheduling and cleanup guarantees * fix: require fresh publication after Autoplan phase reruns * Release gstack 1.87.6 * fix: preserve CI paths, process identity, and test deadlines * fix: keep informational setup commands independent of install probes * fix: clarify plan review decisions and bound source audit reports * Fix remaining Windows identity and native path CI failures * Clarify CEO review decision and reviewer-result routing * test: accept no-install planner in retry supervision * fix(ceo-review): make review decisions and report completion explicit * perf(test): add fast PR gates, input-keyed judge reuse and isolated free shards * fix(test): start isolated CEO smoke from its existing project plan * fix(test): repair CI fixture races and preserve retry evidence * fix(ceo-review): clarify approvals, depth and saved completion --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
2440 lines
188 KiB
TypeScript
2440 lines
188 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||
import * as fs from 'node:fs';
|
||
import * as os from 'node:os';
|
||
import * as path from 'node:path';
|
||
import { readPlanSkillQuestions, matchesNativeQuestion, nativeQuestionSelection, isNativeQuestionSubmitVisible, currentFilePermissionTarget, matchesClippedBashPermission, nativePermissionKey, reserveNativePermissionGrant, type NativeQuestion, type NativePermissionGrant } from './helpers/plan-skill-questions';
|
||
import { isPermissionDialogVisible, parseNumberedOptions, stripAnsi } from './helpers/claude-pty-runner';
|
||
import { setupQuestionEventSource, readPermissionRequestEvents } from './helpers/plan-skill-question-events';
|
||
import retainedQuestionValidation from './fixtures/eng-auq-validation-error.json';
|
||
import retainedBashDirectory from './fixtures/bash-directory-permission.json';
|
||
import retainedDesignTasksPermission from './fixtures/design-tasks-bash-permission.json';
|
||
import { E2E_TOUCHFILES, selectTests } from './helpers/touchfiles';
|
||
|
||
const sessionId = '00000000-0000-4000-8000-000000000001';
|
||
const question: NativeQuestion = { question: 'D1 — Which approach?\nMake it reliable. Enforce the delivery policy.', header: 'Approach', multiSelect: false,
|
||
options: ['Extend dispatcher', 'Queue fanout', 'Minimal patch', 'Hold scope'].map(label => ({ label, description: label })) };
|
||
let config: string;
|
||
let file: string;
|
||
const call = (id: string, questions = [question]) => ({ type: 'assistant', sessionId, message: { role: 'assistant', stop_reason: 'tool_use', content: [{ type: 'tool_use', id, name: 'AskUserQuestion', input: { questions } }] } });
|
||
const write = (...rows: unknown[]) => fs.writeFileSync(file, rows.map(row => JSON.stringify(row) + '\n').join(''));
|
||
beforeEach(() => { config = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'native-question-'))); file = path.join(config, 'projects', 'fixture', `${sessionId}.jsonl`); fs.mkdirSync(path.dirname(file), { recursive: true }); });
|
||
afterEach(() => fs.rmSync(config, { recursive: true, force: true }));
|
||
|
||
function earlyQuestions() {
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PreToolUse[0].hooks[0].command;
|
||
return { source, emit(id: string, input: unknown = { questions: [question] }, toolName = 'AskUserQuestion') {
|
||
const result = Bun.spawnSync(['bash', '-c', command], { timeout: 5000, stdin: Buffer.from(JSON.stringify({
|
||
hook_event_name: 'PreToolUse', session_id: sessionId, transcript_path: file, cwd: config,
|
||
tool_name: toolName, tool_use_id: id, tool_input: input,
|
||
})), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||
expect(result.stdout.length).toBe(0);
|
||
} };
|
||
}
|
||
|
||
function completedQuestionHook() {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: 'Review' } });
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PreToolUse[0].hooks[0].command;
|
||
const input = { questions: [structuredClone(question)] };
|
||
const response = { questions: input.questions, answers: { [question.question]: question.options[0]!.label } };
|
||
const emit = (event: string, id: string, toolInput: unknown, extra: Record<string, unknown> = {}) => {
|
||
const result = Bun.spawnSync(['bash', '-c', command], { timeout: 5000, stdin: Buffer.from(JSON.stringify({
|
||
hook_event_name: event, session_id: sessionId, transcript_path: file, cwd: config,
|
||
tool_name: 'AskUserQuestion', tool_use_id: id, tool_input: toolInput, ...extra,
|
||
})), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode).toBe(0); expect(result.stdout.length).toBe(0); expect(result.stderr.length).toBe(0);
|
||
};
|
||
const pre = () => emit('PreToolUse', 'question-1', input);
|
||
const post = (output: unknown = response, extra: Record<string, unknown> = {}) =>
|
||
emit('PostToolUse', 'question-1', { ...input, answers: response.answers }, { tool_response: output, ...extra });
|
||
const read = () => readPlanSkillQuestions(config, sessionId, source);
|
||
return { source, input, response, emit, pre, post, read };
|
||
}
|
||
|
||
test('AUQ completion retires only the exact answered call during transcript persistence lag', () => {
|
||
const s = completedQuestionHook(); s.pre(); s.post();
|
||
const second = { ...question, question: 'D2 — Preserve the independent delivery policy?' };
|
||
s.emit('PreToolUse', 'question-2', { questions: [second] });
|
||
const result = s.read();
|
||
expect(result.calls.find(c => c.id === 'question-1')).toMatchObject({ result: 'answered', answerLabels: [question.options[0]!.label] });
|
||
expect(result.calls.filter(c => c.result === 'pending').map(c => c.id)).toEqual(['question-2']);
|
||
expect(result.permissionTools).toEqual([]); expect(result.permissionRequests).toEqual([]); expect(result.ready).toBe(false);
|
||
expect(fs.readFileSync(file, 'utf8')).not.toContain('question-1');
|
||
write(call('question-1'), { type: 'user', sessionId, toolUseResult: s.response,
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'question-1',
|
||
content: `Your questions have been answered: "${question.question}"="${question.options[0]!.label}". You can now continue with these answers in mind.` }] } });
|
||
expect(s.read().calls.filter(c => c.id === 'question-1')).toHaveLength(1);
|
||
expect(s.read().calls.find(c => c.id === 'question-1')?.result).toBe('answered');
|
||
});
|
||
|
||
test.each(['question', 'header', 'option-label', 'option-description', 'metadata'] as const)
|
||
('AUQ completion rejects changed execution input (%s)', field => {
|
||
const s = completedQuestionHook(); s.pre();
|
||
const changed: any = structuredClone(s.input);
|
||
if (field === 'metadata') changed.metadata = { source: 'different' };
|
||
else if (field === 'question' || field === 'header') changed.questions[0][field] += ' changed';
|
||
else changed.questions[0].options[0][field === 'option-label' ? 'label' : 'description'] += ' changed';
|
||
const answers = { [changed.questions[0].question]: changed.questions[0].options[0].label };
|
||
s.emit('PostToolUse', 'question-1', { ...changed, answers }, { tool_response: { questions: changed.questions, answers } });
|
||
expect(() => s.read()).toThrow('Native AskUserQuestion completion changed input');
|
||
});
|
||
|
||
test.each(['missing-answer', 'unoffered-answer', 'notes', 'freeform', 'follow-up', 'idle', 'wrong-result-question', 'failed'] as const)
|
||
('AUQ completion cannot turn an incomplete, amended or cancelled response into an ACK (%s)', variant => {
|
||
const s = completedQuestionHook(); s.pre();
|
||
const output: any = structuredClone(s.response);
|
||
if (variant === 'missing-answer') output.answers = {};
|
||
if (variant === 'unoffered-answer') output.answers[question.question] = 'A different action';
|
||
if (variant === 'notes') output.annotations = { [question.question]: { notes: 'Do not proceed' } };
|
||
if (variant === 'freeform') output.response = 'Cancel this review';
|
||
if (variant === 'follow-up') output.followUp = true;
|
||
if (variant === 'idle') output.afkTimeoutMs = 1;
|
||
if (variant === 'wrong-result-question') output.questions[0].header = 'Different';
|
||
s.post(output, variant === 'failed' ? { hook_event_name: 'PostToolUseFailure', error: 'cancelled' } : {});
|
||
expect(() => s.read()).toThrow('Native question event capture failed');
|
||
});
|
||
|
||
test.each(['foreign', 'sidechain', 'orphan'] as const)('AUQ completion needs its owned invocation (%s)', variant => {
|
||
const s = completedQuestionHook();
|
||
if (variant !== 'orphan') s.pre();
|
||
s.post(s.response, variant === 'foreign' ? { session_id: '00000000-0000-4000-8000-000000000099' }
|
||
: variant === 'sidechain' ? { agent_id: 'other-agent' } : {});
|
||
expect(s.read().calls.some(c => c.result === 'answered')).toBe(false);
|
||
});
|
||
|
||
test.each(['error', 'answer', 'cancelled-text', 'cancelled-text-no-raw'] as const)('AUQ completion rejects contradictory later native results (%s)', variant => {
|
||
const s = completedQuestionHook(); s.pre(); s.post();
|
||
expect(s.read().calls[0]?.result).toBe('answered');
|
||
const raw = variant === 'answer' ? { ...s.response, answers: { [question.question]: question.options[1]!.label } } : s.response;
|
||
write(call('question-1'), { type: 'user', sessionId, ...(variant !== 'cancelled-text-no-raw' ? { toolUseResult: raw } : {}),
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'question-1', is_error: variant === 'error',
|
||
content: variant.startsWith('cancelled') ? 'The user did not answer the questions.'
|
||
: `Your questions have been answered: "${question.question}"="${question.options[0]!.label}". You can now continue with these answers in mind.` }] } });
|
||
expect(() => s.read()).toThrow('Native AskUserQuestion completion conflicts with its later result');
|
||
});
|
||
|
||
test('AUQ completion repeats retain one immutable event and coalesce exact text without raw metadata', () => {
|
||
const s = completedQuestionHook(); s.pre(); s.post();
|
||
const first = s.read();
|
||
s.post();
|
||
expect(s.read()).toEqual(first);
|
||
expect(fs.readdirSync(path.join(s.source.directory, 'events'))).toHaveLength(2);
|
||
write(call('question-1'), { type: 'user', sessionId, message: { role: 'user', content: [{ type: 'tool_result',
|
||
tool_use_id: 'question-1', content: `Your questions have been answered: "${question.question}"="${question.options[0]!.label}". You can now continue with these answers in mind.` }] } });
|
||
expect(s.read().calls).toEqual(first.calls);
|
||
});
|
||
|
||
test.each(['duplicate-label', 'duplicate-question', 'multi-select', 'array-answer', 'wrong-cwd', 'input-annotation', 'reserved-notes-sentinel'] as const)
|
||
('AUQ completion refuses ambiguous or mismatched acknowledgment (%s)', variant => {
|
||
const s = completedQuestionHook();
|
||
const input: any = structuredClone(s.input);
|
||
if (variant === 'duplicate-label') input.questions[0].options[1].label = input.questions[0].options[0].label;
|
||
if (variant === 'duplicate-question') input.questions.push(structuredClone(input.questions[0]));
|
||
if (variant === 'multi-select') input.questions[0].multiSelect = true;
|
||
if (variant === 'reserved-notes-sentinel') input.questions[0].options[0].label = '(notes only)';
|
||
const answers = { [question.question]: variant === 'array-answer' ? [question.options[0]!.label] : input.questions[0].options[0].label };
|
||
s.emit('PreToolUse', 'question-1', input);
|
||
s.emit('PostToolUse', 'question-1', { ...input, answers,
|
||
...(variant === 'input-annotation' ? { annotations: { [question.question]: { notes: 'Cancel' } } } : {}) },
|
||
{ tool_response: { questions: input.questions, answers }, ...(variant === 'wrong-cwd' ? { cwd: path.dirname(config) } : {}) });
|
||
expect(() => s.read()).toThrow('Native question event capture failed');
|
||
});
|
||
|
||
function filePermissionRequest(input = { file_path: path.join(config, 'plan.md'), content: 'Final report' }) {
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PermissionRequest[0].hooks[0].command;
|
||
const result = Bun.spawnSync(['bash', '-c', command], { timeout: 5000, stdin: Buffer.from(JSON.stringify({
|
||
hook_event_name: 'PermissionRequest', session_id: sessionId, transcript_path: file, cwd: config,
|
||
tool_name: 'Write', tool_input: input,
|
||
})), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode, result.stderr.toString()).toBe(0);
|
||
expect(result.stdout.length).toBe(0);
|
||
const [event] = readPermissionRequestEvents(source, { configDir: config, sessionId, transcriptFile: file });
|
||
return { source, event: event!, input };
|
||
}
|
||
const nativeWrite = (id: string, input: unknown, cwd = config, name = 'Write', stop_reason: string | null = 'tool_use') => ({
|
||
type: 'assistant', sessionId, cwd, message: { role: 'assistant', stop_reason, content: [{ type: 'tool_use', id, name, input }] },
|
||
});
|
||
const nativeWriteResult = (id: string, timestamp?: string, is_error = false) => ({
|
||
type: 'user', sessionId, timestamp, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, is_error, content: 'Done' }] },
|
||
});
|
||
|
||
// Normalized execution input: preserve injected plan fields and every extra key.
|
||
const exitInput = () => ({ plan: '# Plan\n## GSTACK REVIEW REPORT\n日本語', planFilePath: path.join(config, 'plan.md'),
|
||
allowedPrompts: [{ tool: 'Bash', prompt: 'run tests' }], custom: { exact: true } });
|
||
|
||
test.each([null, 'tool_use'])('early ExitPlanMode coalesces exact %s native input without AUQ or file authority', stopReason => {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: 'Review' } });
|
||
const early = earlyQuestions(), input = exitInput();
|
||
early.emit('early-exit', input, 'ExitPlanMode');
|
||
const initial = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(initial.ready).toBe(true);
|
||
expect(initial.pendingExitPlanModeIds).toEqual(['early-exit']);
|
||
expect(initial.calls).toEqual([]); expect(initial.permissionTools).toEqual([]);
|
||
expect(initial.permissionRequests).toEqual([]); expect(initial.permissionResults).toEqual([]);
|
||
write(nativeWrite('early-exit', input, config, 'ExitPlanMode', stopReason));
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source)).toEqual(initial);
|
||
if (stopReason === null) expect(readPlanSkillQuestions(config, sessionId).ready).toBe(false);
|
||
});
|
||
|
||
test.each([false, true])('early ExitPlanMode retires on an owned result including preexisting/error=%s', isError => {
|
||
const early = earlyQuestions(), input = exitInput();
|
||
write(nativeWriteResult('early-exit', undefined, isError));
|
||
early.emit('early-exit', input, 'ExitPlanMode');
|
||
for (let repeat = 0; repeat < 2; repeat++) {
|
||
early.emit('early-exit', input, 'ExitPlanMode');
|
||
const state = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(state.ready).toBe(false); expect(state.pendingExitPlanModeIds).toEqual([]);
|
||
expect(state.calls).toEqual([]); expect(state.permissionResults).toEqual([]);
|
||
}
|
||
});
|
||
|
||
test.each(['foreign', 'sidechain', 'child', 'wrong-id'])('early ExitPlanMode ignores a %s native result', variant => {
|
||
const early = earlyQuestions(), input = exitInput();
|
||
const result: any = nativeWriteResult(variant === 'wrong-id' ? 'other-exit' : 'early-exit');
|
||
if (variant === 'foreign') result.sessionId = '00000000-0000-4000-8000-000000000002';
|
||
if (variant === 'sidechain') result.isSidechain = true;
|
||
if (variant === 'child') result.parent_tool_use_id = 'parent';
|
||
write(result); early.emit('early-exit', input, 'ExitPlanMode');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).ready).toBe(true);
|
||
fs.appendFileSync(file, JSON.stringify(nativeWriteResult('early-exit')) + '\n');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).ready).toBe(false);
|
||
});
|
||
|
||
test.each(['plan', 'path', 'extra', 'nested-schema-extra', 'cwd', 'AskUserQuestion', 'Write', 'Edit', 'Bash'])('early ExitPlanMode rejects unfinished same-ID %s conflicts', variant => {
|
||
write(); const early = earlyQuestions(), input = exitInput();
|
||
early.emit('early-exit', input, 'ExitPlanMode');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).ready).toBe(true);
|
||
const changed = structuredClone(input);
|
||
if (variant === 'plan') changed.plan += '\nChanged';
|
||
if (variant === 'path') changed.planFilePath += '.other';
|
||
if (variant === 'extra') changed.custom.exact = false;
|
||
// Deprecated nested keys stripped by CLI schema are conservatively rejected.
|
||
if (variant === 'nested-schema-extra') (changed.allowedPrompts[0] as any).extra = 'not in execution input';
|
||
const name = ['AskUserQuestion', 'Write', 'Edit', 'Bash'].includes(variant) ? variant : 'ExitPlanMode';
|
||
write(nativeWrite('early-exit', changed, variant === 'cwd' ? path.dirname(config) : config, name, null));
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('changed input');
|
||
});
|
||
|
||
test('early ExitPlanMode keeps missing transcript, partial bytes and owner changes observable', () => {
|
||
const early = earlyQuestions(), input = exitInput();
|
||
early.emit('exit-one', input, 'ExitPlanMode');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).ready).toBe(false);
|
||
write(); const first = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(first.ready).toBe(true);
|
||
fs.appendFileSync(file, '{"type":');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).pendingBytes).toBeGreaterThan(0);
|
||
write(nativeWriteResult('exit-one'));
|
||
early.emit('exit-two', input, 'ExitPlanMode');
|
||
const second = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(second.ready).toBe(true); expect(second.pendingExitPlanModeIds).toEqual(['exit-two']);
|
||
expect(second).not.toEqual(first);
|
||
});
|
||
|
||
test('permission request identity remains separate until an exact later native result completes it', () => {
|
||
write();
|
||
const { source, event, input } = filePermissionRequest();
|
||
const pending = readPlanSkillQuestions(config, sessionId, source);
|
||
expect(pending.permissionTools).toEqual([]);
|
||
expect(pending.permissionRequests).toEqual([{ requestId: event.requestId, capturedAtMs: event.capturedAtMs,
|
||
name: 'Write', cwd: config, input, result: 'pending' }]);
|
||
write(nativeWrite('real-write', input));
|
||
expect(readPlanSkillQuestions(config, sessionId, source).permissionRequests[0]).toMatchObject({ result: 'pending', nativeToolId: 'real-write' });
|
||
write(nativeWrite('real-write', input), nativeWriteResult('real-write', new Date(event.capturedAtMs + 1).toISOString()));
|
||
expect(readPlanSkillQuestions(config, sessionId, source).permissionRequests[0]).toMatchObject({ requestId: event.requestId, result: 'completed', nativeToolId: 'real-write' });
|
||
});
|
||
|
||
test.each(['old', 'equal', 'invalid', 'missing', 'foreign'])('a %s native result cannot acknowledge a newly captured file request', variant => {
|
||
write();
|
||
const { source, event, input } = filePermissionRequest();
|
||
const time = variant === 'missing' ? undefined : variant === 'invalid' ? 'not-a-date'
|
||
: new Date(event.capturedAtMs + (variant === 'old' ? -1 : variant === 'equal' ? 0 : 1)).toISOString();
|
||
const result = nativeWriteResult('old-write', time);
|
||
if (variant === 'foreign') result.sessionId = '00000000-0000-4000-8000-000000000002';
|
||
write(nativeWrite('old-write', input), result);
|
||
expect(readPlanSkillQuestions(config, sessionId, source).permissionRequests[0].result).toBe('pending');
|
||
if (variant !== 'foreign') expect(readPlanSkillQuestions(config, sessionId, source).permissionRequests[0].nativeToolId).toBeUndefined();
|
||
});
|
||
|
||
test('file request errors and ambiguous full-input matches never become successful completion', () => {
|
||
write();
|
||
const { source, event, input } = filePermissionRequest();
|
||
const timestamp = new Date(event.capturedAtMs + 1).toISOString();
|
||
write(nativeWrite('failed', input), nativeWriteResult('failed', timestamp, true));
|
||
expect(readPlanSkillQuestions(config, sessionId, source).permissionRequests[0]).toMatchObject({ result: 'error', nativeToolId: 'failed' });
|
||
write(nativeWrite('first', input), nativeWriteResult('first', timestamp), nativeWrite('second', input));
|
||
expect(() => readPlanSkillQuestions(config, sessionId, source)).toThrow('Indistinguishable');
|
||
});
|
||
|
||
test.each(['content', 'cwd', 'name', 'unfinished'])('late native file %s disagreement refuses the permission request', variant => {
|
||
write();
|
||
const { source, input } = filePermissionRequest();
|
||
write(nativeWrite('changed', variant === 'content' || variant === 'unfinished' ? { ...input, content: 'Changed after permission' } : input,
|
||
variant === 'cwd' ? path.dirname(config) : config, variant === 'name' ? 'Edit' : 'Write', variant === 'unfinished' ? null : 'tool_use'));
|
||
expect(() => readPlanSkillQuestions(config, sessionId, source)).toThrow('changed input');
|
||
});
|
||
|
||
test('a reused real native tool ID cannot change file input or become an AskUserQuestion', () => {
|
||
const input = { file_path: path.join(config, 'plan.md'), content: 'Initial' };
|
||
write(nativeWrite('same', input), nativeWrite('same', { ...input, content: 'Changed' }));
|
||
expect(() => readPlanSkillQuestions(config, sessionId)).toThrow('changed input');
|
||
write(nativeWrite('same', input), call('same'));
|
||
expect(() => readPlanSkillQuestions(config, sessionId)).toThrow('changed input');
|
||
});
|
||
|
||
test('a pre-transcript native question is pending until its exact owned result arrives', () => {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: 'Begin the review' } });
|
||
const early = earlyQuestions();
|
||
early.emit('early');
|
||
expect(readPlanSkillQuestions(config, sessionId).calls).toEqual([]);
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls.map(c => [c.id, c.result])).toEqual([['early', 'pending']]);
|
||
fs.appendFileSync(file, JSON.stringify({ type: 'user', sessionId: '00000000-0000-4000-8000-000000000002',
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'early' }] } }) + '\n');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls[0].result).toBe('pending');
|
||
fs.appendFileSync(file, JSON.stringify({ type: 'user', sessionId, message: { role: 'user',
|
||
content: [{ type: 'tool_result', tool_use_id: 'early', content: 'Answer accepted' }] } }) + '\n');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls[0].result).toBe('answered');
|
||
});
|
||
|
||
test('early and persisted invocations coalesce, and any changed input fails', () => {
|
||
write(call('same'));
|
||
const early = earlyQuestions();
|
||
early.emit('same');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls).toHaveLength(1);
|
||
write(call('same', [{ ...question, question: 'A different question?' }]));
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('changed input');
|
||
});
|
||
|
||
for (const stopReason of [null, 'tool_use']) {
|
||
test(`the hook's false multiSelect default matches an omitted transcript field (${stopReason})`, () => {
|
||
const { multiSelect, ...omittedDefault } = question;
|
||
const native = call('defaulted');
|
||
native.message.stop_reason = stopReason as any;
|
||
native.message.content[0].input.questions = [omittedDefault as NativeQuestion];
|
||
write(native);
|
||
const early = earlyQuestions();
|
||
early.emit('defaulted');
|
||
const pending = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(pending.calls).toEqual([{ id: 'defaulted', questions: [question], result: 'pending' }]);
|
||
fs.appendFileSync(file, JSON.stringify({ type: 'user', sessionId, message: { role: 'user',
|
||
content: [{ type: 'tool_result', tool_use_id: 'defaulted', content: 'Answer accepted' }] } }) + '\n');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls[0].result).toBe('answered');
|
||
});
|
||
}
|
||
|
||
test('a completed native invocation without an event uses the same false default', () => {
|
||
const { multiSelect, ...omittedDefault } = question;
|
||
write(call('native-default', [omittedDefault as NativeQuestion]));
|
||
expect(readPlanSkillQuestions(config, sessionId).calls).toEqual([
|
||
{ id: 'native-default', questions: [question], result: 'pending' },
|
||
]);
|
||
});
|
||
|
||
for (const multiSelect of [true, null, 'false']) {
|
||
test(`default equivalence still rejects changed multiSelect ${JSON.stringify(multiSelect)}`, () => {
|
||
write(call('changed-default', [{ ...question, multiSelect } as NativeQuestion]));
|
||
const early = earlyQuestions();
|
||
early.emit('changed-default');
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('changed input');
|
||
});
|
||
}
|
||
|
||
test('default equivalence preserves comparison of other input fields', () => {
|
||
const { multiSelect, ...omittedDefault } = question;
|
||
const native = call('other-input', [omittedDefault as NativeQuestion]);
|
||
(native.message.content[0].input as any).metadata = { changed: true };
|
||
write(native);
|
||
const early = earlyQuestions();
|
||
early.emit('other-input');
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('changed input');
|
||
});
|
||
|
||
test('early native input uses the existing validator and never supplies a successful result', () => {
|
||
write(call('valid'));
|
||
const early = earlyQuestions();
|
||
early.emit('invalid', { questions: [] });
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('Unsupported native');
|
||
});
|
||
|
||
test('an error result for an early question stays failed and partial transcript records stay visible', () => {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'early', is_error: true }] } });
|
||
const early = earlyQuestions();
|
||
early.emit('early');
|
||
fs.appendFileSync(file, '{"type":"user"');
|
||
const state = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(state.calls[0].result).toBe('error');
|
||
expect(state.pendingBytes).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('owned calls retain distinct IDs, all question tabs, and exact matching results', () => {
|
||
write(call('first', [question, { ...question, question: 'D2 — Which next step?' }]), call('second'),
|
||
{ type: 'user', sessionId, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'first', content: 'Answer accepted' }] } });
|
||
const state = readPlanSkillQuestions(config, sessionId);
|
||
expect(state.calls.map(c => [c.id, c.result, c.questions.length])).toEqual([['first', 'answered', 2], ['second', 'pending', 1]]);
|
||
});
|
||
test('foreign, sidechain, previews and unfinished calls are not native prompts', () => {
|
||
write({ ...call('foreign'), sessionId: '00000000-0000-4000-8000-000000000002' }, { ...call('side'), isSidechain: true },
|
||
{ ...call('unfinished'), message: { ...call('unfinished').message, stop_reason: null } },
|
||
{ type: 'assistant', sessionId, message: { role: 'assistant', stop_reason: 'tool_use', content: [{ type: 'tool_use', id: 'write', name: 'Write', input: { content: JSON.stringify(call('preview')) } }] } });
|
||
expect(readPlanSkillQuestions(config, sessionId).calls).toEqual([]);
|
||
fs.appendFileSync(file, '{"type":"assistant"');
|
||
expect(readPlanSkillQuestions(config, sessionId).pendingBytes).toBeGreaterThan(0);
|
||
});
|
||
test('errors and malformed native input never become successful acknowledgements', () => {
|
||
write(call('failed'), { type: 'user', sessionId, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'failed', is_error: true }] } });
|
||
expect(readPlanSkillQuestions(config, sessionId).calls[0].result).toBe('error');
|
||
write(call('bad', []));
|
||
expect(() => readPlanSkillQuestions(config, sessionId)).toThrow('Unsupported native');
|
||
expect(() => readPlanSkillQuestions(null, sessionId)).toThrow('owned hermetic');
|
||
});
|
||
test('overflow rejection identifies the native call and option count without disclosing content', () => {
|
||
const privateText = 'private-question-label-description';
|
||
const overflow = { ...question, question: privateText, header: privateText,
|
||
options: Array.from({ length: 5 }, () => ({ label: privateText, description: privateText })) };
|
||
write(call('overflow-call', [overflow]));
|
||
let message = '';
|
||
try { readPlanSkillQuestions(config, sessionId); } catch (error) { message = (error as Error).message; }
|
||
expect(message).toStartWith('Unsupported native AskUserQuestion input shape: toolId="overflow-call"');
|
||
const shape = JSON.parse(message.split(' shape=')[1]);
|
||
expect(shape).toMatchObject({ questionsType: 'array', questionCount: 1, questionsTruncated: false });
|
||
expect(shape.questions[0]).toMatchObject({ optionCount: 5, optionsTruncated: true,
|
||
questionType: 'string', questionNonempty: true, headerType: 'string', headerNonempty: true });
|
||
expect(shape.questions[0].options).toHaveLength(4);
|
||
expect(message).not.toContain(privateText);
|
||
|
||
write(call('long-id-'.repeat(100), Array.from({ length: 50 }, () => ({ ...overflow,
|
||
options: Array.from({ length: 50 }, () => overflow.options[0]) }))));
|
||
try { readPlanSkillQuestions(config, sessionId); } catch (error) { message = (error as Error).message; }
|
||
const bounded = JSON.parse(message.split(' shape=')[1]);
|
||
expect(bounded).toMatchObject({ questionCount: 50, questionsTruncated: true });
|
||
expect(bounded.questions).toHaveLength(4);
|
||
expect(bounded.questions.every((q: any) => q.optionCount === 50 && q.options.length === 4)).toBe(true);
|
||
expect(message).toContain(' (truncated) shape=');
|
||
expect(message.length).toBeLessThan(4_000);
|
||
expect(message).not.toContain(privateText);
|
||
});
|
||
test('missing required option fields remain rejected with types after native defaults apply', () => {
|
||
const { multiSelect, ...defaulted } = question;
|
||
const { description, ...missingDescription } = question.options[0];
|
||
write(call('missing-description', [{ ...defaulted,
|
||
options: [missingDescription, question.options[1]] } as NativeQuestion]));
|
||
let message = '';
|
||
try { readPlanSkillQuestions(config, sessionId); } catch (error) { message = (error as Error).message; }
|
||
expect(message).toStartWith('Unsupported native AskUserQuestion input shape: toolId="missing-description"');
|
||
const shape = JSON.parse(message.split(' shape=')[1]);
|
||
expect(shape.questions[0]).toMatchObject({ multiSelectType: 'boolean', optionCount: 2 });
|
||
expect(shape.questions[0].options[0]).toEqual({ type: 'object', labelType: 'string',
|
||
labelNonempty: true, descriptionType: 'undefined' });
|
||
expect(message).not.toContain(question.question);
|
||
expect(message).not.toContain(missingDescription.label);
|
||
});
|
||
test('a native box heading tolerates wrapped/repainted body and below-viewport choices', () => {
|
||
const visible = stripAnsi('☐ Approach\nD1 — Which\x1b[2Capproach?\nMak it reliable. Enforce the delivery policy.\n❯1.Extend dispatcher\n2.Queue fanout\n');
|
||
expect(matchesNativeQuestion(question, visible, parseNumberedOptions(visible))).toBe(true);
|
||
expect(readPlanSkillQuestions(config, sessionId).calls).toEqual([]);
|
||
});
|
||
test('a stale menu with identical options cannot match a new question or nearby report preview', () => {
|
||
const wrong = 'D1 — Which approach?\n☐ Approach\nD2 — Which next step?\n❯1.Extend dispatcher\n2.Queue fanout\n';
|
||
expect(matchesNativeQuestion(question, wrong, parseNumberedOptions(wrong))).toBe(false);
|
||
const differentOptions = '☐ Approach\nD1 — Which approach?\n❯1.Delete data\n2.Keep data\n';
|
||
expect(matchesNativeQuestion(question, differentOptions, parseNumberedOptions(differentOptions))).toBe(false);
|
||
const sibling = { ...question, question: 'D1 — Which approach?\nA different delivery policy.' };
|
||
const sameHeading = '☐ Approach\n' + sibling.question + '\n❯1.Extend dispatcher\n2.Queue fanout\n';
|
||
expect(matchesNativeQuestion(question, sameHeading, parseNumberedOptions(sameHeading), [sibling])).toBe(false);
|
||
});
|
||
test('a final submit needs the native confirmation text, not an ordinary option or report', () => {
|
||
expect(isNativeQuestionSubmitVisible('Review your answers\nReady to submit your answers?\nSubmit answers')).toBe(true);
|
||
expect(isNativeQuestionSubmitVisible('Submit answers in the report')).toBe(false);
|
||
expect(isNativeQuestionSubmitVisible('Ready to submit your answers?\nYou have not answered all questions\nSubmit answers')).toBe(false);
|
||
});
|
||
test('permission binding rejects command prefixes and matching paths in another tool kind', () => {
|
||
const bash = { id: 'new', name: 'Bash', input: { command: 'git status' } };
|
||
expect(() => nativePermissionKey(bash, 'Bash command git status --porcelain requires permission')).toThrow('cannot be bound');
|
||
expect(nativePermissionKey(bash, 'Bash command git status requires permission')).toBe('Bash:git status');
|
||
expect(nativePermissionKey(bash, 'Bash command `git status` requires permission to run.')).toBe('Bash:git status');
|
||
expect(nativePermissionKey({ id: 'edit', name: 'Edit', input: { file_path: '~/.gstack/config.yaml' } }, 'Edit to ~/.gstack/config.yaml\nDo you want to proceed?\n❯1.Yes\n2.Yes, allow all edits')).toBe('Edit:~/.gstack/config.yaml');
|
||
expect(() => nativePermissionKey({ id: 'read', name: 'Read', input: { file_path: '/project/README.md' } }, 'Bash command cat /project/README.md requires permission')).toThrow('cannot be bound');
|
||
});
|
||
|
||
// Pinned CLI 2.1.263 $At/gs/jAt: the command and description are separate
|
||
// rows, followed by the one-time Yes and optional standing permission rows.
|
||
// Unlike the old fixture, the native card has no "requires permission" text.
|
||
const nativeBashDialog = (command: string, description: string) =>
|
||
'─'.repeat(120) + '\n Bash command\n\n ' + command + '\n ' + description
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
|
||
test('native Bash reason can contain a literal numeric-range token without supplying authority', () => {
|
||
const input = { command: 'printf ready', description: 'Print the owned marker' };
|
||
const tool = { id: 'numeric-reason', name: 'Bash', input };
|
||
// Exact native reason retained from CLI 2.1.263. Its embedded > is text,
|
||
// distinct from a new terminal prompt, quoted card or numbered option.
|
||
const reason = ' Contains zsh <N-M> numeric-range glob';
|
||
const frame = nativeBashDialog(input.command, input.description)
|
||
.replace('\n Do you want to proceed?', '\n' + reason + '\n\n Do you want to proceed?');
|
||
expect(isPermissionDialogVisible(frame)).toBe(true);
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + input.command);
|
||
expect(() => nativePermissionKey(tool, frame.replace(' printf ready', ' printf other'))).toThrow('cannot be bound');
|
||
for (const changed of [' > Quoted permission', ' ❯ New prompt', ' 1. Yes']) {
|
||
expect(isPermissionDialogVisible(frame.replace(reason, changed))).toBe(false);
|
||
}
|
||
});
|
||
|
||
test('native Bash card accepts a wrapped standing-permission label without granting it', () => {
|
||
// CLI 2.1.263 places a long prefix wholly below this label. The selected
|
||
// first option is still the one-time Yes, bound to the command above.
|
||
const input = { command: 'printf %s ready > probe.txt', description: 'Write the owned marker' };
|
||
write({ type: 'assistant', sessionId, cwd: config, message: { role: 'assistant', stop_reason: 'tool_use',
|
||
content: [{ type: 'tool_use', id: 'bash-wrapped', name: 'Bash', input }] } });
|
||
const visible = nativeBashDialog(input.command, input.description).replace(' 2. No',
|
||
' 2. Yes, and don’t ask again for:\n printf %s ready > probe.txt\n 3. No');
|
||
const native = readPlanSkillQuestions(config, sessionId);
|
||
expect(isPermissionDialogVisible(visible)).toBe(true);
|
||
expect(nativePermissionKey(native.permissionTools[0]!, visible)).toBe('Bash:' + input.command);
|
||
const granted = new Set<string>();
|
||
const requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, visible, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, visible, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual(['bash-wrapped']);
|
||
// An empty standing permission, missing cancellation, incorrect focus, and
|
||
// a command present only in the alternate option remain insufficient.
|
||
for (const changed of [
|
||
visible.replace('\n printf %s ready > probe.txt', ''),
|
||
visible.replace('\n printf %s ready > probe.txt', '\n printf %s ready > probe.txt'),
|
||
visible.replace(' 3. No\n', ''),
|
||
visible.replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. Yes', ' ❯ 2. Yes'),
|
||
]) expect(isPermissionDialogVisible(changed)).toBe(false);
|
||
expect(() => nativePermissionKey(native.permissionTools[0]!,
|
||
visible.replace(' ' + input.command, ' false'))).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('native Bash card binds the complete command and grants only its owned invocation', () => {
|
||
const input = { command: 'printf %s ready > probe.txt', description: 'Write the owned marker' };
|
||
write({ type: 'assistant', sessionId, cwd: config, message: { role: 'assistant', stop_reason: 'tool_use',
|
||
content: [{ type: 'tool_use', id: 'bash-current', name: 'Bash', input }] } });
|
||
const visible = nativeBashDialog(input.command, input.description);
|
||
const native = readPlanSkillQuestions(config, sessionId);
|
||
expect(isPermissionDialogVisible(visible)).toBe(true);
|
||
expect(nativePermissionKey(native.permissionTools[0]!, visible)).toBe('Bash:' + input.command);
|
||
const granted = new Set<string>();
|
||
const requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, visible, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, visible, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual(['bash-current']);
|
||
});
|
||
|
||
// Exact ordinary 120x40 frame and owned Bash input from the parent-owned
|
||
// no-grant CLI2.1.263 probe. Default-mode rendering proves geometry only.
|
||
const retainedNativeBashFrame = "\n\n● Writing four lines of synthetic fixture text to synthetic-output.txt\n ⎿ $ printf '%s\\n' '0-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-fr\n ame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-s\n ynthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthet…\n\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Bash command\n Tip: auto mode handles these prompts for you — choose \"switch to auto mode\" below\n\n │ printf '%s\\n' '0-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-f\n │ rame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-da\n │ ta-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synt\n │ hetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >\n │ '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\n │ printf '%s\\n' '1-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-f\n │ rame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-da\n │ ta-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synt\n │ hetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >>\n │ '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\n │ printf '%s\\n' '2-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-f\n │ rame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-da\n │ ta-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synt\n │ hetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >>\n │ '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\n │ printf '%s\\n' '3-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-f\n │ rame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-da\n │ ta-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synt\n │ hetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >>\n │ '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\n Write four lines of synthetic fixture text to synthetic-output.txt\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. Yes, and don’t ask again for: printf * \n 3. Yes, and switch to auto mode · auto mode handles these prompts for you\n 4. No\n\n Esc to cancel · Tab to amend";
|
||
const retainedNativeBashTool = {"id": "toolu_01Gh2jM599F7fYNNV7TKf82Q", "name": "Bash", "input": {"command": "printf '%s\\n' '0-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-' > '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\nprintf '%s\\n' '1-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >> '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\nprintf '%s\\n' '2-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >> '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'\nprintf '%s\\n' '3-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-synthetic-frame-data-' >> '/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace/synthetic-output.txt'", "description": "Write four lines of synthetic fixture text to synthetic-output.txt"}, "cwd": "/tmp/gstack-bash-render-probe-v3-11jt8g7b/workspace"};
|
||
|
||
test('retained native Bash soft-wrap projection binds all four logical command lines', () => {
|
||
expect(isPermissionDialogVisible(retainedNativeBashFrame)).toBe(true);
|
||
expect(nativePermissionKey(retainedNativeBashTool, retainedNativeBashFrame)).toBe('Bash:' + retainedNativeBashTool.input.command);
|
||
const different = { ...retainedNativeBashTool, input: { ...retainedNativeBashTool.input,
|
||
command: retainedNativeBashTool.input.command.replace('\n', ' ') } };
|
||
expect(() => nativePermissionKey(different, retainedNativeBashFrame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(retainedNativeBashTool,
|
||
retainedNativeBashFrame.replace(' │ printf', ' │ rm -rf'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(retainedNativeBashTool,
|
||
retainedNativeBashFrame.replace(' │ rame-data-', ' │ rame-…data-'))).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('native Bash command bytes and description remain separate authority', () => {
|
||
const tool = { id: 'bash', name: 'Bash', input: { command: 'printf "a b"', description: 'Write two spaces' } };
|
||
const frame = nativeBashDialog(tool.input.command, tool.input.description);
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:printf "a b"');
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...tool.input, command: 'printf "a b"' } }, frame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(tool, nativeBashDialog('false', tool.input.command))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(tool, nativeBashDialog('false', 'Bash command printf "a b" requires permission'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...tool, input: { command: 'printf\t"a b"', description: tool.input.description } }, frame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...tool, input: { command: 'printf cafe\u0301', description: 'Print text' } }, nativeBashDialog('printf cafe\u0301', 'Print text'))).toThrow('cannot be bound');
|
||
expect(nativePermissionKey({ id: 'bash', name: 'Bash', input: { command: 'true' } }, nativeBashDialog('true', 'Run shell command'))).toBe('Bash:true');
|
||
});
|
||
|
||
test('native Bash command and description have independent gutters at the actual card width', () => {
|
||
const command = 'printf "' + 'x'.repeat(110) + '"';
|
||
const description = 'd'.repeat(81);
|
||
const frame = nativeBashDialog(command, description).replace('─'.repeat(120), '─'.repeat(240))
|
||
.replace(' ' + command, ' │ ' + command).replace(' ' + description, ' │ ' + description);
|
||
const tool = { id: 'wide', name: 'Bash', input: { command, description } };
|
||
expect(isPermissionDialogVisible(frame)).toBe(true);
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + command);
|
||
expect(() => nativePermissionKey(tool, frame.replace('─'.repeat(240), '─'.repeat(120)))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(tool, frame.replace(' │ ' + description, ' ' + description))).toThrow('cannot be bound');
|
||
const multiline = { id: 'description-lines', name: 'Bash', input: { command: 'true', description: 'First line\nSecond line' } };
|
||
const multilineFrame = nativeBashDialog('true', 'First line').replace(' First line', ' │ First line\n │ Second line');
|
||
expect(nativePermissionKey(multiline, multilineFrame)).toBe('Bash:true');
|
||
});
|
||
|
||
test.each([
|
||
['quoted', (frame: string) => '```\n' + frame],
|
||
['history', (frame: string) => frame + '\n❯ current typed draft'],
|
||
['missing top rule', (frame: string) => frame.slice(frame.indexOf('\n') + 1)],
|
||
['repeated title', (frame: string) => frame.replace(' Bash command\n', ' Bash command\n Bash command\n')],
|
||
['wrong focus', (frame: string) => frame.replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. No', ' ❯ 2. No')],
|
||
['persistent first choice', (frame: string) => frame.replace(' ❯ 1. Yes', ' ❯ 1. Yes, and allow all commands')],
|
||
['missing footer', (frame: string) => frame.slice(0, frame.lastIndexOf(' Esc to cancel'))],
|
||
] as const)('native Bash card refuses %s without legacy fallback', (_name, change) => {
|
||
const frame = change(nativeBashDialog('true', 'Run shell command'));
|
||
expect(isPermissionDialogVisible(frame)).toBe(false);
|
||
expect(() => nativePermissionKey({ id: 'bash', name: 'Bash', input: { command: 'true' } }, frame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
const createDialog = (target: string) => `Do you want to create ${target}?\n❯1.Yes\n2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)\n3.No\nEsc to cancel · Tab to amend`;
|
||
|
||
// Pinned Claude 2.1.263: Mo uses basename in its question, Se uses the
|
||
// cwd-relative subtitle, and gs/Gz render title then subtitle above the diff.
|
||
const nestedFileDialog = (operation: 'create' | 'edit' | 'overwrite', subtitle: string, basename = path.basename(subtitle)) =>
|
||
'─'.repeat(120) + '\n ' + ({ create: 'Create', edit: 'Edit', overwrite: 'Overwrite' }[operation]) + ' file\n ' + subtitle +
|
||
'\n' + '╌'.repeat(120) + '\n 1 Plan content\n' + '╌'.repeat(120) + '\n ' +
|
||
createDialog(basename).replace('create', operation === 'edit' ? 'make this edit to' : operation);
|
||
|
||
// Pinned MEt/Z0o use this exact standing row for every non-read operation.
|
||
const settingsFileDialog = (operation: 'create' | 'edit' | 'overwrite' = 'edit') => nestedFileDialog(operation, '.claude/plans/plan.md').replace(
|
||
'Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session (shift+tab)',
|
||
'Yes, and allow Claude to edit its own settings for this session');
|
||
|
||
test.each(['create', 'edit', 'overwrite'] as const)('settings file controls bind only their exact owned path and reserve once (%s)', operation => {
|
||
const frame = settingsFileDialog(operation);
|
||
const name = operation === 'edit' ? 'Edit' : 'Write';
|
||
const owner = { id: 'settings-file', name, cwd: config,
|
||
input: { file_path: path.join(config, '.claude/plans/plan.md'), ...(name === 'Edit'
|
||
? { old_string: 'Draft', new_string: 'Reviewed' } : { content: 'Reviewed' }) } };
|
||
expect(isPermissionDialogVisible(frame)).toBe(true);
|
||
expect(currentFilePermissionTarget(frame)).toEqual({ operation, filePath: '.claude/plans/plan.md' });
|
||
expect(nativePermissionKey(owner, frame)).toBe(name + ':' + owner.input.file_path);
|
||
const request = { requestId: 'settings-request', nativeToolId: owner.id, name,
|
||
cwd: config, input: owner.input, capturedAtMs: 1, result: 'pending' as const };
|
||
const native = { permissionTools: [owner], permissionResults: [], permissionRequests: [request], permissionRequestCapture: true };
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, frame, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, frame, granted, requests)).toBe(false);
|
||
expect([...requests.values()]).toEqual([{ requestId: request.requestId, operation }]);
|
||
expect(() => nativePermissionKey({ ...owner, input: { ...owner.input, file_path: owner.input.file_path + '.other' } }, frame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, name: name === 'Edit' ? 'Write' : 'Edit' }, frame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test.each(['different-label', 'persistent-focus', 'clipped-path', 'wrong-title'])('settings file controls refuse %s', variant => {
|
||
let frame = settingsFileDialog();
|
||
if (variant === 'different-label') frame = frame.replace('for this session', 'forever');
|
||
if (variant === 'persistent-focus') frame = frame.replace('❯1.Yes', '1.Yes').replace('2. Yes', '❯2. Yes');
|
||
if (variant === 'clipped-path') frame = frame.replace('.claude/plans/plan.md', '.claude/…/plan.md');
|
||
if (variant === 'wrong-title') frame = frame.replace('Edit file', 'Create file');
|
||
expect(currentFilePermissionTarget(frame)).toBeNull();
|
||
expect(isPermissionDialogVisible(frame)).toBe(false);
|
||
});
|
||
|
||
test('captured settings overwrite is recognizable but its clipped basename cannot grant the owned nested Write', () => {
|
||
const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'autoplan-settings-overwrite.json'), 'utf8'));
|
||
const frame = captured.frame.text;
|
||
expect(isPermissionDialogVisible(frame)).toBe(true);
|
||
expect(currentFilePermissionTarget(frame)).toEqual({ operation: 'overwrite', filePath: 'autoplan-password-visibility.md' });
|
||
expect(() => nativePermissionKey(captured.pendingRequest, frame)).toThrow('cannot be bound');
|
||
// Paths mentioned in the diff cannot supply the missing current header.
|
||
expect(() => nativePermissionKey(captured.pendingRequest, captured.pendingRequest.input.file_path + '\n' + frame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('old Bash history cannot block an independently bound current file card', () => {
|
||
const filePath = path.join(config, 'plan.md');
|
||
const frame = retainedNativeBashFrame + '\n' + nestedFileDialog('create', 'plan.md');
|
||
const owner = { id: 'file-after-bash', name: 'Write', cwd: config, input: { file_path: filePath } };
|
||
expect(isPermissionDialogVisible(frame)).toBe(true);
|
||
expect(currentFilePermissionTarget(frame)).toEqual({ operation: 'create', filePath: 'plan.md' });
|
||
expect(nativePermissionKey(owner, frame)).toBe('Write:' + filePath);
|
||
expect(() => nativePermissionKey(retainedNativeBashTool, frame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('old file controls cannot authorize a malformed or differently focused current Bash card', () => {
|
||
const filePath = path.join(config, 'plan.md');
|
||
const fileOwner = { id: 'old-file', name: 'Write', cwd: config, input: { file_path: filePath } };
|
||
for (const bash of [nativeBashDialog('true', 'Run shell command').replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. No', ' ❯ 2. No'),
|
||
nativeBashDialog('true', 'Run shell command').replace(' 2. No', ' 2. N')]) {
|
||
const frame = nestedFileDialog('create', 'plan.md') + '\n' + bash;
|
||
expect(isPermissionDialogVisible(frame)).toBe(false);
|
||
expect(() => nativePermissionKey(fileOwner, frame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ id: 'current-bash', name: 'Bash', input: { command: 'true' } }, frame)).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
// Exact option-2 wrapping from the owned Claude 2.1.263 fake-Write capture.
|
||
// It advertises a directory grant; the driver still reserves only option 1.
|
||
const directoryFileDialog = (operation: 'create' | 'edit' | 'overwrite', subtitle: string, directory: string) =>
|
||
nestedFileDialog(operation, subtitle).replace('for this session (shift+tab)',
|
||
`for this session; Yes, and\n always allow access to\n ${directory}\n for this session (shift+tab)`);
|
||
|
||
test.each(['create', 'edit', 'overwrite'] as const)('extended %s menu binds the complete header and exact owned parent directory', operation => {
|
||
const filePath = path.join(path.dirname(config), 'private state', 'ceo-plans', 'plan.md');
|
||
const owner = { id: 'file', name: operation === 'edit' ? 'Edit' : 'Write', cwd: config, input: { file_path: filePath } };
|
||
for (const subtitle of [path.relative(config, filePath), filePath]) {
|
||
const dialog = directoryFileDialog(operation, subtitle, path.dirname(filePath));
|
||
expect(currentFilePermissionTarget(dialog)).toEqual({ operation, filePath: subtitle });
|
||
expect(nativePermissionKey(owner, dialog)).toBe(`${owner.name}:${filePath}`);
|
||
expect(nativePermissionKey(owner, dialog.replace(/\n /g, ' '))).toBe(`${owner.name}:${filePath}`);
|
||
expect(nativePermissionKey(owner, dialog.slice(dialog.indexOf('╌')))).toBe(`${owner.name}:${filePath}`);
|
||
}
|
||
});
|
||
|
||
// Full current-screen capture from the one-Write 120-column CLI diagnostic.
|
||
// The selected option is 1; the compound option 2 is never granted here.
|
||
test('retained complete outside-directory menu binds its exact native Write', () => {
|
||
const frame = "\n ▐▛███▛█ Claude Code v2.1.263\n▝▜██████▀ Fable 5.1 · API Usage Billing\n ▝▝ ▝▝ /tmp/gstack-hermetic-3734902-j94Svq/gstack-e2e-plan-ceo-paired-KdquY1\n\n\n❯ Use the Write tool exactly once to create \"/tmp/gstack-hermetic-3734902-j94Svq/permission-state-ZmakWx/projects/gstac\n k-e2e-plan-ceo-paired-KdquY1/ceo-plans/2026-09-09-payment-test-coverage.md\" with exactly this content: \"# Fake CEO\n plan\\nDiagnostic marker: 6ef6a97d-8eb6-4cae-87df-357ca47565ad\\nNo real project data.\\n\". This is a private\n permission-dialog diagnostic. Do not use Bash, run a review, or create any other file.\n\n● Creating the diagnostic file now.\n\n● Write(/tmp/gstack-hermetic-3734902-j94Svq/permission-state-ZmakWx/projects/gstac…/2026-09-09-payment-test-coverage.md)\n\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Create file\n ../permission-state-ZmakWx/projects/gstack-e2e-plan-ceo-paired-KdquY1/ceo-plans/2026-09-09-payment-test-coverage.md\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n 1 # Fake CEO plan\n 2 Diagnostic marker: 6ef6a97d-8eb6-4cae-87df-357ca47565ad\n 3 No real project data.\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to create 2026-09-09-payment-test-coverage.md?\n ❯ 1. Yes\n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and\n always allow access to\n /tmp/gstack-hermetic-3734902-j94Svq/permission-state-ZmakWx/projects/gstack-e2e-plan-ceo-paired-KdquY1/ceo-plans\n for this session (shift+tab)\n 3. No\n\n Esc to cancel · Tab to amend\n \n\n\n \n\n\n\n";
|
||
const tool = {
|
||
"id": "toolu_015EevmuskBCGKqz88djcKnM",
|
||
"name": "Write",
|
||
"input": {
|
||
"file_path": "/tmp/gstack-hermetic-3734902-j94Svq/permission-state-ZmakWx/projects/gstack-e2e-plan-ceo-paired-KdquY1/ceo-plans/2026-09-09-payment-test-coverage.md",
|
||
"content": "# Fake CEO plan\nDiagnostic marker: 6ef6a97d-8eb6-4cae-87df-357ca47565ad\nNo real project data.\n"
|
||
},
|
||
"cwd": "/tmp/gstack-hermetic-3734902-j94Svq/gstack-e2e-plan-ceo-paired-KdquY1"
|
||
};
|
||
expect(currentFilePermissionTarget(frame)).toEqual({ operation: 'create',
|
||
filePath: path.relative(tool.cwd, tool.input.file_path) });
|
||
expect(nativePermissionKey(tool, frame)).toBe('Write:' + tool.input.file_path);
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...tool.input, file_path: tool.input.file_path + '.other' } }, frame)).toThrow();
|
||
expect(() => nativePermissionKey(tool, frame.replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. Yes,', ' ❯ 2. Yes,'))).toThrow();
|
||
});
|
||
|
||
test('extended menu refuses mismatched, clipped, wrapped, relative or malformed directory identity', () => {
|
||
const filePath = path.join(path.dirname(config), 'private state', 'ceo-plans', 'plan.md');
|
||
const directory = path.dirname(filePath);
|
||
const owner = { id: 'file', name: 'Write', cwd: config, input: { file_path: filePath } };
|
||
const dialog = directoryFileDialog('create', path.relative(config, filePath), directory);
|
||
for (const invalid of [
|
||
directoryFileDialog('create', path.relative(config, filePath), path.dirname(directory)),
|
||
directoryFileDialog('create', path.relative(config, filePath), directory + '-other'),
|
||
directoryFileDialog('create', path.relative(config, filePath), path.relative(config, directory)),
|
||
directoryFileDialog('create', path.relative(config, filePath), directory.replace('private state', 'privatestate')),
|
||
dialog.replace(directory, directory.replace('ceo-plans', 'ceo-\n plans')),
|
||
dialog.replace(directory, directory.replace('ceo-plans', '…/ceo-plans')),
|
||
dialog.replace(directory, directory + '\t'),
|
||
dialog.replace('always allow access to', 'always allow access everywhere including'),
|
||
dialog.replace('3.No', '3.Yes\n4.No'),
|
||
dialog.replace(' Do you want to create plan.md?', ' Do you want to create other.md?'),
|
||
dialog.slice(dialog.indexOf('╌')).replace(directory, ''),
|
||
]) expect(() => nativePermissionKey(owner, invalid)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, name: 'Edit' }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, cwd: undefined }, dialog)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('extended menu reserves the hook-owned Write once and keeps multiple writable owners ambiguous', () => {
|
||
const input = { file_path: path.join(path.dirname(config), 'private-state', 'ceo-plans', 'plan.md'), content: 'Fake plan' };
|
||
write(nativeWrite('owned-write', input));
|
||
const { source, event } = filePermissionRequest(input);
|
||
const native = readPlanSkillQuestions(config, sessionId, source);
|
||
const dialog = directoryFileDialog('create', path.relative(config, input.file_path), path.dirname(input.file_path));
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, dialog, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual([`request:${event.requestId}`]);
|
||
expect([...requests.keys()]).toEqual([`Write:${input.file_path}`]);
|
||
native.permissionTools.push({ id: 'other-read', name: 'Read', cwd: config, input: { file_path: path.join(config, 'README.md') } });
|
||
expect(reserveNativePermissionGrant(native, dialog, new Set(), new Map())).toBe(true);
|
||
native.permissionRequests.push({ ...native.permissionRequests[0]!, requestId: 'other-write', nativeToolId: undefined });
|
||
const refused = new Set<string>();
|
||
expect(() => reserveNativePermissionGrant(native, dialog, refused, new Map())).toThrow('Ambiguous');
|
||
expect(refused.size).toBe(0);
|
||
});
|
||
|
||
// Autoplan can queue the restore Write, plan Edit, a Read and Bash before
|
||
// the first permission finishes. The current file card owns only its exact path.
|
||
test.each(['Edit', 'Write'] as const)('current file grant distinguishes a pending %s to another file', name => {
|
||
const relative = '.gstack/projects/fixture/restore.md';
|
||
const input = { file_path: path.join(config, relative), content: 'Restore point' };
|
||
const other = name === 'Edit'
|
||
? { file_path: path.join(config, '.claude/plans/plan.md'), old_string: '# Plan', new_string: '# Updated plan' }
|
||
: { file_path: path.join(config, 'other/restore.md'), content: 'Other restore point' };
|
||
write(nativeWrite('restore', input), nativeWrite('plan', other, config, name),
|
||
nativeWrite('read', { file_path: path.join(config, 'review-sections.md') }, config, 'Read'),
|
||
nativeWrite('bash', { command: 'true' }, config, 'Bash'));
|
||
const { source, event } = filePermissionRequest(input);
|
||
const native = readPlanSkillQuestions(config, sessionId, source);
|
||
expect(native.permissionRequests).toHaveLength(1);
|
||
expect(native.permissionTools).toHaveLength(4);
|
||
const dialog = nestedFileDialog('create', relative);
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, dialog, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual([`request:${event.requestId}`]);
|
||
expect([...requests.keys()]).toEqual([`Write:${input.file_path}`]);
|
||
|
||
// A second captured permission remains pending, regardless of request order.
|
||
const next = { ...native.permissionRequests[0]!, requestId: 'other-request',
|
||
nativeToolId: 'plan', name, input: other };
|
||
native.permissionRequests.unshift(next);
|
||
expect(reserveNativePermissionGrant(native, dialog, new Set(), new Map())).toBe(true);
|
||
expect(native.permissionRequests[0]).toEqual(next);
|
||
});
|
||
|
||
test.each(['relative-path', 'missing-path'] as const)('parallel writable owner refuses %s without recording a grant', variant => {
|
||
const input = { file_path: path.join(config, 'restore.md'), content: 'Restore point' };
|
||
write(nativeWrite('restore', input));
|
||
const { source } = filePermissionRequest(input);
|
||
const native = readPlanSkillQuestions(config, sessionId, source);
|
||
native.permissionTools.push({ id: 'other', name: 'Edit', cwd: config,
|
||
input: variant === 'relative-path' ? { file_path: 'other.md' } : {} });
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(() => reserveNativePermissionGrant(native, nestedFileDialog('create', 'restore.md'), granted, requests)).toThrow();
|
||
expect(granted.size).toBe(0); expect(requests.size).toBe(0);
|
||
});
|
||
|
||
test.each(['create', 'edit', 'overwrite'] as const)('current %s title and subtitle bind a nested basename to its owned file', operation => {
|
||
const relative = path.join('.gstack', 'projects', 'fixture', 'restore.md');
|
||
const filePath = path.join(config, relative);
|
||
const owner = { id: 'file', name: operation === 'edit' ? 'Edit' : 'Write', cwd: config, input: { file_path: filePath } };
|
||
const dialog = nestedFileDialog(operation, relative);
|
||
expect(currentFilePermissionTarget(dialog)).toEqual({ operation, filePath: relative });
|
||
expect(nativePermissionKey(owner, dialog)).toBe(`${owner.name}:${filePath}`);
|
||
expect(nativePermissionKey(owner, dialog.replace('Plan content', 'Example ❯ 1. text'))).toBe(`${owner.name}:${filePath}`);
|
||
expect(() => nativePermissionKey({ ...owner, input: { file_path: path.join(config, 'other', 'restore.md') } }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, cwd: undefined }, dialog)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('nested file binding refuses clipped, conflicting, quoted or ambiguous header evidence', () => {
|
||
const relative = '.gstack/projects/fixture/restore.md';
|
||
const owner = { id: 'file', name: 'Write', cwd: config, input: { file_path: path.join(config, relative) } };
|
||
const dialog = nestedFileDialog('create', relative);
|
||
for (const invalid of [
|
||
createDialog('restore.md'), // basename alone still resolves only at cwd
|
||
dialog.slice(dialog.indexOf('╌')),
|
||
dialog.replace(relative, '…/fixture/restore.md'),
|
||
dialog.replace(relative, '.gstack/projects/other/restore.md'),
|
||
dialog.replace(relative, '.gstack/projects/fixture/other.md'),
|
||
dialog.replace('Create file', 'Edit file'),
|
||
dialog.replace(' Create file', ' 1 Create file'),
|
||
dialog.replace(' Create file', '> Create file'),
|
||
dialog.replace('─'.repeat(120), 'quoted header'),
|
||
nestedFileDialog('create', relative) + '\n' + createDialog('restore.md'),
|
||
dialog.replace('\n ' + relative, '\n ' + relative + '\n Create file\n ' + relative),
|
||
]) expect(() => nativePermissionKey(owner, invalid)).toThrow('cannot be bound');
|
||
});
|
||
|
||
// A current terminal viewport can start at the title, with its leading rule
|
||
// scrolled away; the full rule below its subtitle still bounds the same card.
|
||
// Native display padding is not part of the owned filesystem path.
|
||
function renderedPermissionCard(variant: string, subtitle = 'plan.md') {
|
||
let frame = nestedFileDialog('edit', subtitle);
|
||
if (variant !== 'padding') frame = frame.slice(frame.indexOf('\n') + 1);
|
||
if (variant !== 'clipped-rule') frame = frame.replace('\n ' + subtitle + '\n', '\n ' + subtitle + ' \n');
|
||
return frame;
|
||
}
|
||
|
||
test.each(['clipped-rule', 'padding', 'both'])('current permission card supports native %s with one exact owned grant', variant => {
|
||
const s = earlyFileCompletion(); s.complete(); s.emit('PermissionRequest', s.nextInput);
|
||
const frame = renderedPermissionCard(variant), native = s.read();
|
||
expect(currentFilePermissionTarget(frame)).toEqual({ operation: 'edit', filePath: 'plan.md' });
|
||
expect(reserveNativePermissionGrant(native, frame, s.granted, s.requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, frame, s.granted, s.requests)).toBe(false);
|
||
expect(s.granted.size).toBe(2);
|
||
});
|
||
|
||
test.each(['clipped-rule', 'padding', 'both'])('native %s rendering keeps exact path spaces and rejects another owned filename', variant => {
|
||
const relative = 'notes/approved plan.md', filePath = path.join(config, relative);
|
||
const frame = renderedPermissionCard(variant, relative);
|
||
const owner = { id: 'edit', name: 'Edit', cwd: config, input: { file_path: filePath } };
|
||
expect(nativePermissionKey(owner, frame)).toBe('Edit:' + filePath);
|
||
for (const wrong of ['notes/approved plan.md', relative + ' ', 'other/approved plan.md']) {
|
||
expect(() => nativePermissionKey({ ...owner, input: { file_path: path.join(config, wrong) } }, frame)).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test.each(['quoted-prelude', 'blank-prelude', 'missing-bottom-rule', 'broken-bottom-rule', 'wrapped-subtitle', 'tab-padding',
|
||
'unicode-padding', 'leading-padding', 'duplicate-header', 'intervening-menu', 'wider-than-rule', 'wrong-operation'])
|
||
('current permission card still refuses %s', variant => {
|
||
let frame = renderedPermissionCard('both', 'notes/plan.md');
|
||
if (variant === 'quoted-prelude') frame = 'Example:\n' + frame;
|
||
if (variant === 'blank-prelude') frame = '\n' + frame;
|
||
if (variant === 'missing-bottom-rule') frame = frame.replace('╌'.repeat(120), '');
|
||
if (variant === 'broken-bottom-rule') frame = frame.replace('╌'.repeat(120), '╌'.repeat(119) + 'x');
|
||
if (variant === 'wrapped-subtitle') frame = frame.replace('notes/plan.md', 'notes/\nplan.md');
|
||
if (variant === 'tab-padding') frame = frame.replace('plan.md \n', 'plan.md\t\n');
|
||
if (variant === 'unicode-padding') frame = frame.replace('plan.md \n', 'plan.md\u00a0\n');
|
||
if (variant === 'leading-padding') frame = frame.replace('\n notes/plan.md', '\n notes/plan.md');
|
||
if (variant === 'duplicate-header') frame = frame.replace(' 1 Plan content', ' Edit file\n notes/plan.md\n 1 Plan content');
|
||
if (variant === 'intervening-menu') frame = frame.replace(' 1 Plan content', ' ❯ 1. Prior choice\n 1 Plan content');
|
||
if (variant === 'wider-than-rule') frame = frame.replace('╌'.repeat(120), '╌'.repeat(10));
|
||
if (variant === 'wrong-operation') frame = frame.replace(' Edit file', ' Create file');
|
||
expect(currentFilePermissionTarget(frame)).toBeNull();
|
||
});
|
||
|
||
test('modern native Edit wording binds its exact owned relative or absolute path', () => {
|
||
// Claude 2.1.257 Io(Edit) + Cwo: "Do you want to make this edit to <fileName>?"
|
||
const filePath = path.join(config, 'plan.md');
|
||
write(nativeWrite('edit', { file_path: filePath, old_string: 'Draft', new_string: 'Final report' }, config, 'Edit'));
|
||
const owner = readPlanSkillQuestions(config, sessionId).permissionTools[0]!;
|
||
for (const displayed of ['plan.md', filePath]) {
|
||
const dialog = createDialog(displayed).replace('create', 'make this edit to');
|
||
expect(currentFilePermissionTarget(dialog)).toEqual({ operation: 'edit', filePath: displayed });
|
||
expect(isPermissionDialogVisible(dialog)).toBe(true);
|
||
expect(nativePermissionKey(owner, dialog)).toBe(`Edit:${filePath}`);
|
||
}
|
||
});
|
||
|
||
test('modern native Edit wording keeps malformed and restricted menus unsupported', () => {
|
||
const dialog = createDialog('plan.md').replace('create', 'make this edit to');
|
||
for (const malformed of [
|
||
dialog.replace('make this edit to', 'make these edits to'),
|
||
dialog.replace('make this edit to', 'make this edit for'),
|
||
dialog.replace('make this edit to', 'make this edit'),
|
||
dialog.replace('make this edit to', 'write to'),
|
||
dialog.replace('auto-approve file edits and common file commands', 'review this plan'),
|
||
'Do you want to make this edit to plan.md?\n❯1.Yes\n2.No',
|
||
]) {
|
||
expect(currentFilePermissionTarget(malformed)).toBeNull();
|
||
expect(isPermissionDialogVisible(malformed)).toBe(false);
|
||
}
|
||
});
|
||
|
||
test('modern native Edit wording cannot bind a different path, cwd, tool or later menu', () => {
|
||
const filePath = path.join(config, 'plan.md');
|
||
const owner = { id: 'edit', name: 'Edit', cwd: config, input: { file_path: filePath, old_string: 'Draft', new_string: 'Final' } };
|
||
const dialog = createDialog('plan.md').replace('create', 'make this edit to');
|
||
expect(() => nativePermissionKey({ ...owner, name: 'Write' }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, cwd: path.dirname(config) }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(owner, dialog.replace('plan.md', 'other.md'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(owner, `${dialog}\n${createDialog('other.md')}`)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('modern native Edit wording preserves ordinary Write create and overwrite ownership', () => {
|
||
const filePath = path.join(config, 'plan.md');
|
||
const owner = { id: 'write', name: 'Write', cwd: config, input: { file_path: filePath, content: 'Final report' } };
|
||
for (const operation of ['create', 'overwrite'] as const) {
|
||
const dialog = createDialog('plan.md').replace('create', operation);
|
||
expect(currentFilePermissionTarget(dialog)).toEqual({ operation, filePath: 'plan.md' });
|
||
expect(nativePermissionKey(owner, dialog)).toBe(`Write:${filePath}`);
|
||
expect(() => nativePermissionKey({ ...owner, name: 'Edit' }, dialog)).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test('modern native Edit wording does not regrant an indistinguishable completed Edit', () => {
|
||
const input = { file_path: path.join(config, 'plan.md'), old_string: 'Draft', new_string: 'Final' };
|
||
const dialog = createDialog('plan.md').replace('create', 'make this edit to');
|
||
const granted = new Set<string>();
|
||
const requests = new Map<string, NativePermissionGrant>();
|
||
write(nativeWrite('edit-first', input, config, 'Edit'));
|
||
const first = readPlanSkillQuestions(config, sessionId);
|
||
expect(reserveNativePermissionGrant(first, dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(first, dialog, granted, requests)).toBe(false);
|
||
write(nativeWrite('edit-first', input, config, 'Edit'), nativeWriteResult('edit-first'), nativeWrite('edit-again', input, config, 'Edit'));
|
||
expect(() => reserveNativePermissionGrant(readPlanSkillQuestions(config, sessionId), dialog, granted, requests)).toThrow('cannot be distinguished');
|
||
});
|
||
|
||
// The retained cfa50758 PermissionRequest appended GSTACK REVIEW REPORT by
|
||
// replacing an existing final paragraph with that paragraph plus the report.
|
||
// Reproduce that Edit shape with synthetic content and a launcher-owned hook.
|
||
async function scopedEditSequence(variant = 'native', unfinishedPrior = false) {
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PermissionRequest[0].hooks[0].command;
|
||
const emit = (input: unknown) => {
|
||
const previous = new Set(readPermissionRequestEvents(source, { configDir: config, sessionId, transcriptFile: file }).map(event => event.requestId));
|
||
const child = Bun.spawnSync(['bash', '-c', command], { timeout: 5000,
|
||
stdin: Buffer.from(JSON.stringify({ hook_event_name: 'PermissionRequest', session_id: sessionId,
|
||
transcript_path: file, cwd: config, tool_name: 'Edit', tool_input: input })), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(child.exitCode, child.stderr.toString()).toBe(0);
|
||
return readPermissionRequestEvents(source, { configDir: config, sessionId, transcriptFile: file })
|
||
.find(event => !previous.has(event.requestId))!;
|
||
};
|
||
const old_string = '### Unresolved Decisions\n\nNone. The review choices were answered.';
|
||
const firstInput = { file_path: path.join(config, 'plan.md'), old_string: 'Draft', new_string: old_string, replace_all: false };
|
||
const nextInput = variant === 'same-input' ? firstInput : {
|
||
file_path: path.join(config, variant === 'wrong-path' ? 'other.md' : 'plan.md'), old_string,
|
||
new_string: old_string + '\n\n## GSTACK REVIEW REPORT\n\n| Review | Runs | Status | Findings |\n| CEO | 1 | CLEAR | Review complete |\n\n**VERDICT:** CEO CLEARED\n\nNO UNRESOLVED DECISIONS',
|
||
replace_all: false,
|
||
};
|
||
const first = nativeWrite('scoped-edit-1', firstInput, config, 'Edit');
|
||
write(first);
|
||
const firstEvent = emit(firstInput);
|
||
const dialog = createDialog('plan.md').replace('create', 'make this edit to');
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
const read = () => readPlanSkillQuestions(config, sessionId, variant === 'no-observer' ? undefined : source);
|
||
expect(reserveNativePermissionGrant(read(), dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(read(), dialog, granted, requests)).toBe(false);
|
||
const resultAtMs = firstEvent.capturedAtMs + (variant === 'before-result' ? 60_000
|
||
: variant === 'prior-before-request' ? -1 : variant === 'prior-equal-request' ? 0 : 1);
|
||
const rows: unknown[] = [unfinishedPrior || variant === 'unfinished-prior' ? nativeWrite('scoped-edit-1', firstInput, config, 'Edit', null) : first];
|
||
if (variant !== 'no-ack') rows.push(nativeWriteResult('scoped-edit-1', new Date(resultAtMs).toISOString(), variant === 'error'));
|
||
if (variant !== 'early') rows.push(nativeWrite(variant === 'changed-id' ? 'scoped-edit-1' : 'scoped-edit-2', nextInput,
|
||
variant === 'wrong-cwd' ? path.dirname(config) : config, 'Edit'));
|
||
if (variant === 'multiple-owner') rows.push(nativeWrite('other-pending', { command: 'true' }, config, 'Bash'));
|
||
if (variant === 'parallel-read') rows.push(nativeWrite('other-pending', { file_path: path.join(config, 'README.md') }, config, 'Read'));
|
||
if (variant === 'parallel-tool-search') rows.push(nativeWrite('other-pending', { query: 'select:AskUserQuestion' }, config, 'ToolSearch'));
|
||
if (variant === 'same-path-owner') rows.push(nativeWrite('other-pending', { ...nextInput, new_string: 'Different pending edit' }, config, 'Edit'));
|
||
write(...rows);
|
||
await Bun.sleep(5);
|
||
const nextEvent = emit(nextInput);
|
||
if (variant === 'equal-result') {
|
||
rows[1] = nativeWriteResult('scoped-edit-1', new Date(nextEvent.capturedAtMs).toISOString());
|
||
write(...rows);
|
||
}
|
||
return { read, dialog, granted, requests, firstEvent, nextEvent, resultAtMs, nextInput };
|
||
}
|
||
|
||
test.each(['native', 'early', 'multiple-owner', 'parallel-read', 'parallel-tool-search', 'unfinished-prior'])('fresh scoped Edit can append the terminal report after a completed Edit (%s)', async variant => {
|
||
const sequence = await scopedEditSequence(variant);
|
||
const native = sequence.read();
|
||
const prior = native.permissionRequests.find(item => item.requestId === sequence.firstEvent.requestId)!;
|
||
expect(prior).toMatchObject({ result: 'completed', nativeToolId: 'scoped-edit-1', nativeResultAtMs: sequence.resultAtMs });
|
||
expect(sequence.nextEvent.requestId).not.toBe(sequence.firstEvent.requestId);
|
||
expect(sequence.nextEvent.capturedAtMs).toBeGreaterThan(sequence.resultAtMs);
|
||
expect(native.permissionRequests.find(item => item.requestId === sequence.nextEvent.requestId)).toMatchObject({ result: 'pending', input: sequence.nextInput });
|
||
expect(reserveNativePermissionGrant(native, sequence.dialog, sequence.granted, sequence.requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, sequence.dialog, sequence.granted, sequence.requests)).toBe(false);
|
||
expect(sequence.granted.size).toBe(2);
|
||
expect(sequence.requests.get(`Edit:${path.join(config, 'plan.md')}`)?.requestId).toBe(sequence.nextEvent.requestId);
|
||
if (variant === 'multiple-owner' || variant === 'parallel-read' || variant === 'parallel-tool-search') {
|
||
expect(native.permissionTools.some(tool => tool.id === 'other-pending')).toBe(true);
|
||
expect(sequence.granted.has('other-pending')).toBe(false);
|
||
}
|
||
});
|
||
|
||
test.each(['no-file-owner', 'wrong-file', 'two-file-owners', 'no-observer'])
|
||
('parallel ToolSearch cannot authorize a file grant with %s', async variant => {
|
||
const sequence = await scopedEditSequence('parallel-tool-search');
|
||
const native = sequence.read();
|
||
if (variant === 'no-file-owner') {
|
||
native.permissionRequests = [];
|
||
native.permissionTools = native.permissionTools.filter(tool => tool.name === 'ToolSearch');
|
||
}
|
||
if (variant === 'two-file-owners') native.permissionRequests.push({
|
||
...native.permissionRequests.find(request => request.result === 'pending')!,
|
||
requestId: 'second-edit', nativeToolId: undefined,
|
||
});
|
||
if (variant === 'no-observer') native.permissionRequestCapture = false;
|
||
const dialog = variant === 'wrong-file' ? sequence.dialog.replace('plan.md', 'other.md') : sequence.dialog;
|
||
const grantedBefore = [...sequence.granted], requestsBefore = [...sequence.requests];
|
||
expect(() => reserveNativePermissionGrant(native, dialog, sequence.granted, sequence.requests)).toThrow();
|
||
expect([...sequence.granted]).toEqual(grantedBefore);
|
||
expect([...sequence.requests]).toEqual(requestsBefore);
|
||
});
|
||
|
||
test.each(['no-ack', 'error', 'before-result', 'equal-result', 'same-input', 'changed-id', 'wrong-path', 'wrong-cwd', 'same-path-owner', 'no-observer'])
|
||
('fresh scoped Edit preserves refusal for %s', async variant => {
|
||
const sequence = await scopedEditSequence(variant);
|
||
expect(() => reserveNativePermissionGrant(sequence.read(), sequence.dialog, sequence.granted, sequence.requests))
|
||
.toThrow(/Repeated native permission|Indistinguishable|changed input|cannot be bound|Ambiguous native permission/);
|
||
expect(sequence.granted.size).toBe(1);
|
||
});
|
||
|
||
test.each(['no-ack', 'error', 'prior-before-request', 'prior-equal-request', 'before-result', 'equal-result',
|
||
'same-input', 'changed-id', 'wrong-cwd', 'same-path-owner'])
|
||
('unfinished Edit retirement preserves refusal for %s', async variant => {
|
||
const sequence = await scopedEditSequence(variant, true);
|
||
const granted = [...sequence.granted], requests = [...sequence.requests];
|
||
expect(() => reserveNativePermissionGrant(sequence.read(), sequence.dialog, sequence.granted, sequence.requests)).toThrow();
|
||
expect([...sequence.granted]).toEqual(granted);
|
||
expect([...sequence.requests]).toEqual(requests);
|
||
});
|
||
|
||
test('unfinished pending Edit has no native grant authority without an owned request', () => {
|
||
const input = { file_path: path.join(config, 'plan.md'), old_string: 'Draft', new_string: 'Approved' };
|
||
write(nativeWrite('unfinished-pending', input, config, 'Edit', null));
|
||
const native = readPlanSkillQuestions(config, sessionId);
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(native.permissionTools).toEqual([]);
|
||
expect(reserveNativePermissionGrant(native, createDialog('plan.md').replace('create', 'make this edit to'), granted, requests)).toBe(false);
|
||
expect(granted.size).toBe(0); expect(requests.size).toBe(0);
|
||
});
|
||
|
||
test.each(['same-path', 'mixed-operation', 'granted-shadow', 'no-observer', 'legacy-file', 'legacy-bash', 'malformed', 'unsupported', 'no-match'])
|
||
('a current file dialog with parallel work preserves refusal for %s', async variant => {
|
||
const sequence = await scopedEditSequence('multiple-owner');
|
||
const native = variant === 'no-observer' ? readPlanSkillQuestions(config, sessionId) : sequence.read();
|
||
const owner = native.permissionRequests.find(request => request.requestId === sequence.nextEvent.requestId)!;
|
||
let dialog = sequence.dialog;
|
||
if (variant === 'same-path' || variant === 'granted-shadow') {
|
||
native.permissionRequests.push({ ...owner, requestId: 'distinct-pending-request', nativeToolId: undefined });
|
||
if (variant === 'granted-shadow') sequence.granted.add(`request:${owner.requestId}`);
|
||
}
|
||
if (variant === 'mixed-operation') native.permissionTools.push({
|
||
id: 'same-path-write', name: 'Write', cwd: config,
|
||
input: { file_path: sequence.nextInput.file_path, content: 'Other pending write' },
|
||
});
|
||
if (variant === 'legacy-file') dialog = `Edit to ${sequence.nextInput.file_path}`;
|
||
if (variant === 'legacy-bash') dialog = 'Bash command true requires permission';
|
||
if (variant === 'malformed') native.permissionTools.find(tool => tool.id === 'other-pending')!.input = { command: 42 };
|
||
if (variant === 'unsupported') native.permissionTools.find(tool => tool.id === 'other-pending')!.name = 'Grep';
|
||
if (variant === 'no-match') dialog = dialog.replace('plan.md', 'different.md');
|
||
const grantedBefore = [...sequence.granted], requestsBefore = [...sequence.requests];
|
||
expect(() => reserveNativePermissionGrant(native, dialog, sequence.granted, sequence.requests))
|
||
.toThrow(variant === 'malformed' || variant === 'unsupported' ? 'Unsupported native permission' : 'Ambiguous native permission');
|
||
expect([...sequence.granted]).toEqual(grantedBefore);
|
||
expect([...sequence.requests]).toEqual(requestsBefore);
|
||
});
|
||
|
||
test('modern overwrite permission uses the exact current Write path and controls', () => {
|
||
const filePath = path.join(config, 'plan.md');
|
||
const dialog = createDialog('plan.md').replace('create', 'overwrite');
|
||
const owner = { id: 'overwrite', name: 'Write', cwd: config, input: { file_path: filePath, content: 'Final report' } };
|
||
expect(isPermissionDialogVisible(dialog)).toBe(true);
|
||
expect(nativePermissionKey(owner, dialog)).toBe(`Write:${filePath}`);
|
||
expect(() => nativePermissionKey({ ...owner, name: 'Edit' }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, cwd: path.dirname(config) }, dialog)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(owner, dialog.replace('plan.md', 'other.md'))).toThrow('cannot be bound');
|
||
expect(isPermissionDialogVisible(dialog.replace('auto-approve file edits and common file commands', 'review this plan'))).toBe(false);
|
||
expect(isPermissionDialogVisible('Do you want to overwrite plan.md?\n❯1.Yes\n2.No')).toBe(false);
|
||
});
|
||
|
||
test('current create-file permission controls are recognized without treating an ordinary decision as permission', () => {
|
||
// Exact controls retained from the CEO finding-count timeout. Its clipped
|
||
// basename is evidence for classification only, never path authorization.
|
||
expect(isPermissionDialogVisible(createDialog('gstck-test-plan-co.md').replace('Do you', 'Doyou'))).toBe(true);
|
||
expect(isPermissionDialogVisible('Do you want to create plan.md?\n❯1.Yes\n2.No')).toBe(false);
|
||
expect(isPermissionDialogVisible(createDialog('plan.md').replace('auto-approve file edits and common file commands', 'review the recommendation'))).toBe(false);
|
||
});
|
||
|
||
test('current file permissions resolve the exact displayed relative path against the owned native cwd', () => {
|
||
const cwd = path.join(config, 'project');
|
||
const filePath = path.join(cwd, 'plan.md');
|
||
write({ type: 'assistant', sessionId, cwd, message: { role: 'assistant', stop_reason: 'tool_use',
|
||
content: [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: filePath, content: 'Plan' } }] } });
|
||
const owner = readPlanSkillQuestions(config, sessionId).permissionTools[0]!;
|
||
expect(nativePermissionKey(owner, createDialog('plan.md'))).toBe(`Write:${filePath}`);
|
||
expect(nativePermissionKey(owner, createDialog(filePath))).toBe(`Write:${filePath}`);
|
||
expect(() => nativePermissionKey({ ...owner, cwd: path.join(config, 'different') }, createDialog('plan.md'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, cwd: undefined }, createDialog('plan.md'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...owner, name: 'Read' }, createDialog(filePath))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(owner, createDialog('pln.md'))).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey(owner, `${createDialog(filePath)}\n${createDialog('other.md')}`)).toThrow('cannot be bound');
|
||
});
|
||
|
||
// Sanitized retained native frame: preceding assistant prose omitted; modal rows unchanged.
|
||
const wrappedPreviewFixture = {
|
||
"questions": [
|
||
{
|
||
"header": "Approach",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"description": "PreToolUse hook captures AUQ before transcript write; transcript confirms result status. Race-proof, injection-hardened, tests the real rendering path. Completeness: 9/10.",
|
||
"label": "B: Hook + transcript (current approach) (recommended)",
|
||
"preview": "APPROACH B: Hook + transcript cross-reference\n Captures: early events (hook) + result status (transcript)\n Race-proof: yes — hook fires synchronously before tool result\n Injection-safe: nonce + scope + FIFO rejection + path validation\n Tests real rendering path: yes\n Completeness: 9/10"
|
||
},
|
||
{
|
||
"description": "Poll readOwnedClaudeTranscript until question appears. Simpler but flaky under CI load. Completeness: 5/10.",
|
||
"label": "A: Transcript-only with retry",
|
||
"preview": "APPROACH A: Transcript-only with retry\n Captures: result status only (not pre-transcript events)\n Race-proof: no — timing depends on CI load\n Flaky risk: high\n Completeness: 5/10"
|
||
},
|
||
{
|
||
"description": "Replace AUQ at the harness level with canned answers. Avoids timing entirely but misses rendering bugs. Completeness: 6/10.",
|
||
"label": "C: Question injection/mock",
|
||
"preview": "APPROACH C: Question injection\n Captures: nothing real — bypasses AUQ entirely\n Race-proof: yes (trivially)\n Tests real rendering: no\n Completeness: 6/10"
|
||
}
|
||
],
|
||
"question": "D1 — Which implementation approach does this branch use, and do you want to proceed with it?"
|
||
},
|
||
{
|
||
"header": "Review mode",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"description": "This is test infrastructure — make it bulletproof. Catch failure modes, edge cases, security assumptions. No expansions surfaced. Right for infra/fix work.",
|
||
"label": "HOLD SCOPE (recommended)",
|
||
"preview": "HOLD SCOPE\n Focus: correctness, security, edge cases\n Expansions: none surfaced\n Right for: bug fixes, infra improvements"
|
||
},
|
||
{
|
||
"description": "Hold the current scope as baseline, but surface cherry-pick opportunities: extend to other skills, auto-answer hints, better error messages when unanswered.",
|
||
"label": "SELECTIVE EXPANSION",
|
||
"preview": "SELECTIVE EXPANSION\n Focus: correctness + cherry-pick opportunities\n Expansions: each presented individually\n Right for: solid work that might have natural adjacent wins"
|
||
},
|
||
{
|
||
"description": "Dream big — what would a 10x eval system look like? Rethink the whole approach. Right if you want to question premises, not just validate them.",
|
||
"label": "SCOPE EXPANSION",
|
||
"preview": "SCOPE EXPANSION\n Focus: ambitious re-imagining\n Expansions: enthusiastically recommended\n Right for: early-stage or uncertain direction"
|
||
}
|
||
],
|
||
"question": "D2 — Which review mode?"
|
||
}
|
||
],
|
||
"visible": [
|
||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||
"← ☐ Approach ☐ Review mode ✔ Submit →",
|
||
"",
|
||
"│ D1 — Which implementation approach does this branch use, and do you want to proceed with it?",
|
||
"",
|
||
"❯ 1. B: Hook + transcript ┌────────────────────────────────────────────────────────────────────┐",
|
||
" (current approach) │ APPROACH B: Hook + transcript cross-reference │",
|
||
" (recommended) │ Captures: early events (hook) + result status (transcript) │",
|
||
" 2. A: Transcript-only with │ Race-proof: yes — hook fires synchronously before tool result │",
|
||
" retry │ Injection-safe: nonce + scope + FIFO rejection + path validation │",
|
||
" 3. C: Question │ Tests real rendering path: yes │",
|
||
" injection/mock │ Completeness: 9/10 │",
|
||
" └────────────────────────────────────────────────────────────────────┘",
|
||
"",
|
||
" Notes: press n to add notes",
|
||
" ",
|
||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||
" Chat about this",
|
||
"",
|
||
"Enter to select · ↑/↓ to navigate · n to add notes · Tab to switch questions · Esc to cancel",
|
||
].join("\n")
|
||
};
|
||
const wrappedQuestion = wrappedPreviewFixture.questions[0]!;
|
||
const wrappedModeQuestion = wrappedPreviewFixture.questions[1]!;
|
||
const matchesWrapped = (visible: string, offered: NativeQuestion = wrappedQuestion) =>
|
||
matchesNativeQuestion(offered, visible, parseNumberedOptions(visible), wrappedPreviewFixture.questions);
|
||
|
||
test('the retained preview menu corroborates physical label continuations', () => {
|
||
expect(matchesWrapped(wrappedPreviewFixture.visible)).toBe(true);
|
||
expect(matchesWrapped(wrappedPreviewFixture.visible, wrappedModeQuestion)).toBe(false);
|
||
});
|
||
|
||
|
||
const recommendedWrapQuestion: NativeQuestion = {
|
||
header: 'Accessibility', multiSelect: false,
|
||
question: 'D12 — Issue 9: Add the accessibility spec (keyboard, screen reader, contrast, motion) to the plan?',
|
||
options: [
|
||
{ label: '9A) Full a11y spec: landmarks, focus order, live regions, contrast targets, reduced motion, test checklist (recommended)', description: 'Rail as nav landmark; visible labels and a manual test checklist.' },
|
||
{ label: '9B) Contrast fix only, as the seed plan proposed', description: 'Resolve only the contrast defect.' },
|
||
],
|
||
};
|
||
const recommendedWrapFrame = '☐ Accessibility\n' + recommendedWrapQuestion.question
|
||
+ '\n❯ 1. ' + recommendedWrapQuestion.options[0]!.label.replace(' (recommended)', '')
|
||
+ '\n (recommended)\n Rail as nav landmark; visible labels and a manual test checklist.\n 2. '
|
||
+ recommendedWrapQuestion.options[1]!.label + '\n Resolve only the contrast defect.\n 3. Type something.\nEnter to select · ↑/↓ to navigate · Esc to cancel';
|
||
const matchRecommendedWrap = (frame: string, owned = recommendedWrapQuestion) =>
|
||
nativeQuestionSelection(owned, frame, parseNumberedOptions(frame));
|
||
|
||
test('plain owned label accepts its immediately wrapped recommendation suffix', () => {
|
||
expect(parseNumberedOptions(recommendedWrapFrame)[0]!.label).not.toContain('(recommended)');
|
||
expect(matchRecommendedWrap(recommendedWrapFrame)).toEqual({ kind: 'digit' });
|
||
expect(matchRecommendedWrap(recommendedWrapFrame.replaceAll(' ', '').replaceAll('❯ 1. ', '❯1. '))).toEqual({ kind: 'digit' });
|
||
expect(matchRecommendedWrap(recommendedWrapFrame, { ...recommendedWrapQuestion,
|
||
options: recommendedWrapQuestion.options.map(option => ({ ...option, preview: 'Owned preview requires its own frame.' })) })).toBeNull();
|
||
});
|
||
|
||
test.each([
|
||
(frame: string) => frame.replace('\n (recommended)', ''),
|
||
(frame: string) => frame.replace('(recommended)', '(not recommended)'),
|
||
(frame: string) => frame.replace('(recommended)', '(recommended by the example)'),
|
||
(frame: string) => frame.replace('\n (recommended)', '\n\n (recommended)'),
|
||
(frame: string) => frame.replace('\n (recommended)', '\n Description first\n (recommended)'),
|
||
(frame: string) => frame.replace('❯ 1.', '(recommended)\n❯ 1.').replace('\n (recommended)', ''),
|
||
(frame: string) => frame.replace('test checklist', 'different checklist'),
|
||
(frame: string) => frame.replace('2. 9B)', '2. Other action'),
|
||
(frame: string) => frame.replace('\n (recommended)', '\n 2. Another menu\n (recommended)'),
|
||
(frame: string) => frame.replace('D12 — Issue 9:', 'D13 — Different issue:'),
|
||
(frame: string) => frame + '\n☐ Different\nD13 — Another question?\n❯ 1. Other action\n 2. Keep current state',
|
||
])('wrapped recommendation cannot bridge missing, changed, stale or interrupted labels %#', change => {
|
||
expect(matchRecommendedWrap(change(recommendedWrapFrame))).toBeNull();
|
||
});
|
||
|
||
test('plain wrapped menus retain the existing parser behavior', () => {
|
||
const visible = '☐ Approach\n' + wrappedQuestion.question + '\n❯ 1. B: Hook + transcript\n (current approach)\n (recommended)\n 2. A: Transcript-only with\n retry\n';
|
||
expect(matchesWrapped(visible)).toBe(false);
|
||
});
|
||
|
||
test('two complete native labels still corroborate when the last visible choice is clipped', () => {
|
||
const visible = '☐ Approach\n' + wrappedQuestion.question + '\n❯ 1. B: Hook + transcript (current approach) (recommended)\n 2. A: Transcript-only with retry\n 3. C: Question';
|
||
expect(matchesWrapped(visible)).toBe(true);
|
||
expect(matchesWrapped(visible + '\n\n')).toBe(true);
|
||
});
|
||
|
||
test('a complete preview frame permits a valid clipped final label but rejects a changed prefix', () => {
|
||
const visible = wrappedPreviewFixture.visible.replace(' injection/mock', ' '.repeat(' injection/mock'.length));
|
||
expect(matchesWrapped(visible)).toBe(true);
|
||
expect(matchesWrapped(visible.replace('C: Question', 'C: Changed!'))).toBe(false);
|
||
});
|
||
|
||
test('preview text cannot supply missing or changed left-column labels', () => {
|
||
const preview = wrappedPreviewFixture.visible.replace(/│ APPROACH B:[^\n]*│/, `│ ${wrappedQuestion.options[0]!.label.padEnd(66)} │`);
|
||
const missing = preview.replace(' (current approach)', ' '.repeat(' (current approach)'.length));
|
||
expect(matchesWrapped(missing)).toBe(false);
|
||
const changed = preview.replace('B: Hook + transcript', 'B: Different choice'.padEnd('B: Hook + transcript'.length));
|
||
expect(matchesWrapped(changed)).toBe(false);
|
||
});
|
||
|
||
test('preview text containing a cursor cannot become the active option list', () => {
|
||
const decoy = '❯ 1. ' + wrappedQuestion.options[0]!.label;
|
||
const visible = wrappedPreviewFixture.visible.replace(/│ APPROACH B:[^\n]*│/, `│ ${decoy.padEnd(66)} │`);
|
||
expect(matchesWrapped(visible)).toBe(true);
|
||
expect(matchesWrapped(visible.replace('A: Transcript-only with', 'A: Unrelated choice'.padEnd('A: Transcript-only with'.length)))).toBe(false);
|
||
});
|
||
|
||
test('physical label continuation cannot jump a blank row or a menu boundary', () => {
|
||
const visible = wrappedPreviewFixture.visible.replace(' (current approach)', ' '.repeat(' (current approach)'.length));
|
||
expect(matchesWrapped(visible)).toBe(false);
|
||
const decoy = wrappedPreviewFixture.visible.replace(' (current approach)', ' ──────────────────');
|
||
expect(matchesWrapped(decoy)).toBe(false);
|
||
});
|
||
|
||
test('malformed preview borders and duplicate numbered rows remain refused', () => {
|
||
expect(matchesWrapped(wrappedPreviewFixture.visible.replace('└', ' '))).toBe(false);
|
||
expect(matchesWrapped(wrappedPreviewFixture.visible.replace('┐', ' '))).toBe(false);
|
||
expect(matchesWrapped(wrappedPreviewFixture.visible.replace(' 2. A:', ' 1. A:'))).toBe(false);
|
||
});
|
||
|
||
test('a stale wrapped menu cannot corroborate the following current question', () => {
|
||
const stale = wrappedPreviewFixture.visible + '\n☐ Another question\nD3 — Pick a different action?\n❯ 1. B: Hook + transcript (current approach) (recommended)\n 2. A: Transcript-only with retry\n';
|
||
expect(matchesWrapped(stale)).toBe(false);
|
||
});
|
||
|
||
test('a later inline menu supersedes an older physical preview menu', () => {
|
||
const current: NativeQuestion = { question: 'D3 — Pick a different action?', header: 'Another question', multiSelect: false,
|
||
options: ['Keep current files', 'Inspect evidence'].map(label => ({ label, description: label })) };
|
||
const visible = wrappedPreviewFixture.visible + '\n☐ Another question\nD3 — Pick a different action? ❯1. Keep current files 2. Inspect evidence\n';
|
||
expect(matchesWrapped(visible)).toBe(false);
|
||
expect(matchesNativeQuestion(current, visible, parseNumberedOptions(visible), [wrappedQuestion])).toBe(true);
|
||
});
|
||
|
||
test('an inline list beginning at the physical line start retains stream parsing', () => {
|
||
const current: NativeQuestion = { question: 'Which?', header: 'Mode', multiSelect: false,
|
||
options: ['Hold', 'Expand'].map(label => ({ label, description: label })) };
|
||
for (const menu of ['❯1. Hold 2. Expand', '❯1.Hold2.Expand']) {
|
||
const visible = '☐ Mode\nWhich?\n' + menu;
|
||
expect(matchesNativeQuestion(current, visible, parseNumberedOptions(visible))).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('plain option glyphs do not create a side-preview frame', () => {
|
||
for (const label of ['Keep │ pipes', 'Render ┌ corners', 'Render ┐ corners', 'Draw ┌──┐', 'Draw ┌──┐']) {
|
||
const current: NativeQuestion = { question: 'Which?', header: 'Mode', multiSelect: false,
|
||
options: [label, 'Another choice'].map(label => ({ label, description: label })) };
|
||
const visible = '☐ Mode\nWhich?\n❯1. ' + label + '\n 2. Another choice\n';
|
||
expect(matchesNativeQuestion(current, visible, parseNumberedOptions(visible))).toBe(true);
|
||
}
|
||
});
|
||
|
||
test('missing preview corners cannot promote right-column decoy labels', () => {
|
||
const current: NativeQuestion = { question: 'Which?', header: 'Mode', multiSelect: false,
|
||
options: ['Hold', 'Expand'].map(label => ({ label, description: label })) };
|
||
const border = '┌' + '─'.repeat(28) + '┐';
|
||
for (const top of [border, border.replace('┌', ' '), border.replace('┐', ' '), border.replace(/[┌┐]/g, ' ')]) {
|
||
const visible = '☐ Mode\nWhich?\n' + '❯1. Wrong'.padEnd(24) + top + '\n'
|
||
+ ' 2. Also wrong'.padEnd(24) + '│ ' + '❯1. Hold 2. Expand'.padEnd(26) + ' │\n'
|
||
+ ' '.repeat(24) + '└' + '─'.repeat(28) + '┘';
|
||
expect(matchesNativeQuestion(current, visible, parseNumberedOptions(visible))).toBe(false);
|
||
}
|
||
});
|
||
|
||
|
||
// Exact current modal and native input retained from expansion first-attempt
|
||
// diagnostic 13288fb0. Preview prose is display content, never choice authority.
|
||
const previewInputFrame = {
|
||
"question": {
|
||
"question": "Which implementation approach should anchor the review?",
|
||
"header": "Approach",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"label": "A — Client-side CSV (recommended)",
|
||
"description": "Smallest diff. Reuses settings API response directly in the browser. One formatter util + tests. Completeness: 9/10 — covers the full happy path and edge-case escaping; misses server-auth-gate on export (not needed for settings).",
|
||
"preview": "button onClick → fetch existing API → csvFormatter(data) → Blob URL download\n\nFiles touched: settings page (+button), csvFormatter.ts (new), csvFormatter.test.ts (new)"
|
||
},
|
||
{
|
||
"label": "B — Server-side endpoint",
|
||
"description": "Cleaner for large datasets; adds new route + handler + auth wiring. Completeness: 10/10 — fresh data, proper headers, server auth gate. Over-engineering for a settings page.",
|
||
"preview": "GET /settings/export.csv\n → auth middleware\n → settingsService.getAll()\n → csvSerializer()\n → stream response\n\nFiles touched: route, handler, serializer, serializer.test, settings page (+button)"
|
||
},
|
||
{
|
||
"label": "C — Client-side CSV + JSON bonus",
|
||
"description": "Near-zero extra cost after A; adds a format dropdown. Completeness: 9/10 — same as A plus programmatic-use JSON format. Minor scope expansion.",
|
||
"preview": "button [Export ▾]\n ├ CSV → csvFormatter(data) → download\n └ JSON → JSON.stringify(data, null, 2) → download\n\nSame files as A + dropdown component"
|
||
}
|
||
]
|
||
},
|
||
"visible": [
|
||
" ☐ Approach ",
|
||
" ",
|
||
"Which implementation approach should anchor the review?",
|
||
" ",
|
||
"❯ 1. A — Client-side CSV ┌────────────────────────────────────────────────────────────────────────────────────┐",
|
||
" (recommended) │ button onClick → fetch existing API → csvFormatter(data) → Blob URL download │",
|
||
" 2. B — Server-side endpoint │ │",
|
||
" 3. C — Client-side CSV + │ Files touched: settings page (+button), csvFormatter.ts (new), │",
|
||
" JSON bonus │ csvFormatter.test.ts (new) │",
|
||
" └────────────────────────────────────────────────────────────────────────────────────┘",
|
||
"",
|
||
" Notes: press n to add notes",
|
||
" ",
|
||
"────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────",
|
||
" Chat about this",
|
||
"",
|
||
"Enter to select · ↑/↓ to navigate · n to add notes · Esc to cancel"
|
||
]
|
||
};
|
||
const currentPreview = previewInputFrame.visible.join('\n');
|
||
const previewInput = previewInputFrame.question;
|
||
const selectPreview = (visible = currentPreview, input: NativeQuestion = previewInput) =>
|
||
nativeQuestionSelection(input, visible, parseNumberedOptions(visible));
|
||
const focusPreview = (index: number) => currentPreview.replace('❯ 1.', ' 1.').replace(` ${index}.`, `❯ ${index}.`);
|
||
|
||
test('retained preview selection follows its own left-column focus for each native choice', () => {
|
||
expect(selectPreview()).toEqual({ kind: 'preview', focusedIndex: 1 });
|
||
expect(selectPreview(focusPreview(2))).toEqual({ kind: 'preview', focusedIndex: 2 });
|
||
expect(selectPreview(focusPreview(3))).toEqual({ kind: 'preview', focusedIndex: 3 });
|
||
});
|
||
|
||
// Exact clipping row and columns from the retained expansion-mode preview.
|
||
const retainedClippingRuler = ' ├─── ✂ ─── 1 lines hidden ───────────────────────────────────────────────────────────┤';
|
||
const clippedPreview = focusPreview(2).replace(/^( +└)/m, `${retainedClippingRuler}\n$1`);
|
||
|
||
test.each([1, 12])('preview clipping-ruler preserves owned left-column focus (%i hidden lines)', count => {
|
||
const ruler = `├─── ✂ ─── ${count} lines hidden `.padEnd(85, '─') + '┤';
|
||
const visible = clippedPreview.replace(retainedClippingRuler, ' '.repeat(34) + ruler);
|
||
expect(selectPreview(visible)).toEqual({ kind: 'preview', focusedIndex: 2 });
|
||
});
|
||
|
||
test('preview clipping-ruler supports zero trailing dashes and a left-column label continuation', () => {
|
||
const ruler = '├─── ✂ ─── 1 lines hidden ┤';
|
||
const innerWidth = ruler.length - 2;
|
||
const narrow = clippedPreview.split('\n').map(line => {
|
||
const left = line.slice(0, 34);
|
||
const pane = line.slice(34);
|
||
if (pane.startsWith('├')) return left + ruler;
|
||
if (pane.startsWith('┌')) return left + '┌' + '─'.repeat(innerWidth) + '┐';
|
||
if (pane.startsWith('└')) return left + '└' + '─'.repeat(innerWidth) + '┘';
|
||
if (pane.startsWith('│')) return left + '│' + pane.slice(1, innerWidth + 1).padEnd(innerWidth) + '│';
|
||
return line;
|
||
}).join('\n');
|
||
expect(selectPreview(narrow)).toEqual({ kind: 'preview', focusedIndex: 2 });
|
||
const sharedRow = focusPreview(3).replace(/^ JSON bonus +│[^\n]*│$/m,
|
||
' JSON bonus'.padEnd(34) + retainedClippingRuler.slice(34));
|
||
expect(selectPreview(sharedRow)).toEqual({ kind: 'preview', focusedIndex: 3 });
|
||
});
|
||
|
||
test.each([
|
||
['shifted left edge', retainedClippingRuler.slice(1)],
|
||
['shifted right edge', retainedClippingRuler.replace('─┤', '──┤')],
|
||
['shortened right edge', retainedClippingRuler.replace('─┤', '┤')],
|
||
['missing left junction', retainedClippingRuler.replace('├', '│')],
|
||
['missing right junction', retainedClippingRuler.replace('┤', '│')],
|
||
['wrong scissors', retainedClippingRuler.replace('✂', 'x')],
|
||
['wrong delimiter', retainedClippingRuler.replace('✂ ───', '✂ ─ ─')],
|
||
['tab separator', retainedClippingRuler.replace('✂ ', '✂\t')],
|
||
['zero count', retainedClippingRuler.replace('1 lines', '0 lines')],
|
||
['negative count', retainedClippingRuler.replace('1 lines', '-1 lines').replace('──┤', '─┤')],
|
||
['leading zero', retainedClippingRuler.replace('1 lines', '01 lines').replace('──┤', '─┤')],
|
||
['wrong wording', retainedClippingRuler.replace('lines hidden', 'lines folded')],
|
||
['trailing content', retainedClippingRuler + ' x'],
|
||
['wrapped ruler', retainedClippingRuler.replace('lines hidden', 'lines\nhidden')],
|
||
] as const)('preview clipping-ruler rejects malformed frames: %s', (_name, ruler) => {
|
||
expect(selectPreview(clippedPreview.replace(retainedClippingRuler, ruler))).toBeNull();
|
||
});
|
||
|
||
test('preview clipping-ruler must occur once immediately before the bottom border', () => {
|
||
expect(selectPreview(clippedPreview.replace(retainedClippingRuler, `${retainedClippingRuler}\n${retainedClippingRuler}`))).toBeNull();
|
||
expect(selectPreview(clippedPreview.replace(retainedClippingRuler, `${retainedClippingRuler}\n`))).toBeNull();
|
||
const interior = focusPreview(2).replace(/^( JSON bonus)/m, `${retainedClippingRuler}\n$1`);
|
||
expect(selectPreview(interior)).toBeNull();
|
||
});
|
||
|
||
test('preview clipping-ruler cannot replace label, focus, rectangle, inventory or footer evidence', () => {
|
||
expect(selectPreview(clippedPreview.replace('B — Server-side endpoint', 'B — Foreign-side endpoint'))).toBeNull();
|
||
expect(selectPreview(clippedPreview.replace(' 1.', '❯ 1.'))).toBeNull();
|
||
expect(selectPreview(clippedPreview.replace('❯ 2.', ' 2.'))).toBeNull();
|
||
expect(selectPreview(clippedPreview.replace('└', ' '))).toBeNull();
|
||
expect(selectPreview(clippedPreview, { ...previewInput, options: previewInput.options.map(({ preview, ...option }) => option) })).toBeNull();
|
||
expect(selectPreview(clippedPreview.replace('Enter to select', 'Enter to confirm'))).toBeNull();
|
||
});
|
||
|
||
test('mixed native preview options retain the preview protocol for an option without preview', () => {
|
||
const mixed = { ...previewInput, options: previewInput.options.map((option, index) => {
|
||
const { preview, ...plain } = option; return index === 0 ? option : plain;
|
||
}) };
|
||
expect(selectPreview(focusPreview(2), mixed)).toEqual({ kind: 'preview', focusedIndex: 2 });
|
||
});
|
||
|
||
test('preview cursor content cannot substitute for missing, duplicate or wrong left-column focus', () => {
|
||
const decoy = currentPreview.replace(/│ button onClick[^\n]*│/, '│ ' + '❯ 2. B — Server-side endpoint'.padEnd(82) + ' │');
|
||
expect(selectPreview(decoy)).toEqual({ kind: 'preview', focusedIndex: 1 });
|
||
expect(selectPreview(decoy.replace('❯ 1.', ' 1.'))).toBeNull();
|
||
expect(selectPreview(currentPreview.replace(' 2.', '❯ 2.'))).toBeNull();
|
||
expect(selectPreview(focusPreview(2).replace('B — Server-side endpoint', 'B — Foreign-side endpoint'))).toBeNull();
|
||
});
|
||
|
||
test('preview commit requires native inventory, current prompt, rectangle and actual footer', () => {
|
||
const noPreview = { ...previewInput, options: previewInput.options.map(({ preview, ...option }) => option) };
|
||
expect(selectPreview(currentPreview, noPreview)).toBeNull();
|
||
expect(selectPreview(currentPreview.replace('Which implementation approach should anchor the review?', 'An unrelated later question'))).toBeNull();
|
||
expect(selectPreview(currentPreview.replace('└', ' '))).toBeNull();
|
||
expect(selectPreview(currentPreview.replace('Enter to select', 'Enter to confirm'))).toBeNull();
|
||
expect(selectPreview(currentPreview + '\n☐ Different question\nOther prompt\n❯1.First\n2.Second')).toBeNull();
|
||
});
|
||
|
||
test('normal native input retains digit-only selection and preview input in a plain frame waits', () => {
|
||
const noPreview = { ...previewInput, options: previewInput.options.map(({ preview, ...option }) => option) };
|
||
const plain = `☐ ${noPreview.header}\n${noPreview.question}\n`
|
||
+ noPreview.options.map((option, i) => `${i === 0 ? '❯' : ' '}${i + 1}. ${option.label}`).join('\n');
|
||
expect(selectPreview(plain, noPreview)).toEqual({ kind: 'digit' });
|
||
expect(selectPreview(plain)).toBeNull();
|
||
});
|
||
|
||
// Exact complete retained 40-row frames and native input from HOLD diagnostic 9a5717f1.
|
||
const shortPreviewFrame = {
|
||
"question": {
|
||
"header": "Review mode",
|
||
"multiSelect": false,
|
||
"options": [
|
||
{
|
||
"description": "The plan is good but could be great. Dream big — propose the ambitious version (e.g., CSV + JSON + import/restore + scheduled backups). Every expansion proposed individually for your approval. You opt in to each one.",
|
||
"label": "SCOPE EXPANSION"
|
||
},
|
||
{
|
||
"description": "The CSV export scope is the baseline. Separately surface cherry-pick opportunities (e.g., JSON format option, copy-to-clipboard, column filtering) for you to individually accept or defer. Neutral recommendations — you decide what's worth adding.",
|
||
"label": "SELECTIVE EXPANSION (recommended)",
|
||
"preview": "Baseline: CSV export button + client-side formatting\nCherry-picks surfaced individually:\n [ ] JSON export option?\n [ ] Copy-to-clipboard fallback?\n [ ] Column selection UI?\n [ ] Download filename with timestamp?"
|
||
},
|
||
{
|
||
"description": "The scope is right. Maximum rigor review only: architecture, security, edge cases (empty settings, special chars, large payloads), observability, deployment. No expansions surfaced.",
|
||
"label": "HOLD SCOPE"
|
||
},
|
||
{
|
||
"description": "Propose the minimum viable version: just the download trigger wired to the existing API, skip the formatter abstraction. Ruthlessly cut everything that isn't strictly necessary to ship the button.",
|
||
"label": "SCOPE REDUCTION"
|
||
}
|
||
],
|
||
"question": "D2 — Which review mode do you want for this plan?"
|
||
},
|
||
"before": "\n Read 1 file, ran 1 shell command\n\n● Now let me run the pre-review system audit and check for design docs.\n\n Ran 4 shell commands\n\n● No design doc, no handoff note, fresh repo. Let me run the premise analysis inline and present the implementation\n alternatives per the skill workflow.\n\n Ran 1 shell command\n\n● Updated plan\n ⎿ /plan to preview\n\n● Pre-review analysis done. Plan is well-framed — no premise issues. The question is how the formatter module connects\n to the download trigger. Per the skill workflow, I need your call on implementation approach before selecting review\n mode.\n\n● User answered Claude's questions:\n ⎿ · D1 — How should the CSV formatter connect to the settings export? (0C-bis: Implementation Approach) → A)\n Client-side only\n\n● Got it — client-side only, no formatter module. Now the mode selection you asked for.\n\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Review mode\n\nD2 — Which review mode do you want for this plan?\n\n❯ 1. SCOPE EXPANSION ┌──────────────────────────────────────────────────────┐\n 2. SELECTIVE EXPANSION │ No preview available │\n (recommended) └──────────────────────────────────────────────────────┘\n 3. HOLD SCOPE \n 4. SCOPE REDUCTION Notes: press n to add notes\n \n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Esc to cancel",
|
||
"focused": "\n Read 1 file, ran 1 shell command\n\n● Now let me run the pre-review system audit and check for design docs.\n\n Ran 4 shell commands\n\n● No design doc, no handoff note, fresh repo. Let me run the premise analysis inline and present the implementation\n alternatives per the skill workflow.\n\n Ran 1 shell command\n\n● Updated plan\n ⎿ /plan to preview\n\n● Pre-review analysis done. Plan is well-framed — no premise issues. The question is how the formatter module connects\n to the download trigger. Per the skill workflow, I need your call on implementation approach before selecting review\n mode.\n\n● User answered Claude's questions:\n ⎿ · D1 — How should the CSV formatter connect to the settings export? (0C-bis: Implementation Approach) → A)\n Client-side only\n\n● Got it — client-side only, no formatter module. Now the mode selection you asked for.\n\n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n ☐ Review mode\n\nD2 — Which review mode do you want for this plan?\n\n 1. SCOPE EXPANSION ┌──────────────────────────────────────────────────────┐\n 2. SELECTIVE EXPANSION │ No preview available │\n (recommended) └──────────────────────────────────────────────────────┘\n❯ 3. HOLD SCOPE \n 4. SCOPE REDUCTION Notes: press n to add notes\n \n────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\n Chat about this\n\nEnter to select · ↑/↓ to navigate · n to add notes · Esc to cancel"
|
||
};
|
||
const selectShortPreview = (visible = shortPreviewFrame.focused, input: NativeQuestion = shortPreviewFrame.question) =>
|
||
nativeQuestionSelection(input, visible, parseNumberedOptions(visible));
|
||
|
||
test('short preview panes retain the complete owned option column below their bottom', () => {
|
||
expect(selectShortPreview(shortPreviewFrame.before)).toEqual({ kind: 'preview', focusedIndex: 1 });
|
||
expect(selectShortPreview()).toEqual({ kind: 'preview', focusedIndex: 3 });
|
||
expect(selectShortPreview(shortPreviewFrame.focused.replace('❯ 3.', ' 3.').replace(' 4.', '❯ 4.')))
|
||
.toEqual({ kind: 'preview', focusedIndex: 4 });
|
||
});
|
||
|
||
test.each([
|
||
['changed label below pane', shortPreviewFrame.focused.replace('HOLD SCOPE', 'HOLD OTHER')],
|
||
['duplicate focus', shortPreviewFrame.focused.replace(' 4.', '❯ 4.')],
|
||
['missing focus', shortPreviewFrame.focused.replace('❯ 3.', ' 3.')],
|
||
['gapped index below pane', shortPreviewFrame.focused.replace('❯ 3.', '❯ 4.')],
|
||
['clipped final label', shortPreviewFrame.focused.replace('SCOPE REDUCTION', 'SCOPE RED')],
|
||
['missing label continuation', shortPreviewFrame.focused.replace(' (recommended)', ' '.repeat(17))],
|
||
['right-column decoy', shortPreviewFrame.focused.replace('Notes: press n to add notes', '❯ 4. SCOPE REDUCTION')],
|
||
['unknown right-column text', shortPreviewFrame.focused.replace('Notes: press n to add notes', 'HOLD SCOPE confirmed')],
|
||
['broken bottom corner', shortPreviewFrame.focused.replace('└', ' ')],
|
||
['changed footer', shortPreviewFrame.focused.replace('Enter to select', 'Enter to confirm')],
|
||
] as const)('short preview panes refuse incomplete or ambiguous frames: %s', (_name, visible) => {
|
||
expect(selectShortPreview(visible)).toBeNull();
|
||
});
|
||
|
||
test('a short preview pane cannot own a later menu or substitute for native preview inventory', () => {
|
||
const later = shortPreviewFrame.focused + '\n☐ Other question\nD3 — Choose another action?\n❯ 1. First\n 2. Second';
|
||
expect(selectShortPreview(later)).toBeNull();
|
||
const noPreview = { ...shortPreviewFrame.question, options: shortPreviewFrame.question.options.map(({ preview, ...option }) => option) };
|
||
expect(selectShortPreview(shortPreviewFrame.focused, noPreview)).toBeNull();
|
||
expect(() => nativeQuestionSelection(shortPreviewFrame.question, shortPreviewFrame.focused,
|
||
parseNumberedOptions(shortPreviewFrame.focused), [structuredClone(shortPreviewFrame.question)]))
|
||
.toThrow('Indistinguishable repeated native question');
|
||
});
|
||
|
||
|
||
function earlyFileCompletion(toolName: 'Write' | 'Edit' = 'Edit') {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: 'Review' } });
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PermissionRequest[0].hooks[0].command;
|
||
const firstInput = toolName === 'Edit'
|
||
? { file_path: path.join(config, 'plan.md'), old_string: 'Draft', new_string: 'Decision D9 approved', replace_all: false }
|
||
: { file_path: path.join(config, 'plan.md'), content: 'Decision D9 approved' };
|
||
const response = toolName === 'Edit'
|
||
? { filePath: firstInput.file_path, oldString: 'Draft', newString: 'Decision D9 approved', originalFile: 'Draft', structuredPatch: [], userModified: false, replaceAll: false }
|
||
: { type: 'create', filePath: firstInput.file_path, content: 'Decision D9 approved', structuredPatch: [], originalFile: null, userModified: false };
|
||
const emit = (hookEvent: string, input: unknown, extra: Record<string, unknown> = {}) => {
|
||
const child = Bun.spawnSync(['bash', '-c', command], { timeout: 5000,
|
||
stdin: Buffer.from(JSON.stringify({ hook_event_name: hookEvent, session_id: sessionId,
|
||
transcript_path: file, cwd: config, tool_name: toolName, tool_input: input, ...extra })), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(child.exitCode, child.stderr.toString()).toBe(0);
|
||
expect(child.stdout.length).toBe(0); expect(child.stderr.length).toBe(0);
|
||
};
|
||
const read = () => readPlanSkillQuestions(config, sessionId, source);
|
||
emit('PermissionRequest', firstInput);
|
||
const first = read().permissionRequests[0]!;
|
||
const dialog = toolName === 'Edit' ? createDialog('plan.md').replace('create', 'make this edit to') : createDialog('plan.md');
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(read(), dialog, granted, requests)).toBe(true);
|
||
const complete = (extra: Record<string, unknown> = {}) => emit('PostToolUse', firstInput,
|
||
{ tool_use_id: 'unflushed-file-1', tool_response: response, ...extra });
|
||
const nextInput = toolName === 'Edit' ? { ...firstInput, old_string: 'Decision D9 approved', new_string: 'Decision D9 approved\nSecurity review' }
|
||
: { ...firstInput, content: 'Decision D9 approved\nSecurity review' };
|
||
const nextDialog = toolName === 'Edit' ? dialog : dialog.replace('create', 'overwrite');
|
||
return { source, read, emit, first, firstInput, response, complete, nextInput, nextDialog, granted, requests };
|
||
}
|
||
|
||
test.each(['Write', 'Edit'] as const)('owned PostToolUse completes %s before transcript publication and permits one fresh next grant', toolName => {
|
||
const s = earlyFileCompletion(toolName);
|
||
s.complete();
|
||
expect(fs.readFileSync(file, 'utf8')).not.toContain('unflushed-file-1');
|
||
expect(s.read().permissionRequests[0]).toMatchObject({ requestId: s.first.requestId, result: 'completed', nativeToolId: 'unflushed-file-1', completionEvidence: 'PostToolUse' });
|
||
expect(s.read().calls).toEqual([]); expect(s.read().ready).toBe(false);
|
||
s.emit('PermissionRequest', s.nextInput);
|
||
expect(reserveNativePermissionGrant(s.read(), s.nextDialog, s.granted, s.requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(s.read(), s.nextDialog, s.granted, s.requests)).toBe(false);
|
||
expect(s.granted.size).toBe(2);
|
||
// Matching later JSONL is corroboration, not a second completion/grant.
|
||
write(nativeWrite('unflushed-file-1', s.firstInput, config, toolName), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString()), toolUseResult: s.response,
|
||
});
|
||
expect(reserveNativePermissionGrant(s.read(), s.nextDialog, s.granted, s.requests)).toBe(false);
|
||
});
|
||
|
||
|
||
// A report may be created, overwritten after findings, then overwritten again
|
||
// after a later section. These are real owned hook events, not inferred grants.
|
||
async function repeatedWriteSequence(variant = 'fresh') {
|
||
const s = earlyFileCompletion('Write'); s.complete();
|
||
s.emit('PermissionRequest', s.nextInput);
|
||
expect(reserveNativePermissionGrant(s.read(), s.nextDialog, s.granted, s.requests)).toBe(true);
|
||
const second = s.read().permissionRequests.find(item => item.result === 'pending')!;
|
||
const response = { ...s.response, type: 'update', content: s.nextInput.content,
|
||
originalFile: s.firstInput.content };
|
||
if (variant === 'failed-ack') s.emit('PostToolUseFailure', s.nextInput, { tool_use_id: 'unflushed-file-2', error: 'write failed' });
|
||
else if (variant !== 'no-ack') s.emit('PostToolUse', s.nextInput, { tool_use_id: 'unflushed-file-2', tool_response: response });
|
||
await Bun.sleep(5);
|
||
const thirdInput = variant === 'identical-input' ? s.nextInput
|
||
: variant === 'extra-field-only' ? { ...s.nextInput, incidental: true }
|
||
: { ...s.nextInput, content: s.nextInput.content + '\n\n## GSTACK REVIEW REPORT\nCEO review complete.' };
|
||
s.emit('PermissionRequest', thirdInput);
|
||
return { ...s, second, thirdInput };
|
||
}
|
||
|
||
test('owned repeated Write overwrite follows only its prior successful ACK and grants once', async () => {
|
||
const s = await repeatedWriteSequence();
|
||
const native = s.read(), prior = native.permissionRequests.find(item => item.requestId === s.second.requestId)!;
|
||
const current = native.permissionRequests.find(item => item.result === 'pending')!;
|
||
expect(prior).toMatchObject({ result: 'completed', nativeToolId: 'unflushed-file-2', completionEvidence: 'PostToolUse' });
|
||
expect(current.requestId).not.toBe(prior.requestId);
|
||
expect(current.capturedAtMs).toBeGreaterThan(prior.nativeResultAtMs!);
|
||
expect(current.input).toEqual(s.thirdInput);
|
||
expect(fs.readFileSync(file, 'utf8')).not.toContain('unflushed-file-2');
|
||
expect(reserveNativePermissionGrant(native, s.nextDialog, s.granted, s.requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, s.nextDialog, s.granted, s.requests)).toBe(false);
|
||
expect(s.granted.size).toBe(3);
|
||
expect(s.requests.get(`Write:${path.join(config, 'plan.md')}`)?.requestId).toBe(current.requestId);
|
||
});
|
||
|
||
test.each(['no-ack', 'failed-ack', 'identical-input', 'extra-field-only', 'before-ack', 'equal-ack', 'missing-native-id',
|
||
'wrong-cwd', 'wrong-path', 'wrong-tool', 'wrong-frame', 'replayed-request', 'multiple-writers'])
|
||
('owned repeated Write overwrite preserves refusal for %s', async variant => {
|
||
const s = await repeatedWriteSequence(variant);
|
||
const before = [...s.granted], requestsBefore = [...s.requests];
|
||
const attempt = () => {
|
||
const native = s.read(), current = native.permissionRequests.find(item => item.result === 'pending' && item.requestId !== s.second.requestId)!;
|
||
const prior = native.permissionRequests.find(item => item.requestId === s.second.requestId)!;
|
||
if (variant === 'before-ack') current.capturedAtMs = prior.nativeResultAtMs! - 1;
|
||
if (variant === 'equal-ack') current.capturedAtMs = prior.nativeResultAtMs!;
|
||
if (variant === 'missing-native-id') delete prior.nativeToolId;
|
||
if (variant === 'wrong-cwd') current.cwd = path.dirname(config);
|
||
if (variant === 'wrong-path') current.input.file_path = path.join(config, 'sibling.md');
|
||
if (variant === 'wrong-tool') current.name = 'Edit';
|
||
if (variant === 'replayed-request') current.requestId = s.second.requestId;
|
||
if (variant === 'multiple-writers') native.permissionRequests.push({ ...current, requestId: 'other-writer' });
|
||
const dialog = variant === 'wrong-frame' ? s.nextDialog.replace('overwrite', 'create') : s.nextDialog;
|
||
if (variant === 'replayed-request') expect(reserveNativePermissionGrant(native, dialog, s.granted, s.requests)).toBe(false);
|
||
else expect(() => reserveNativePermissionGrant(native, dialog, s.granted, s.requests)).toThrow();
|
||
};
|
||
// Duplicate input or a failed native hook is rejected before reservation.
|
||
if (variant === 'identical-input' || variant === 'failed-ack') expect(attempt).toThrow();
|
||
else attempt();
|
||
expect([...s.granted]).toEqual(before); expect([...s.requests]).toEqual(requestsBefore);
|
||
});
|
||
|
||
test.each(['missing', 'foreign-session', 'subagent', 'failure', 'malformed-response', 'wrong-response-path', 'changed-input', 'duplicate-request', 'duplicate-completion-id', 'late-completion'])
|
||
('owned PostToolUse cannot retire an unproven request (%s)', variant => {
|
||
const s = earlyFileCompletion();
|
||
if (variant === 'duplicate-request') s.emit('PermissionRequest', s.firstInput);
|
||
if (variant === 'late-completion') s.emit('PermissionRequest', s.nextInput);
|
||
if (variant === 'failure') s.emit('PostToolUseFailure', s.firstInput, { tool_use_id: 'unflushed-file-1', error: 'Failed to write' });
|
||
else if (variant !== 'missing') s.complete(
|
||
variant === 'foreign-session' ? { session_id: '00000000-0000-4000-8000-000000000002' }
|
||
: variant === 'subagent' ? { agent_id: 'other-worker' }
|
||
: variant === 'malformed-response' ? { tool_response: { success: true } }
|
||
: variant === 'wrong-response-path' ? { tool_response: { ...s.response, filePath: '/wrong/plan.md' } }
|
||
: variant === 'changed-input' ? { tool_input: { ...s.firstInput, new_string: 'Not the requested edit' } } : {});
|
||
if (variant === 'duplicate-completion-id') s.complete({ tool_use_id: 'second-completion-for-one-request' });
|
||
if (variant !== 'late-completion') s.emit('PermissionRequest', s.nextInput);
|
||
expect(() => reserveNativePermissionGrant(s.read(), s.nextDialog, s.granted, s.requests)).toThrow();
|
||
expect(s.granted.size).toBe(1);
|
||
});
|
||
|
||
test.each(['name', 'input', 'cwd', 'unfinished-input', 'error', 'raw-response'])
|
||
('owned PostToolUse refuses later native contradiction (%s)', variant => {
|
||
const s = earlyFileCompletion(); s.complete();
|
||
expect(s.read().permissionRequests[0].result).toBe('completed');
|
||
const input = variant.includes('input') ? { ...s.firstInput, new_string: 'Conflicting native edit' } : s.firstInput;
|
||
write(nativeWrite('unflushed-file-1', input, variant === 'cwd' ? path.dirname(config) : config,
|
||
variant === 'name' ? 'Write' : 'Edit', variant === 'unfinished-input' ? null : 'tool_use'), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString(), variant === 'error'),
|
||
toolUseResult: variant === 'raw-response' ? { ...s.response, newString: 'Different completed edit' } : s.response,
|
||
});
|
||
expect(s.read).toThrow(/changed input|conflicts with/);
|
||
expect(s.granted.size).toBe(1);
|
||
});
|
||
|
||
test('a duplicate success callback cannot replace its first immutable completion', () => {
|
||
const s = earlyFileCompletion(); s.complete();
|
||
expect(s.read().permissionRequests[0].result).toBe('completed');
|
||
s.complete({ tool_response: { ...s.response, newString: 'A different response' } });
|
||
expect(s.read).toThrow('capture failed');
|
||
});
|
||
|
||
test('unrequested successful file work carries no permission, AUQ or completion-modal authority', () => {
|
||
const s = earlyFileCompletion();
|
||
const other = { ...s.firstInput, file_path: path.join(config, 'auto-allowed.md') };
|
||
s.emit('PostToolUse', other, { tool_use_id: 'auto-allowed', tool_response: { ...s.response, filePath: other.file_path } });
|
||
const native = s.read();
|
||
expect(native.permissionRequests[0].result).toBe('pending');
|
||
expect(native.permissionTools).toEqual([]); expect(native.permissionResults).toEqual([]);
|
||
expect(native.calls).toEqual([]); expect(native.ready).toBe(false);
|
||
});
|
||
|
||
test('native storage may clear large Edit originalFile bytes but no other completion fields', () => {
|
||
const s = earlyFileCompletion(); s.complete();
|
||
write(nativeWrite('unflushed-file-1', s.firstInput, config, 'Edit'), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString()), toolUseResult: { ...s.response, originalFile: '' },
|
||
});
|
||
expect(s.read().permissionRequests[0]).toMatchObject({ result: 'completed', completionEvidence: 'PostToolUse' });
|
||
});
|
||
|
||
|
||
test.each(['preserved', 'clearable', 'cleared-mismatch'])('Write update storage preserves the exact native clearing condition (%s)', variant => {
|
||
const s = earlyFileCompletion('Write');
|
||
const response = { ...s.response, type: 'update', originalFile: variant === 'clearable' ? 'Previous plan' : null, structuredPatch: [] };
|
||
s.complete({ tool_response: response });
|
||
const stored = variant === 'preserved' ? response : { ...response, content: '', originalFile: null };
|
||
write(nativeWrite('unflushed-file-1', s.firstInput), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString()), toolUseResult: stored,
|
||
});
|
||
if (variant === 'cleared-mismatch') expect(s.read).toThrow('conflicts with its later result');
|
||
else expect(s.read().permissionRequests[0]).toMatchObject({ result: 'completed', completionEvidence: 'PostToolUse' });
|
||
});
|
||
|
||
test.each([
|
||
['small', 'Draft', false],
|
||
['10000 units', 'a'.repeat(10_000), false],
|
||
['10001 units', 'a'.repeat(10_001), true],
|
||
['10000 UTF16 units despite more UTF8 bytes', '😀'.repeat(5_000), false],
|
||
['10001 UTF16 units', '😀'.repeat(5_000) + 'x', true],
|
||
['already cleared', '', false],
|
||
] as const)('native append storage nulls Edit originalFile only above its exact bound (%s)', (_name, originalFile, accepted) => {
|
||
const s = earlyFileCompletion();
|
||
const response = { ...s.response, originalFile };
|
||
s.complete({ tool_response: response });
|
||
expect(s.read().permissionRequests[0].result).toBe('completed');
|
||
write(nativeWrite('unflushed-file-1', s.firstInput, config, 'Edit'), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString()),
|
||
toolUseResult: { ...response, originalFile: null },
|
||
});
|
||
if (accepted) expect(s.read().permissionRequests[0]).toMatchObject({ result: 'completed', completionEvidence: 'PostToolUse' });
|
||
else expect(s.read).toThrow('conflicts with its later result');
|
||
expect(s.granted.size).toBe(1);
|
||
});
|
||
|
||
test.each(['raw', 'tool-specific-stored', 'append-raw', 'append-stored'] as const)
|
||
('native append storage composes with exact Write storage (%s)', variant => {
|
||
const s = earlyFileCompletion('Write');
|
||
const response = { ...s.response, type: 'update', originalFile: 'a'.repeat(10_001) };
|
||
s.complete({ tool_response: response });
|
||
const stored = variant === 'tool-specific-stored' || variant === 'append-stored'
|
||
? { ...response, content: '', originalFile: null }
|
||
: variant === 'append-raw' ? { ...response, originalFile: null } : response;
|
||
write(nativeWrite('unflushed-file-1', s.firstInput), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString()), toolUseResult: stored,
|
||
});
|
||
expect(s.read().permissionRequests[0]).toMatchObject({ result: 'completed', completionEvidence: 'PostToolUse' });
|
||
});
|
||
|
||
test.each(['changed-field', 'missing-field', 'extra-field', 'error'] as const)
|
||
('native append storage preserves every other result conflict (%s)', variant => {
|
||
const s = earlyFileCompletion();
|
||
const response = { ...s.response, originalFile: 'a'.repeat(10_001) };
|
||
s.complete({ tool_response: response });
|
||
const stored: Record<string, unknown> = { ...response, originalFile: null };
|
||
if (variant === 'changed-field') stored.newString = 'Different completed edit';
|
||
if (variant === 'missing-field') delete stored.originalFile;
|
||
if (variant === 'extra-field') stored.unexplained = true;
|
||
write(nativeWrite('unflushed-file-1', s.firstInput, config, 'Edit'), {
|
||
...nativeWriteResult('unflushed-file-1', new Date().toISOString(), variant === 'error'), toolUseResult: stored,
|
||
});
|
||
expect(s.read).toThrow('conflicts with its later result');
|
||
expect(s.granted.size).toBe(1);
|
||
});
|
||
|
||
// Native Design counting failure, Claude 2.1.263: a 105-line wireframe scrolls
|
||
// its title/subtitle off screen. The current basename and complete directory
|
||
// in option 2 still identify the one-time option 1 permission exactly.
|
||
const clippedDesignFrame = " 76 <div class=\"field invalid\">\n 77 <label for=\"email\">Email</label> 1 file changed ✕\n 78 <input id=\"email\" value=\"margarethe@acme\" aria-invalid=\"true\" aria-describedby=\"email-err\">\n 79 <div class=\"error\" id=\"email-err\" role=\"alert\"><span aria-hidden=\"true\">!</span><span>Enter a full email address, like name@company.c gstack-test-plan-design.md\n om.</span></div>\n 80 </div> ────────────────────────────────────────────────────────────────────────────────────────\n 81 </section> gstack-test-plan-design.md (untracked)\n 82 C t u ────────────────────────────────────────────────────────────────────────────────────────\n 83 P<section id=\"notifications\" aria-labelledby=\"h-notif\"> New file not yet staged.\n 84 L <h2 id=\"h-notif\">Notifications</h2> Run `git add :/gstack-test-plan-design.md` to see line counts.\n 85 <div class=\"field\">\n 86 F <label for=\"digest\">Weekly digest email</label>\n 87 C <input id=\"digest\" value=\"Every Monday, 9:00\"> \n 88 C <div class=\"help\">Sent in your account timezone.</div>\n 89 </div>\n 90 T</secsion>\n 91 \n 92 <section id=\"api-keys\" aria-labelledby=\"h-keys\">\n 93 P <h2 id=\"h-keys\">API keys</h2> \n 94 <div class=\"empty\">\n 95 <p>No keys yet. Keys let scripts and integrations act on your behalf.</p>\n 96 <button class=\"btn\">Create your first key</button>\n 97 </div>\n 98 </section>\n 99\n 100 <div class=\"note\">Wireframe only. Designer mockup generation was unavailable (no OpenAI key). Toast below shows the post-save success sta\n te.</div>\n 101 </main>\n 102 </div>\n 103 <div class=\"toast\" role=\"status\">Saved. Changes are live.</div>\n 104 </body>\n 105 </html>\n╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌\n Do you want to create wireframe-desktop.html?\n ❯ 1. Yes \n 2. Yes, and switch to accept edits (auto-approve file edits and common file commands) for this session; Yes, and always allow access to\n /home/vercel-sandbox/.gstack/projects/gstack-e2e-plan-design-16ewXN/designs/settings-page-20260910 for this session (shift+tab)\n 3. No\n\n Esc to cancel · Tab to amend";
|
||
const clippedDesignOwner = {"id": "toolu_01VQSgtivSuKpKci3w9RXzbn", "name": "Write", "cwd": "/tmp/gstack-paid-shard-klXxAu/tmp/gstack-e2e-plan-design-16ewXN", "input": {"file_path": "/home/vercel-sandbox/.gstack/projects/gstack-e2e-plan-design-16ewXN/designs/settings-page-20260910/wireframe-desktop.html"}};
|
||
test('clipped native create header binds its fully displayed parent and grants only once', () => {
|
||
const filePath = clippedDesignOwner.input.file_path;
|
||
expect(currentFilePermissionTarget(clippedDesignFrame)).toEqual({ operation: 'create', filePath });
|
||
expect(nativePermissionKey(clippedDesignOwner, clippedDesignFrame)).toBe('Write:' + filePath);
|
||
const native = { permissionTools: [clippedDesignOwner], permissionResults: [], permissionRequestCapture: true,
|
||
permissionRequests: [{ requestId: 'owned-clipped-request', capturedAtMs: 1, name: 'Write' as const,
|
||
input: clippedDesignOwner.input, cwd: clippedDesignOwner.cwd, result: 'pending' as const, nativeToolId: clippedDesignOwner.id }] };
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(reserveNativePermissionGrant(native, clippedDesignFrame, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(native, clippedDesignFrame, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual(['request:owned-clipped-request']);
|
||
expect([...requests.keys()]).toEqual(['Write:' + filePath]);
|
||
});
|
||
test('clipped create permission refuses another basename, parent, malformed directory, or conflicting header', () => {
|
||
const filePath = clippedDesignOwner.input.file_path, directory = path.dirname(filePath);
|
||
for (const invalid of [
|
||
clippedDesignFrame.replace('Do you want to create wireframe-desktop.html?', 'Do you want to create other.html?'),
|
||
clippedDesignFrame.replace(directory, directory + '-sibling'),
|
||
clippedDesignFrame.replace(directory, path.dirname(directory)),
|
||
clippedDesignFrame.replace(directory, directory.replace('/designs/', '/desi…/')),
|
||
clippedDesignFrame.replace(directory, directory.replace('/designs/', '/desi\n gns/')),
|
||
clippedDesignFrame.replace(directory, 'relative/designs'),
|
||
clippedDesignFrame.replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. Yes,', ' ❯ 2. Yes,'),
|
||
' Create file\n another/wireframe-desktop.html\n' + clippedDesignFrame,
|
||
'─'.repeat(240) + '\n Create file\n another/wireframe-desktop.html\n' + clippedDesignFrame,
|
||
'─'.repeat(240) + '\n Create file\n ' + filePath + '\n Create file\n ' + filePath + '\n' + clippedDesignFrame,
|
||
]) expect(() => nativePermissionKey(clippedDesignOwner, invalid)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...clippedDesignOwner, input: { file_path: filePath + '.other' } }, clippedDesignFrame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...clippedDesignOwner, name: 'Edit' }, clippedDesignFrame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
|
||
// The execution hook contains CLI-validated nested objects; the raw transcript
|
||
// can retain undeclared model keys that never reach the permission component.
|
||
for (const stopReason of [null, 'tool_use']) {
|
||
test(`owned question execution strips only corroborated nested schema extras (${stopReason})`, () => {
|
||
const native = call('schema-extra', [JSON.parse(JSON.stringify(question))]);
|
||
native.message.stop_reason = stopReason as any;
|
||
(native.message.content[0].input.questions[0] as any).multiSelar = false;
|
||
(native.message.content[0].input.questions[0].options[0] as any).undeclaredPreview = 'not displayed';
|
||
write(native);
|
||
const early = earlyQuestions(); early.emit('schema-extra');
|
||
const pending = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(pending.calls).toEqual([{ id: 'schema-extra', questions: [question], result: 'pending' }]);
|
||
fs.appendFileSync(file, JSON.stringify(nativeWriteResult('schema-extra')) + '\n');
|
||
expect(readPlanSkillQuestions(config, sessionId, early.source).calls).toEqual([
|
||
{ id: 'schema-extra', questions: [question], result: 'answered' },
|
||
]);
|
||
expect(pending.permissionRequests).toEqual([]); expect(pending.permissionTools).toEqual([]);
|
||
});
|
||
}
|
||
|
||
test('a raw duplicate question cannot erase nested changes without an owned execution hook', () => {
|
||
const modified = call('raw-extra', [JSON.parse(JSON.stringify(question))]);
|
||
(modified.message.content[0].input.questions[0] as any).multiSelar = false;
|
||
write(call('raw-extra', [JSON.parse(JSON.stringify(question))]), modified);
|
||
expect(() => readPlanSkillQuestions(config, sessionId)).toThrow('changed input');
|
||
});
|
||
|
||
test.each(['question', 'header', 'multiSelect', 'label', 'description', 'preview', 'kind', 'metadata', 'top-extra'])
|
||
('owned nested schema equivalence preserves changed %s refusal', field => {
|
||
const native = call('schema-conflict', [JSON.parse(JSON.stringify(question))]);
|
||
const input: any = native.message.content[0].input;
|
||
input.questions[0].multiSelar = false;
|
||
if (field === 'question' || field === 'header') input.questions[0][field] = 'different';
|
||
else if (field === 'multiSelect') input.questions[0].multiSelect = 'false';
|
||
else if (field === 'label' || field === 'description') input.questions[0].options[0][field] = 'different';
|
||
else if (field === 'preview') input.questions[0].options[0].preview = null;
|
||
else if (field === 'kind') input.questions[0].kind = 'text';
|
||
else input[field] = { changed: true };
|
||
write(native); const early = earlyQuestions(); early.emit('schema-conflict');
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('changed input');
|
||
});
|
||
|
||
test('changed execution hooks cannot use the transcript-only schema projection', () => {
|
||
write(); const early = earlyQuestions(); early.emit('hook-conflict');
|
||
early.emit('hook-conflict', { questions: [{ ...question, multiSelar: false }] });
|
||
expect(() => readPlanSkillQuestions(config, sessionId, early.source)).toThrow('Native question event capture failed');
|
||
});
|
||
|
||
function bashHooks() {
|
||
write({ type: 'user', sessionId, message: { role: 'user', content: 'Review' } });
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PreToolUse[0].hooks[0].command;
|
||
const input = { command: 'printf %s ready > probe.txt', description: 'Write the owned marker' };
|
||
const response = { stdout: 'ready', stderr: '', interrupted: false };
|
||
const emit = (event: string, id = 'bash-1', toolInput: unknown = input, extra: Record<string, unknown> = {}) => {
|
||
const result = Bun.spawnSync(['bash', '-c', command], { timeout: 5000, stdin: Buffer.from(JSON.stringify({
|
||
hook_event_name: event, session_id: sessionId, transcript_path: file, cwd: config,
|
||
tool_name: 'Bash', tool_use_id: id, tool_input: toolInput, ...extra,
|
||
})), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(result.exitCode).toBe(0); expect(result.stdout.length).toBe(0); expect(result.stderr.length).toBe(0);
|
||
};
|
||
const read = () => readPlanSkillQuestions(config, sessionId, source);
|
||
const post = (output: unknown = response, extra: Record<string, unknown> = {}) => emit('PostToolUse', 'bash-1', input, { tool_response: output, ...extra });
|
||
return { source, input, response, emit, post, read };
|
||
}
|
||
|
||
test('captured directory Bash permission fixture selects every existing permission-helper consumer', () => {
|
||
const selected = selectTests(['test/fixtures/bash-directory-permission.json'], E2E_TOUCHFILES);
|
||
expect(selected.reason).toBe('diff');
|
||
expect(selected.selected.sort()).toEqual(selectTests(['test/helpers/plan-skill-questions.ts'], E2E_TOUCHFILES).selected.sort());
|
||
});
|
||
|
||
test('captured directory Bash card requires its exact owned request and permits only one grant', () => {
|
||
const { input, card, nativeId } = retainedBashDirectory;
|
||
const s = bashHooks(); s.emit('PreToolUse', nativeId, input);
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
// Legacy callers have no native binding. Only the bound driver opts in.
|
||
expect(isPermissionDialogVisible(card)).toBe(false);
|
||
expect(isPermissionDialogVisible(card, true)).toBe(true);
|
||
expect(nativePermissionKey({ id: nativeId, name: 'Bash', input }, card)).toBe('Bash:' + input.command);
|
||
expect(reserveNativePermissionGrant(s.read(), card, granted, requests)).toBe(false);
|
||
s.emit('PermissionRequest', nativeId, input);
|
||
expect(reserveNativePermissionGrant(s.read(), card, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(s.read(), card, granted, requests)).toBe(false);
|
||
expect([...granted]).toEqual([nativeId]);
|
||
s.emit('PostToolUse', nativeId, input, { tool_response: s.response });
|
||
expect(s.read().permissionResults).toEqual([{ id: nativeId, result: 'completed' }]);
|
||
expect(s.read().permissionTools).toEqual([]);
|
||
s.emit('PreToolUse', 'next', input); s.emit('PermissionRequest', 'next', input);
|
||
expect(() => reserveNativePermissionGrant(s.read(), card, granted, requests)).toThrow('stale rendering');
|
||
});
|
||
|
||
const damagedDirectoryBashCards = {
|
||
missingPath: retainedBashDirectory.card.replace(' and /home/vercel-sandbox/.gstack from this project', ' and from this project'),
|
||
relativePath: retainedBashDirectory.card.replace(' and /home/vercel-sandbox/.gstack', ' and ../.gstack'),
|
||
ellipsis: retainedBashDirectory.card.replace(' and /home/vercel-sandbox/.gstack', ' and /home/…/.gstack'),
|
||
trailingPermission: retainedBashDirectory.card.replace(' from this project', ' from this project and allow all commands'),
|
||
wrappedPath: retainedBashDirectory.card.replace(' and /home/vercel-sandbox/.gstack', '\n and /home/vercel-sandbox/.gstack'),
|
||
appendedContinuation: retainedBashDirectory.card.replace(' from this project', ' from this project\n Always allow all commands'),
|
||
wrongFocus: retainedBashDirectory.card.replace(' ❯ 1. Yes', ' 1. Yes').replace(' 2. Yes', ' ❯ 2. Yes'),
|
||
missingNo: retainedBashDirectory.card.replace(' 3. No\n', ''),
|
||
history: retainedBashDirectory.card + '\n❯ New unrelated draft',
|
||
};
|
||
test.each(Object.entries(damagedDirectoryBashCards))('captured directory Bash card refuses %s controls', (_name, card) => {
|
||
expect(isPermissionDialogVisible(card, true)).toBe(false);
|
||
expect(() => nativePermissionKey({ id: 'captured', name: 'Bash', input: retainedBashDirectory.input }, card)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test.each(['command', 'cwd'])('captured directory Bash request cannot change native %s authority', variant => {
|
||
const s = bashHooks(); const { input, card, nativeId } = retainedBashDirectory;
|
||
s.emit('PreToolUse', nativeId, input);
|
||
s.emit('PermissionRequest', nativeId, variant === 'command' ? { ...input, command: input.command + '\nprintf changed' } : input,
|
||
variant === 'cwd' ? { cwd: '/different-owner' } : {});
|
||
const granted = new Set<string>();
|
||
if (variant === 'cwd') expect(() => s.read()).toThrow();
|
||
else expect(reserveNativePermissionGrant(s.read(), card, granted, new Map())).toBe(false);
|
||
expect([...granted]).toEqual([]);
|
||
});
|
||
|
||
test('owned Bash hooks authorize one exact current command and retire it before transcript persistence', () => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
const frame = nativeBashDialog(s.input.command, s.input.description);
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
expect(s.read().permissionTools).toEqual([{ id: 'bash-1', name: 'Bash', input: s.input, cwd: config, bashPermissionRequestId: null }]);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
s.emit('PermissionRequest');
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
s.post();
|
||
expect(s.read().permissionTools).toEqual([]);
|
||
expect(s.read().permissionResults).toEqual([{ id: 'bash-1', result: 'completed' }]);
|
||
expect(fs.readFileSync(file, 'utf8')).not.toContain('bash-1');
|
||
const next = { ...s.input, command: 'printf %s next > next.txt' };
|
||
s.emit('PreToolUse', 'bash-2', next); s.emit('PermissionRequest', 'ignored-native-id', next);
|
||
expect(reserveNativePermissionGrant(s.read(), nativeBashDialog(next.command, next.description), granted, requests)).toBe(true);
|
||
expect([...granted]).toEqual(['bash-1', 'bash-2']);
|
||
expect(s.read().calls).toEqual([]); expect(s.read().ready).toBe(false); expect(s.read().permissionRequests).toEqual([]);
|
||
});
|
||
|
||
test.each(['foreign', 'sidechain', 'orphan', 'screen-only'] as const)('owned Bash hooks do not invent invocation authority (%s)', variant => {
|
||
const s = bashHooks();
|
||
if (variant !== 'orphan' && variant !== 'screen-only') s.emit('PreToolUse', 'bash-1', s.input,
|
||
variant === 'foreign' ? { session_id: '00000000-0000-4000-8000-000000000099' } : { agent_id: 'sidechain' });
|
||
if (variant === 'orphan') s.post();
|
||
expect(s.read().permissionTools).toEqual([]); expect(s.read().permissionResults).toEqual([]);
|
||
expect(reserveNativePermissionGrant(s.read(), nativeBashDialog(s.input.command, s.input.description), new Set(), new Map())).toBe(false);
|
||
});
|
||
|
||
test.each(['command', 'description', 'cwd', 'name', 'unfinished'] as const)('owned Bash hooks reject later invocation conflict (%s)', variant => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
write(nativeWrite('bash-1', variant === 'command' || variant === 'description' ? { ...s.input, [variant]: 'changed' } : s.input,
|
||
variant === 'cwd' ? path.dirname(config) : config, variant === 'name' ? 'Write' : 'Bash', variant === 'unfinished' ? null : 'tool_use'));
|
||
if (variant === 'unfinished') {
|
||
expect(s.read().permissionTools[0]?.id).toBe('bash-1');
|
||
write(nativeWrite('bash-1', { ...s.input, command: 'changed' }, config, 'Bash', null));
|
||
}
|
||
expect(() => s.read()).toThrow('Native tool changed');
|
||
});
|
||
|
||
test.each(['changed-input', 'wrong-cwd', 'malformed', 'error-envelope', 'two-completions'] as const)
|
||
('owned Bash hooks refuse invalid or conflicting completion (%s)', variant => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
if (variant === 'changed-input') s.emit('PostToolUse', 'bash-1', { ...s.input, command: 'changed' }, { tool_response: s.response });
|
||
else if (variant === 'two-completions') {
|
||
s.post(); s.emit('PostToolUseFailure', 'bash-1', s.input, { error: 'Command failed', is_interrupt: false });
|
||
} else s.post(variant === 'malformed' ? { ...s.response, interrupted: 'false' } : s.response,
|
||
variant === 'wrong-cwd' ? { cwd: path.dirname(config) } : variant === 'error-envelope' ? { error: 'Failed' } : {});
|
||
expect(() => s.read()).toThrow(variant === 'changed-input' ? 'Native Bash completion changed'
|
||
: variant === 'two-completions' ? 'Conflicting native Bash completion' : 'Native question event capture failed');
|
||
});
|
||
|
||
test.each(['native-failure', 'interrupt-post', 'interrupt-failure'] as const)('owned Bash hooks preserve failed invocation status (%s)', variant => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
const grants = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
const frame = nativeBashDialog(s.input.command, s.input.description);
|
||
s.emit('PermissionRequest');
|
||
expect(reserveNativePermissionGrant(s.read(), frame, grants, requests)).toBe(true);
|
||
if (variant === 'interrupt-post') s.post({ ...s.response, interrupted: true });
|
||
else s.emit('PostToolUseFailure', 'bash-1', s.input, { error: 'Command failed with exit code 1', is_interrupt: variant === 'interrupt-failure' });
|
||
expect(s.read().permissionResults).toEqual([{ id: 'bash-1', result: 'error' }]);
|
||
expect(s.read().permissionTools).toEqual([]);
|
||
s.emit('PreToolUse', 'bash-2'); s.emit('PermissionRequest');
|
||
expect(() => reserveNativePermissionGrant(s.read(), frame, grants, requests)).toThrow();
|
||
});
|
||
|
||
test.each(['background', 'semantic-nonzero', 'stored-output', 'identical-repeat'] as const)
|
||
('owned Bash hooks retain native invocation semantics (%s)', variant => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
const response = variant === 'background' ? { ...s.response, stdout: '', backgroundTaskId: 'task-1',
|
||
backgroundedByUser: true, timedOutAfterMs: 3000 } : variant === 'semantic-nonzero'
|
||
? { ...s.response, stdout: '', returnCodeInterpretation: 'No matches found' } : s.response;
|
||
s.post(response); const first = s.read();
|
||
expect(first.permissionResults).toEqual([{ id: 'bash-1', result: 'completed' }]);
|
||
if (variant === 'identical-repeat') { s.emit('PreToolUse'); s.post(response); expect(s.read()).toEqual(first); }
|
||
write(nativeWrite('bash-1', s.input, config, 'Bash'), { type: 'user', sessionId,
|
||
toolUseResult: variant === 'stored-output' ? { ...response, stdout: '', stderr: '' } : response,
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'bash-1', content: 'native formatted output', is_error: false }] } });
|
||
expect(s.read().permissionResults).toEqual(first.permissionResults);
|
||
});
|
||
|
||
test.each(['error', 'raw-output', 'success-after-failure', 'failure-text'] as const)
|
||
('owned Bash hooks refuse contradictory persisted results (%s)', variant => {
|
||
const s = bashHooks(); s.emit('PreToolUse');
|
||
const failure = variant === 'success-after-failure' || variant === 'failure-text';
|
||
if (failure) s.emit('PostToolUseFailure', 'bash-1', s.input, { error: 'Native failure', is_interrupt: false }); else s.post();
|
||
write(nativeWrite('bash-1', s.input, config, 'Bash'), { type: 'user', sessionId,
|
||
...(!failure ? { toolUseResult: variant === 'raw-output' ? { ...s.response, interrupted: true } : s.response } : {}),
|
||
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'bash-1',
|
||
content: variant === 'failure-text' ? 'Different error' : failure ? 'Native failure' : 'ready',
|
||
is_error: variant === 'error' || variant === 'failure-text' }] } });
|
||
expect(() => s.read()).toThrow('Native Bash completion conflicts');
|
||
});
|
||
|
||
|
||
|
||
test.each(['changed-command', 'changed-description', 'changed-option', 'orphan', 'duplicate-request', 'ambiguous-invocations'] as const)
|
||
('owned Bash hooks require one effective post-Pre permission input (%s)', variant => {
|
||
const s = bashHooks();
|
||
if (variant !== 'orphan') s.emit('PreToolUse');
|
||
if (variant === 'ambiguous-invocations') s.emit('PreToolUse', 'bash-2');
|
||
const effective = variant === 'changed-command' ? { ...s.input, command: 'changed' }
|
||
: variant === 'changed-description' ? { ...s.input, description: 'Changed' }
|
||
: variant === 'changed-option' ? { ...s.input, dangerouslyDisableSandbox: true } : s.input;
|
||
s.emit('PermissionRequest', 'not-a-native-owner', effective);
|
||
if (variant === 'duplicate-request') s.emit('PermissionRequest', 'also-not-a-native-owner', effective);
|
||
if (variant === 'duplicate-request' || variant === 'ambiguous-invocations') expect(() => s.read()).toThrow('native Bash permission request');
|
||
else expect(reserveNativePermissionGrant(s.read(), nativeBashDialog(s.input.command, s.input.description), new Set(), new Map())).toBe(false);
|
||
});
|
||
|
||
test('owned Bash hooks retain harmless Pre safety settings and require final permission pairing', () => {
|
||
fs.writeFileSync(path.join(config, 'settings.json'), JSON.stringify({ hooks: { PreToolUse: [
|
||
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'exit 0' }] },
|
||
] } }));
|
||
const s = bashHooks(); s.emit('PreToolUse'); s.emit('PermissionRequest');
|
||
expect(reserveNativePermissionGrant(s.read(), nativeBashDialog(s.input.command, s.input.description), new Set(), new Map())).toBe(true);
|
||
});
|
||
|
||
|
||
test('native Bash projection preserves literal U+2026 without widening to complex Unicode', () => {
|
||
const command = 'printf "Saving…"';
|
||
const tool = { id: 'ellipsis', name: 'Bash', input: { command, description: 'Print Saving…' } };
|
||
expect(nativePermissionKey(tool, nativeBashDialog(command, tool.input.description))).toBe('Bash:' + command);
|
||
for (const foreign of ['\u200b', '\u202e', '好', '👩💻']) {
|
||
const input = { command: command.replace('…', foreign), description: tool.input.description };
|
||
expect(() => nativePermissionKey({ ...tool, input }, nativeBashDialog(input.command, input.description))).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
|
||
test('clipped Bash suffix only requests repaint and never supplies grant authority', () => {
|
||
const input = { command: 'printf first\nprintf "Saving…"\nprintf third', description: 'Write owned status' };
|
||
const tool = { id: 'clipped', name: 'Bash', cwd: '/owned', input };
|
||
const visible = ' │ printf "Saving…"\n │ printf third\n Write owned status'
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
expect(matchesClippedBashPermission(tool, visible, 240)).toBe(true);
|
||
expect(() => nativePermissionKey(tool, visible)).toThrow('cannot be bound');
|
||
for (const changed of [visible.replace('printf third', 'printf other'), visible.replace('owned status', 'foreign status'),
|
||
visible.replace(' ❯ 1. Yes', ' 1. Yes'), ' Bash command\n' + visible, ' Create file\n' + visible,
|
||
visible + '\n❯ A new prompt', '```\n' + visible]) expect(matchesClippedBashPermission(tool, changed, 240)).toBe(false);
|
||
expect(matchesClippedBashPermission({ ...tool, bashPermissionRequestId: null }, visible, 240)).toBe(false);
|
||
expect(matchesClippedBashPermission({ ...tool, name: 'Read' }, visible, 240)).toBe(false);
|
||
});
|
||
|
||
test('pinned Bash soft continuations elide one separator but preserve hard-line indentation', () => {
|
||
// Eg wraps each original physical line separately and elides exactly one
|
||
// leading ASCII space on a nonempty soft continuation. These expected rows
|
||
// are literal renderer output, not generated by the matcher under test.
|
||
const head = 'x'.repeat(112);
|
||
const detail = 'd'.repeat(112);
|
||
const input = { command: head + ' tail\n hard-indent', description: detail + ' description-tail' };
|
||
const tool = { id: 'soft-boundary', name: 'Bash', input };
|
||
const frame = '─'.repeat(120) + '\n Bash command\n\n'
|
||
+ ' │ ' + head + '\n │ tail\n │ hard-indent\n'
|
||
+ ' │ ' + detail + '\n │ description-tail'
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + input.command);
|
||
for (const changed of [frame.replace(' │ tail', ' │ tail'),
|
||
frame.replace(' │ hard-indent', ' │ hard-indent'),
|
||
frame.replace(' │ description-tail', ' │ description-tail')]) {
|
||
expect(() => nativePermissionKey(tool, changed)).toThrow('cannot be bound');
|
||
}
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...input, command: input.command.replace('\n hard', '\n hard') } }, frame)).toThrow('cannot be bound');
|
||
});
|
||
|
||
test('pinned Bash header-only clipping requests a repaint while the full payload cannot grant', () => {
|
||
const input = { command: 'printf first\nprintf second', description: 'Write owned status' };
|
||
const tool = { id: 'header-clipped', name: 'Bash', cwd: '/owned', input };
|
||
const visible = ' │ printf first\n │ printf second\n Write owned status'
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
expect(matchesClippedBashPermission(tool, visible, 240)).toBe(true);
|
||
expect(() => nativePermissionKey(tool, visible)).toThrow('cannot be bound');
|
||
for (const changed of [' │ printf foreign\n' + visible, visible.replace('printf first', 'printf other'),
|
||
visible.replace(' Esc to cancel · Tab to amend', ' Esc to cancel'), visible + '\n❯ New prompt']) {
|
||
expect(matchesClippedBashPermission(tool, changed, 240)).toBe(false);
|
||
}
|
||
expect(matchesClippedBashPermission({ ...tool, bashPermissionRequestId: null }, visible, 240)).toBe(false);
|
||
});
|
||
|
||
|
||
// The native CLI can persist several queued Edits with stop_reason:null while
|
||
// its PermissionRequest hook names exactly one of their complete inputs.
|
||
function queuedEditRequests() {
|
||
write();
|
||
const { source, settingsPath } = setupQuestionEventSource({ configDir: config, cwd: config, sessionId, rootDir: config });
|
||
const command = JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks.PermissionRequest[0].hooks[0].command;
|
||
const emit = (input: unknown) => {
|
||
const prior = new Set(readPermissionRequestEvents(source, { configDir: config, sessionId, transcriptFile: file }).map(event => event.requestId));
|
||
const child = Bun.spawnSync(['bash', '-c', command], { timeout: 5000,
|
||
stdin: Buffer.from(JSON.stringify({ hook_event_name: 'PermissionRequest', session_id: sessionId,
|
||
transcript_path: file, cwd: config, tool_name: 'Edit', tool_input: input })), stdout: 'pipe', stderr: 'pipe' });
|
||
expect(child.exitCode, child.stderr.toString()).toBe(0);
|
||
return readPermissionRequestEvents(source, { configDir: config, sessionId, transcriptFile: file }).find(event => !prior.has(event.requestId))!;
|
||
};
|
||
const first = { file_path: path.join(config, 'plan.md'), old_string: 'Task 1', new_string: 'Task 1 approved', replace_all: false };
|
||
const second = { ...first, old_string: 'Task 2', new_string: 'Task 2 approved' };
|
||
const read = () => readPlanSkillQuestions(config, sessionId, source);
|
||
const dialog = createDialog('plan.md').replace('create', 'make this edit to');
|
||
return { source, emit, first, second, read, dialog };
|
||
}
|
||
|
||
test('queued unfinished Edits keep distinct IDs and only the exact owned request can receive a grant', async () => {
|
||
const s = queuedEditRequests();
|
||
const firstRow = nativeWrite('queued-first', s.first, config, 'Edit', null);
|
||
const secondRow = nativeWrite('queued-second', s.second, config, 'Edit', null);
|
||
write(firstRow, secondRow);
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
const unobserved = readPlanSkillQuestions(config, sessionId);
|
||
expect(unobserved.permissionTools).toEqual([]);
|
||
expect(reserveNativePermissionGrant(unobserved, s.dialog, granted, requests)).toBe(false);
|
||
const firstEvent = s.emit(s.first);
|
||
const first = s.read();
|
||
expect(first.permissionTools).toEqual([]);
|
||
expect(first.permissionRequests).toEqual([expect.objectContaining({ requestId: firstEvent.requestId, input: s.first, result: 'pending' })]);
|
||
expect(first.permissionRequests[0].nativeToolId).toBeUndefined();
|
||
expect(reserveNativePermissionGrant(first, s.dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(first, s.dialog, granted, requests)).toBe(false);
|
||
expect(granted).toEqual(new Set([`request:${firstEvent.requestId}`]));
|
||
const resultAt = firstEvent.capturedAtMs + 1;
|
||
write(firstRow, secondRow, nativeWriteResult('queued-first', new Date(resultAt).toISOString()));
|
||
await Bun.sleep(5);
|
||
const secondEvent = s.emit(s.second);
|
||
const second = s.read();
|
||
expect(second.permissionRequests.find(event => event.requestId === firstEvent.requestId)).toMatchObject({ result: 'completed', nativeToolId: 'queued-first', nativeResultAtMs: resultAt });
|
||
expect(second.permissionTools).toEqual([]);
|
||
expect(secondEvent.capturedAtMs).toBeGreaterThan(resultAt);
|
||
expect(reserveNativePermissionGrant(second, s.dialog, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(second, s.dialog, granted, requests)).toBe(false);
|
||
expect(granted).toEqual(new Set([`request:${firstEvent.requestId}`, `request:${secondEvent.requestId}`]));
|
||
});
|
||
|
||
test.each(['no-exact-input', 'same-id-input', 'same-id-path', 'same-id-cwd', 'same-id-name', 'same-id-bash', 'duplicate-exact-ids', 'finalized-owner', 'second-request'])
|
||
('queued unfinished Edit preserves refusal for %s', variant => {
|
||
const s = queuedEditRequests();
|
||
const first = nativeWrite('queued-first', s.first, config, 'Edit', null);
|
||
let second = nativeWrite('queued-second', s.second, config, 'Edit', null);
|
||
if (variant.startsWith('same-id-')) {
|
||
second = nativeWrite('queued-first', variant === 'same-id-path' ? { ...s.first, file_path: path.join(config, 'other.md') }
|
||
: variant === 'same-id-input' ? s.second : s.first, variant === 'same-id-cwd' ? path.dirname(config) : config,
|
||
variant === 'same-id-name' ? 'Write' : variant === 'same-id-bash' ? 'Bash' : 'Edit', null);
|
||
}
|
||
if (variant === 'duplicate-exact-ids') second = nativeWrite('queued-second', s.first, config, 'Edit', null);
|
||
if (variant === 'finalized-owner') second = nativeWrite('queued-second', s.second, config, 'Edit');
|
||
write(...(variant === 'no-exact-input' ? [second] : [first, second]));
|
||
s.emit(s.first);
|
||
if (variant === 'second-request') s.emit(s.second);
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
expect(() => reserveNativePermissionGrant(s.read(), s.dialog, granted, requests)).toThrow();
|
||
expect(granted.size).toBe(0); expect(requests.size).toBe(0);
|
||
});
|
||
|
||
|
||
// Qualified V6 Design hook input (command da782e62), used ONLY as data. This
|
||
// source-derived renderer control is not a captured V6 terminal frame, and no
|
||
// tool command is executed. Existing Bash ownership/completion controls remain.
|
||
const retainedDesignEmDashInput = {"command": "cd /tmp/gstack-paid-shard-eIwOQS/tmp/gstack-e2e-plan-design-qQyLqj\neval \"$(/tmp/gstack-paid-shard-eIwOQS/tmp/gstack-hermetic-1019314-1e844d/with-skills/runtime/bin/gstack-slug 2>/dev/null)\"\nTASKS_DIR=\"${HOME}/.gstack/projects/${SLUG:-unknown}\"\nmkdir -p \"$TASKS_DIR\"\nTASKS_FILE=\"$TASKS_DIR/tasks-design-review-$(date +%Y%m%d-%H%M%S).jsonl\"\nCOMMIT=$(git rev-parse HEAD 2>/dev/null || echo unknown)\nBRANCH=$(git branch --show-current 2>/dev/null || echo unknown)\nRUN_ID=\"$(date -u +%Y%m%dT%H%M%SZ)-$$\"\ncommand -v jq >/dev/null || { echo \"NO_JQ\"; exit 0; }\nemit() { jq -nc --arg phase 'design-review' --arg run_id \"$RUN_ID\" --arg branch \"$BRANCH\" --arg commit \"$COMMIT\" \\\n --arg id \"$1\" --arg priority \"$2\" --arg component \"$3\" --arg effort_human \"$4\" --arg effort_cc \"$5\" \\\n --arg title \"$6\" --arg source_finding \"$7\" --argjson files \"$8\" \\\n '{phase:$phase, run_id:$run_id, branch:$branch, commit:$commit, id:$id, priority:$priority, component:$component, files:$files, effort_human:$effort_human, effort_cc:$effort_cc, title:$title, source_finding:$source_finding}' >> \"$TASKS_FILE\"; }\nemit T1 P1 \"Settings header actions\" \"~1h\" \"~10min\" \"Apply filled primary variant to Save and quiet secondary to Reset, Cancel, Export\" \"Pass 1 IA — D3/1A: nothing tells the user which is the primary action\" '[\"<app>/settings-page\"]'\nemit T2 P1 \"Save in-flight state\" \"~3h\" \"~20min\" \"Spinner in Save, label Saving…, held min-width, status line text, reduced-motion static glyph\" \"Pass 2 States — D4/2A: spinner or skeleton left undecided\" '[\"<app>/settings-page\",\"<app>/Button\",\"<app>/save-status-line\"]'\nemit T3 P1 \"Settings error color roles\" \"~30min\" \"~5min\" \"Set error foreground #991B1B and message background #FEF2F2\" \"Pass 5 Design System — D7/5A: contrast approximately 3:1\" '[\"<app>/settings-tokens.css\"]'\nemit T4 P2 \"Settings section-gap role\" \"~30min\" \"~5min\" \"Set every inter-section gap and header-to-Profile gap to 32px\" \"Pass 5 Design System — D5/3A: 24/32/16px inconsistent gaps\" '[\"<app>/settings-tokens.css\",\"<app>/settings-page\"]'\nemit T5 P2 \"Settings label and helper type roles\" \"~1h\" \"~10min\" \"Labels 16px/600, helper and validation 14px/400, remove 18px\" \"Pass 5 Design System — D6/4A: 14/16/18px label sizes\" '[\"<app>/settings-tokens.css\"]'\nemit T6 P2 \"Acceptance run\" \"~1h\" \"~10min\" \"Execute responsive and accessibility acceptance table at 375px and 768px+\" \"Pass 6 Responsive & A11y — new treatments must not regress DESIGN.md\" '[]'\nemit T7 P3 \"TODOS.md\" \"~15min\" \"~2min\" \"Create TODOS.md with the three approved follow-ups\" \"TODOS.md updates — D8, D9, D10\" '[\"TODOS.md\"]'\necho \"TASKS_FILE: $TASKS_FILE ($(wc -l < \"$TASKS_FILE\") lines)\"\n/tmp/gstack-paid-shard-eIwOQS/tmp/gstack-hermetic-1019314-1e844d/with-skills/runtime/bin/gstack-review-log '{\"skill\":\"plan-design-review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"initial_score\":4,\"overall_score\":9,\"unresolved\":0,\"decisions_made\":5,\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\"}' && echo REVIEW_LOGGED\necho \"---REVIEW_READ---\"\n/tmp/gstack-paid-shard-eIwOQS/tmp/gstack-hermetic-1019314-1e844d/with-skills/runtime/bin/gstack-review-read", "description": "Write tasks JSONL, log review, read review dashboard data"};
|
||
const emDashCard = (input: { command: string; description: string }) => {
|
||
const render = (value: string) => {
|
||
const gutter = value.includes('\n') || value.length > 80;
|
||
return value.split('\n').flatMap(line => Bun.wrapAnsi(line, 240 - (gutter ? 8 : 6), { hard: true, trim: false })
|
||
.split('\n').map((row, index) => index > 0 && row.startsWith(' ') && row.length > 1 ? row.slice(1) : row))
|
||
.map(row => ((gutter ? ' │ ' : ' ') + row).replace(/ +$/, '')).join('\n');
|
||
};
|
||
return '─'.repeat(240) + '\n Bash command\n\n' + render(input.command) + '\n' + render(input.description)
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
};
|
||
|
||
test('native Bash em dash uses literal positive-width projection without widening Unicode', () => {
|
||
const input = { command: 'printf "A—B…"', description: 'Print A—B…' };
|
||
const tool = { id: 'em-dash', name: 'Bash', input };
|
||
expect(nativePermissionKey(tool, nativeBashDialog(input.command, input.description))).toBe('Bash:' + input.command);
|
||
for (const foreign of ['\u2029', '\u00a0', '\u200b', '\u202e', '\u2066', '\u0301', '好', '👩💻', '\t', '\r', '\x1b', '\u0085']) {
|
||
const changed = { ...input, command: input.command.replace('—', foreign) };
|
||
expect(() => nativePermissionKey({ ...tool, input: changed }, nativeBashDialog(changed.command, changed.description))).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test('native Bash em dash preserves exact soft boundaries and hard indentation', () => {
|
||
const head = 'x'.repeat(111) + '—';
|
||
const input = { command: head + ' —tail\n —hard', description: 'Print —…' };
|
||
const tool = { id: 'em-dash-wrap', name: 'Bash', input };
|
||
const frame = '─'.repeat(120) + '\n Bash command\n\n │ ' + head
|
||
+ '\n │ —tail\n │ —hard\n Print —…'
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + input.command);
|
||
for (const changed of [frame.replace(' │ —tail', ' │ —tail'), frame.replace(' │ —hard', ' │ —hard'),
|
||
frame.replace('—hard', '-hard'), frame.replace('Print —…', 'Print -…')]) {
|
||
expect(() => nativePermissionKey(tool, changed)).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test('retained Design em dash input requires the complete exact card before granting', () => {
|
||
const input = retainedDesignEmDashInput;
|
||
const tool = { id: 'retained-em-dash', name: 'Bash', input, cwd: '/owned', bashPermissionRequestId: 'owned-request' };
|
||
const frame = emDashCard(input);
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + input.command);
|
||
const clipped = frame.split('\n').slice(5).join('\n');
|
||
expect(matchesClippedBashPermission(tool, clipped, 240)).toBe(true);
|
||
expect(() => nativePermissionKey(tool, clipped)).toThrow('cannot be bound');
|
||
for (const changed of [frame.replace('TODOS.md updates —', 'TODOS.md updates -'),
|
||
frame.replace(input.description, 'Read a different dashboard'), frame.replace(' ❯ 1. Yes', ' 1. Yes'),
|
||
frame.replace(' Esc to cancel · Tab to amend', ' Esc to cancel'), frame + '\n❯ Another prompt']) {
|
||
expect(() => nativePermissionKey(tool, changed)).toThrow('cannot be bound');
|
||
}
|
||
for (const changed of [clipped.replace('TODOS.md updates —', 'TODOS.md updates -'),
|
||
clipped.replace(input.description, 'Different description'), clipped.replace(' ❯ 1. Yes', ' 1. Yes'),
|
||
' Bash command\n' + clipped, clipped + '\n❯ Another prompt']) {
|
||
expect(matchesClippedBashPermission(tool, changed, 240)).toBe(false);
|
||
}
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...input, command: input.command.replace('T7 P3', 'T7 P2') } }, frame)).toThrow('cannot be bound');
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...input, description: 'Different description' } }, frame)).toThrow('cannot be bound');
|
||
expect(matchesClippedBashPermission({ ...tool, bashPermissionRequestId: null }, clipped, 240)).toBe(false);
|
||
});
|
||
|
||
test('retained Design em dash hook input still requires a paired request and one native completion', () => {
|
||
const s = bashHooks(); const input = retainedDesignEmDashInput; const frame = emDashCard(input);
|
||
const granted = new Set<string>(); const requests = new Map<string, NativePermissionGrant>();
|
||
s.emit('PreToolUse', 'em-dash-owned', input);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
s.emit('PermissionRequest', 'not-a-native-id', input);
|
||
expect(() => reserveNativePermissionGrant(s.read(), frame.split('\n').slice(5).join('\n'), granted, requests)).toThrow('cannot be bound');
|
||
expect(granted.size).toBe(0);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
expect(s.read().permissionTools.map(tool => tool.id)).toEqual(['em-dash-owned']);
|
||
s.emit('PostToolUse', 'em-dash-owned', input, { tool_response: { stdout: '', stderr: '', interrupted: false } });
|
||
expect(s.read().permissionTools).toEqual([]);
|
||
expect(s.read().permissionResults).toEqual([{ id: 'em-dash-owned', result: 'completed' }]);
|
||
expect([...granted]).toEqual(['em-dash-owned']);
|
||
});
|
||
|
||
test.each(['command', 'description'] as const)('retained Design em dash refuses changed post-Pre %s', field => {
|
||
const s = bashHooks(); const input = retainedDesignEmDashInput;
|
||
s.emit('PreToolUse', 'em-dash-owned', input);
|
||
s.emit('PermissionRequest', 'not-a-native-id', { ...input, [field]: input[field] + ' changed' });
|
||
const granted = new Set<string>();
|
||
expect(reserveNativePermissionGrant(s.read(), emDashCard(input), granted, new Map())).toBe(false);
|
||
expect(granted.size).toBe(0);
|
||
});
|
||
|
||
|
||
test('pinned Bash clipped top rule requires the entire exact payload and only permits repaint', () => {
|
||
const input = retainedDesignEmDashInput;
|
||
const tool = { id: 'clipped-rule', name: 'Bash', input, cwd: '/owned', bashPermissionRequestId: 'owned-request' };
|
||
const frame = emDashCard(input).split('\n').slice(1).join('\n');
|
||
const native = { permissionTools: [tool], permissionResults: [], permissionRequests: [], permissionRequestCapture: true };
|
||
expect(frame).toStartWith(' Bash command\n\n');
|
||
expect(matchesClippedBashPermission(tool, frame, 240)).toBe(true);
|
||
expect(() => nativePermissionKey(tool, frame)).toThrow('cannot be bound');
|
||
const granted = new Set<string>();
|
||
expect(() => reserveNativePermissionGrant(native, frame, granted, new Map())).toThrow('cannot be bound');
|
||
expect(granted.size).toBe(0);
|
||
for (const changed of [frame.replace(' Bash command', ' Bash command (unproven)'),
|
||
frame.replace(' Bash command\n\n', ' Bash command\n'), 'Prior context\n' + frame,
|
||
frame.split('\n').filter((_, i) => i !== 2).join('\n'),
|
||
frame.replace('TODOS.md updates —', 'TODOS.md updates -'), frame.replace(input.description, 'Other description'),
|
||
frame.replace(' ❯ 1. Yes', ' 1. Yes'), frame + '\n❯ Another prompt']) {
|
||
expect(matchesClippedBashPermission(tool, changed, 240)).toBe(false);
|
||
}
|
||
expect(matchesClippedBashPermission(tool, frame, 120)).toBe(false);
|
||
expect(matchesClippedBashPermission({ ...tool, bashPermissionRequestId: null }, frame, 240)).toBe(false);
|
||
expect(matchesClippedBashPermission({ ...tool, input: { ...input, command: input.command + ' changed' } }, frame, 240)).toBe(false);
|
||
});
|
||
|
||
|
||
test('captured Design tasks permission binds its exact single-cell Unicode payload', () => {
|
||
const { tool, frame } = retainedDesignTasksPermission;
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + tool.input.command);
|
||
});
|
||
|
||
|
||
test.each(['≤', '–', 'é', '©', '™'])('native Bash preserves unsanitized one-cell BMP text: %s', glyph => {
|
||
const input = { command: `printf "${glyph}"`, description: `Print ${glyph}` };
|
||
expect(nativePermissionKey({ id: 'one-cell', name: 'Bash', input }, nativeBashDialog(input.command, input.description))).toBe('Bash:' + input.command);
|
||
});
|
||
|
||
test.each(['\u0000', '\u007f', '\u0085', '\t', '\r', '\x1b', '\u00a0', '\u2028', '\u2029',
|
||
'\u200b', '\u2060', '\u202e', '\u2066', '\u0301', 'e\u0301', '\u034f', '\ufe0f', '\u2800',
|
||
'\ud800', '好', '😀', '👩💻', '𝔸'])
|
||
('native Bash refuses sanitized or non-single-cell payload %j', glyph => {
|
||
for (const field of ['command', 'description'] as const) {
|
||
const input = { command: 'printf ready', description: 'Print ready', [field]: `Print ${glyph}` };
|
||
expect(() => nativePermissionKey({ id: 'unsafe-glyph', name: 'Bash', input }, nativeBashDialog(input.command, input.description))).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test('one-cell Unicode keeps exact hard indentation and soft continuation at both viewports', () => {
|
||
for (const columns of [120, 240]) {
|
||
const head = 'x'.repeat(columns - 9) + '≤';
|
||
const input = { command: head + ' étail\n ™hard', description: 'Print ≤–é©™' };
|
||
const tool = { id: 'unicode-wrap', name: 'Bash', input };
|
||
const frame = '─'.repeat(columns) + '\n Bash command\n\n │ ' + head
|
||
+ '\n │ étail\n │ ™hard\n Print ≤–é©™'
|
||
+ '\n\n Do you want to proceed?\n ❯ 1. Yes\n 2. No\n\n Esc to cancel · Tab to amend';
|
||
expect(nativePermissionKey(tool, frame)).toBe('Bash:' + input.command);
|
||
for (const changed of [frame.replace(' │ étail', ' │ étail'), frame.replace(' │ ™hard', ' │ ™hard'),
|
||
frame.replace('Print ≤–é©™', 'Print <–é©™')]) expect(() => nativePermissionKey(tool, changed)).toThrow('cannot be bound');
|
||
}
|
||
});
|
||
|
||
test('captured Design tasks card still requires exact command, description, width and controls', () => {
|
||
const { tool, frame } = retainedDesignTasksPermission;
|
||
const lines = frame.split('\n');
|
||
const heading = lines.indexOf(' Bash command');
|
||
expect(heading).toBeGreaterThanOrEqual(0);
|
||
expect(lines[heading + 1]).toMatch(/^ *$/);
|
||
const clipped = lines.slice(heading + 3).join('\n');
|
||
expect(matchesClippedBashPermission(tool, clipped, 240)).toBe(true);
|
||
expect(() => nativePermissionKey(tool, clipped)).toThrow('cannot be bound');
|
||
for (const changed of [frame.replace('≤16px', '<16px'), frame.replace(tool.input.description, 'Different task'),
|
||
frame.replace('─'.repeat(240), '─'.repeat(120)), frame.replace('─'.repeat(240), '─'.repeat(239)),
|
||
frame.replace(' ❯ 1. Yes', ' 1. Yes'), frame.replace(' Esc to cancel · Tab to amend', ' Esc to cancel'),
|
||
frame + '\n❯ Another prompt', '```\n' + frame]) expect(() => nativePermissionKey(tool, changed)).toThrow('cannot be bound');
|
||
for (const field of ['command', 'description'] as const) {
|
||
expect(() => nativePermissionKey({ ...tool, input: { ...tool.input, [field]: tool.input[field] + ' changed' } }, frame)).toThrow('cannot be bound');
|
||
}
|
||
expect(matchesClippedBashPermission({ ...tool, bashPermissionRequestId: null }, clipped, 240)).toBe(false);
|
||
});
|
||
|
||
test('captured Design tasks permission needs an owned request and retires on its actual completion', () => {
|
||
const s = bashHooks(); const { tool, frame } = retainedDesignTasksPermission; const input = tool.input;
|
||
const granted = new Set<string>(), requests = new Map<string, NativePermissionGrant>();
|
||
s.emit('PreToolUse', 'tasks-owned', input);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
s.emit('PermissionRequest', 'hook-not-native-id', input);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(true);
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, requests)).toBe(false);
|
||
expect(s.read().permissionTools.map(tool => tool.id)).toEqual(['tasks-owned']);
|
||
s.emit('PostToolUse', 'tasks-owned', input, { tool_response: { stdout: '', stderr: '', interrupted: false } });
|
||
expect(s.read().permissionTools).toEqual([]);
|
||
expect(s.read().permissionResults).toEqual([{ id: 'tasks-owned', result: 'completed' }]);
|
||
expect([...granted]).toEqual(['tasks-owned']);
|
||
});
|
||
|
||
test.each(['command', 'description'] as const)('captured Design tasks refuses changed post-Pre %s', field => {
|
||
const s = bashHooks(); const { tool, frame } = retainedDesignTasksPermission;
|
||
s.emit('PreToolUse', 'tasks-owned', tool.input);
|
||
s.emit('PermissionRequest', 'hook-not-native-id', { ...tool.input, [field]: tool.input[field] + ' changed' });
|
||
const granted = new Set<string>();
|
||
expect(reserveNativePermissionGrant(s.read(), frame, granted, new Map())).toBe(false);
|
||
expect(granted.size).toBe(0);
|
||
});
|
||
|
||
|
||
function capturedQuestionValidation() {
|
||
const block = JSON.parse(retainedQuestionValidation.toolUseJson);
|
||
const invoked = { type: 'assistant', sessionId, cwd: config, timestamp: retainedQuestionValidation.invokedAt,
|
||
message: { role: 'assistant', stop_reason: 'tool_use', content: [block] } };
|
||
const rejected = { type: 'user', sessionId, cwd: config, timestamp: retainedQuestionValidation.rejectedAt,
|
||
toolUseResult: JSON.parse(retainedQuestionValidation.toolUseResultJson),
|
||
message: { role: 'user', content: [JSON.parse(retainedQuestionValidation.toolResultJson)] } };
|
||
return { block, invoked, rejected };
|
||
}
|
||
|
||
test.each(['same-snapshot', 'split-snapshots'])('captured native schema rejection stays unoffered and permits a new corrected invocation (%s)', variant => {
|
||
const { block, invoked, rejected } = capturedQuestionValidation();
|
||
write(invoked, ...(variant === 'same-snapshot' ? [rejected] : []));
|
||
const early = earlyQuestions();
|
||
const initial = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(initial.pendingBytes).toBe(0);
|
||
expect(initial.calls).toEqual([{ id: block.id, questions: [], result: variant === 'same-snapshot' ? 'error' : 'pending',
|
||
validation: { input: block.input, ...(variant === 'same-snapshot' ? { rejection: {
|
||
content: rejected.message.content[0].content, toolUseResult: rejected.toolUseResult,
|
||
} } : {}) } }]);
|
||
expect(initial.calls[0].answerLabels).toBeUndefined();
|
||
// No defaulted copy is mislabeled as the original rejected input.
|
||
expect(Object.hasOwn((initial.calls[0].validation!.input as typeof block.input).questions[0], 'multiSelect')).toBe(false);
|
||
if (variant === 'split-snapshots') fs.appendFileSync(file, JSON.stringify(rejected) + '\n');
|
||
const terminal = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(terminal.calls[0]).toMatchObject({ result: 'error', questions: [], validation: { input: block.input } });
|
||
const corrected = { questions: [structuredClone(block.input.questions[1])] };
|
||
early.emit('corrected-new-id', corrected);
|
||
const pending = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(pending.calls.find(c => c.id === block.id)).toEqual(terminal.calls[0]);
|
||
expect(pending.calls.filter(c => c.result === 'pending')).toEqual([{ id: 'corrected-new-id', questions: corrected.questions, result: 'pending' }]);
|
||
fs.appendFileSync(file, JSON.stringify({ type: 'user', sessionId, message: { role: 'user', content: [
|
||
{ type: 'tool_result', tool_use_id: 'corrected-new-id', content: 'Answer accepted' },
|
||
] } }) + '\n');
|
||
const answered = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(answered.calls.find(c => c.id === 'corrected-new-id')?.result).toBe('answered');
|
||
expect(answered.calls.find(c => c.id === block.id)).toEqual(terminal.calls[0]);
|
||
});
|
||
|
||
test.each(['no-result', 'wrong-result-id', 'foreign-result', 'sidechain-result'])('native schema validation stays pending without its own result (%s)', variant => {
|
||
const { block, invoked, rejected } = capturedQuestionValidation();
|
||
if (variant === 'wrong-result-id') rejected.message.content[0].tool_use_id = 'another-id';
|
||
if (variant === 'foreign-result') rejected.sessionId = '00000000-0000-4000-8000-000000000099';
|
||
if (variant === 'sidechain-result') (rejected as any).isSidechain = true;
|
||
write(invoked, ...(variant === 'no-result' ? [] : [rejected]));
|
||
const early = earlyQuestions();
|
||
const native = readPlanSkillQuestions(config, sessionId, early.source);
|
||
expect(native.calls).toEqual([{ id: block.id, questions: [], result: 'pending', validation: { input: block.input } }]);
|
||
expect(native.ready).toBe(false);
|
||
expect(native.pendingBytes).toBe(0);
|
||
});
|
||
|
||
test.each(['before-invocation', 'earlier-time', 'wrong-cwd', 'success', 'generic-error', 'changed-content',
|
||
'changed-structured', 'duplicate-result', 'changed-input', 'changed-tool', 'executed-hook', 'no-hook-capture'])
|
||
('native schema rejection refuses incompatible ownership or resolution (%s)', variant => {
|
||
const { block, invoked, rejected } = capturedQuestionValidation();
|
||
if (variant === 'earlier-time') rejected.timestamp = '2026-09-12T09:44:53.655Z';
|
||
if (variant === 'wrong-cwd') rejected.cwd += '/foreign';
|
||
if (variant === 'success') rejected.message.content[0].is_error = false;
|
||
if (variant === 'generic-error') { rejected.toolUseResult = 'Request cancelled'; rejected.message.content[0].content = 'Request cancelled'; }
|
||
if (variant === 'changed-content') rejected.message.content[0].content += ' amended';
|
||
if (variant === 'changed-structured') rejected.toolUseResult = rejected.toolUseResult.replace('header', 'otherField');
|
||
const extra = structuredClone(invoked);
|
||
if (variant === 'changed-input') extra.message.content[0].input.questions[0].question += ' changed';
|
||
if (variant === 'changed-tool') extra.message.content[0].name = 'Write';
|
||
write(...(variant === 'before-invocation' ? [rejected, invoked] : [invoked, rejected]),
|
||
...(['changed-input', 'changed-tool'].includes(variant) ? [extra] : []),
|
||
...(variant === 'duplicate-result' ? [rejected] : []));
|
||
const early = earlyQuestions();
|
||
if (variant === 'executed-hook') early.emit(block.id, block.input);
|
||
expect(() => readPlanSkillQuestions(config, sessionId, variant === 'no-hook-capture' ? undefined : early.source))
|
||
.toThrow(/Unsupported native AskUserQuestion|Native question validation/);
|
||
});
|
||
|
||
|
||
test('native schema rejection refuses an owned orphan completion hook', () => {
|
||
const s = completedQuestionHook();
|
||
const { block, invoked, rejected } = capturedQuestionValidation();
|
||
write(invoked, rejected);
|
||
s.emit('PostToolUse', block.id, { ...s.input, answers: s.response.answers }, { tool_response: s.response });
|
||
expect(() => s.read()).toThrow('Unsupported native AskUserQuestion reached execution or lacks hook capture');
|
||
});
|