|
| 1 | +import { test, expect } from "@playwright/test"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import os from "node:os"; |
| 4 | +import path from "node:path"; |
| 5 | +import { currentRow, items, launch, seedHome, tid } from "./support"; |
| 6 | + |
| 7 | +/** |
| 8 | + * A plan clarification (docs/task-context.md, CF-7 #280): the CLI reports a plan, asks a clarifying question |
| 9 | + * through the question card, and carries on once it is answered. The answer reaches the CLI once, and Save |
| 10 | + * to Notes records it with the secret hidden. A clarification nobody answered says so and gains no answer. |
| 11 | + * The executable below is a scripted codex-cli 0.163.0 app-server (`turn/plan/updated`, then |
| 12 | + * `item/tool/requestUserInput`), not a provider. |
| 13 | + */ |
| 14 | +function fixture(dir: string): string { |
| 15 | + const cli = path.join(dir, "codex-clarify.cjs"); |
| 16 | + fs.writeFileSync(cli, `#!${process.execPath} |
| 17 | +const fs = require('node:fs'); |
| 18 | +if (process.argv.includes('--version')) { console.log('codex-cli 0.163.0'); process.exit(0); } |
| 19 | +if (process.argv.includes('login') || process.argv.includes('auth')) { console.log('Logged in using ChatGPT'); process.exit(0); } |
| 20 | +const log = ${JSON.stringify(path.join(dir, "codex-replies.jsonl"))}; |
| 21 | +const emit = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); |
| 22 | +const questions = [ |
| 23 | + { id: 'q-env', header: 'Environment', question: 'Which environment should the plan target?', isOther: false, isSecret: false, options: [{ label: 'staging', description: 'Safe to try' }, { label: 'production', description: 'Live traffic' }] }, |
| 24 | + { id: 'q-token', header: 'Token', question: 'Paste the deploy token', isOther: true, isSecret: true, options: null }, |
| 25 | +]; |
| 26 | +let turn = 0, open = null; |
| 27 | +const cwds = new Map(); |
| 28 | +const plan = (thread, id, a, b) => emit({ method: 'turn/plan/updated', params: { threadId: thread, turnId: id, explanation: null, plan: [{ step: 'Pick the environment', status: a }, { step: 'Deploy', status: b }] } }); |
| 29 | +require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { |
| 30 | + const msg = JSON.parse(line); |
| 31 | + const p = msg.params || {}; |
| 32 | + if (msg.method === 'initialize') return emit({ id: msg.id, result: { userAgent: 'codex-clarify/0.163.0' } }); |
| 33 | + if (msg.method === 'initialized') return; |
| 34 | + if (msg.method === 'account/read') return emit({ id: msg.id, result: { account: { type: 'chatgpt' }, requiresOpenaiAuth: true } }); |
| 35 | + if (msg.method === 'model/list') return emit({ id: msg.id, result: { data: [{ id: 'fixture', model: 'fixture', displayName: 'Fixture', isDefault: true }], nextCursor: null } }); |
| 36 | + if (msg.method === 'thread/start' || msg.method === 'thread/resume') { |
| 37 | + const id = p.threadId || 'thr-' + cwds.size; |
| 38 | + cwds.set(id, p.cwd || ''); |
| 39 | + return emit({ id: msg.id, result: { thread: { id } } }); |
| 40 | + } |
| 41 | + if (msg.method === 'turn/start' && /modex-title-/.test(cwds.get(p.threadId) || '')) { |
| 42 | + const id = 'title-' + (++turn); |
| 43 | + emit({ id: msg.id, result: { turn: { id } } }); |
| 44 | + emit({ method: 'item/completed', params: { threadId: p.threadId, turnId: id, item: { id: 'name-' + turn, type: 'agentMessage', text: 'Deploy plan', phase: 'final_answer' } } }); |
| 45 | + return emit({ method: 'turn/completed', params: { threadId: p.threadId, turn: { id, status: 'completed', error: null } } }); |
| 46 | + } |
| 47 | + if (msg.method === 'turn/start') { |
| 48 | + const id = 'turn-' + (++turn); |
| 49 | + open = { thread: p.threadId, turn: id, request: 900 + turn, done: false }; |
| 50 | + emit({ id: msg.id, result: { turn: { id } } }); |
| 51 | + emit({ method: 'turn/started', params: { threadId: p.threadId, turn: { id } } }); |
| 52 | + plan(p.threadId, id, 'inProgress', 'pending'); |
| 53 | + emit({ id: open.request, method: 'item/tool/requestUserInput', params: { threadId: p.threadId, turnId: id, itemId: 'ask-' + turn, isBlocking: true, autoResolutionMs: null, questions } }); |
| 54 | + return; |
| 55 | + } |
| 56 | + const finish = (status, text) => { |
| 57 | + if (!open || open.done) return; |
| 58 | + open.done = true; |
| 59 | + if (text) emit({ method: 'item/completed', params: { threadId: open.thread, turnId: open.turn, item: { id: 'say-' + turn, type: 'agentMessage', text, phase: 'final_answer' } } }); |
| 60 | + emit({ method: 'turn/completed', params: { threadId: open.thread, turn: { id: open.turn, status, error: null } } }); |
| 61 | + }; |
| 62 | + if (msg.method === 'turn/interrupt') { emit({ id: msg.id, result: {} }); return finish('interrupted'); } |
| 63 | + if (open && msg.id === open.request && !msg.method) { |
| 64 | + fs.appendFileSync(log, JSON.stringify({ request: msg.id, result: msg.result, error: msg.error }) + '\\n'); |
| 65 | + if (msg.result) { |
| 66 | + plan(open.thread, open.turn, 'completed', 'completed'); |
| 67 | + finish('completed', 'Planned the deploy to ' + msg.result.answers['q-env'].answers[0] + '.'); |
| 68 | + } |
| 69 | + return; |
| 70 | + } |
| 71 | + if (msg.id !== undefined && msg.method) emit({ id: msg.id, error: { code: -32601, message: 'fixture does not implement ' + msg.method } }); |
| 72 | +}); |
| 73 | +`, { mode: 0o755 }); |
| 74 | + return cli; |
| 75 | +} |
| 76 | + |
| 77 | +type Reply = { request: number; result?: { answers: Record<string, { answers: string[] }> }; error?: { code: number } }; |
| 78 | +const replies = (dir: string): Reply[] => { |
| 79 | + const file = path.join(dir, "codex-replies.jsonl"); |
| 80 | + return fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as Reply) : []; |
| 81 | +}; |
| 82 | +const everything = (dir: string): string => fs.readdirSync(dir, { recursive: true, withFileTypes: true }) |
| 83 | + .filter((entry) => entry.isFile()).map((entry) => fs.readFileSync(path.join(entry.parentPath, entry.name), "utf8")).join("\n"); |
| 84 | + |
| 85 | +test("a plan clarification is answered on its card, reaches the CLI once, and is saved to Notes with the secret hidden; an unanswered one says so", async () => { |
| 86 | + const { home, repo } = seedHome({ default_backend: "codex", default_mode: "agent" }); |
| 87 | + const logs = fs.mkdtempSync(path.join(os.tmpdir(), "modex-e2e-clarify-")); |
| 88 | + const statePath = path.join(home, "app/state.json"); |
| 89 | + const state = JSON.parse(fs.readFileSync(statePath, "utf8")); |
| 90 | + state.settings.codex_bin = fixture(logs); |
| 91 | + fs.writeFileSync(statePath, JSON.stringify(state)); |
| 92 | + const { app, page } = await launch(home); |
| 93 | + try { |
| 94 | + await page.keyboard.press("Meta+n"); |
| 95 | + await tid(page, "composer-input").fill("Plan the deploy"); |
| 96 | + await page.keyboard.press("Meta+Enter"); |
| 97 | + // The plan as reported, then the clarification on the existing question card. |
| 98 | + await expect(tid(items(page, "plan"), "plan-step")).toHaveText(["In progress Pick the environment", "Not started Deploy"]); |
| 99 | + const card = items(page, "question").first(); |
| 100 | + await expect(currentRow(page)).toHaveAttribute("data-status", "waiting"); |
| 101 | + await card.getByRole("radio", { name: "staging" }).check(); |
| 102 | + await card.getByLabel("Token").fill("s3cr3t-deploy-token"); |
| 103 | + await tid(card, "question-submit").click(); |
| 104 | + await expect(items(page, "assistant").last()).toContainText("Planned the deploy to staging."); |
| 105 | + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); |
| 106 | + await expect(tid(items(page, "plan"), "plan-step")).toHaveText(["Reported done Pick the environment", "Reported done Deploy"]); |
| 107 | + expect(replies(logs).map((reply) => reply.result)).toEqual([{ answers: { "q-env": { answers: ["staging"] }, "q-token": { answers: ["s3cr3t-deploy-token"] } } }]); |
| 108 | + |
| 109 | + // Save to Notes: the clarification is in the record, the secret is not. |
| 110 | + await tid(page, "save-task-note").last().click(); |
| 111 | + await expect(tid(page, "task-note-saved").last()).toContainText("Saved to Notes · Task:"); |
| 112 | + const notes = async () => (await page.evaluate(() => window.modex!.invoke("space:list", undefined))) as { title: string; markdown: string }[]; |
| 113 | + const first = (await notes()).find((note) => note.title.startsWith("Task:"))!; |
| 114 | + expect(first.markdown).toContain("## Clarifications"); |
| 115 | + expect(first.markdown).toContain("- Which environment should the plan target? — staging"); |
| 116 | + expect(first.markdown).toContain("- Paste the deploy token — (hidden)"); |
| 117 | + expect(first.markdown).not.toContain("s3cr3t-deploy-token"); |
| 118 | + |
| 119 | + // A second turn asks again, and Stop closes the question unanswered: the CLI gets an error, the record no answer. |
| 120 | + await tid(page, "composer-input").fill("Plan it again"); |
| 121 | + await page.keyboard.press("Meta+Enter"); |
| 122 | + await expect(tid(items(page, "question").nth(1), "question-submit")).toBeVisible(); |
| 123 | + await tid(page, "stop").click(); |
| 124 | + await expect(currentRow(page)).toHaveAttribute("data-status", "idle"); |
| 125 | + await expect.poll(() => replies(logs).length).toBe(2); |
| 126 | + expect(replies(logs)[1]).toMatchObject({ error: { code: -32000 } }); |
| 127 | + await tid(page, "save-task-note").last().click(); |
| 128 | + await expect(tid(page, "task-note-saved").last()).toContainText("Saved to Notes · Task:"); |
| 129 | + const second = (await notes()).filter((note) => note.title.startsWith("Task:")).find((note) => note.markdown.includes("Plan it again"))!; |
| 130 | + expect(second.markdown).toContain("- Which environment should the plan target? — not answered: the turn was stopped"); |
| 131 | + expect(second.markdown).toMatch(/## Not settled\n[\s\S]*Paste the deploy token — not answered: the turn was stopped/); |
| 132 | + expect(second.markdown).not.toContain("— staging"); |
| 133 | + expect(replies(logs)).toHaveLength(2); |
| 134 | + expect(everything(home).includes("s3cr3t-deploy-token")).toBe(false); |
| 135 | + } finally { |
| 136 | + await app.close(); |
| 137 | + for (const dir of [home, repo, logs]) fs.rmSync(dir, { recursive: true, force: true }); |
| 138 | + } |
| 139 | +}); |
0 commit comments