From 75ff918e4c58ccbba5faf37bbe1864caed4144ab Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 22:35:45 -0400 Subject: [PATCH 1/4] fix(permission): honour the SDK's ask hints, change effort through applyFlagSettings, gate Auto and dontAsk by capability (lr-2ebc65) Carry suppressAlwaysAllowRule, defaultToNo and matchedAskRule from the canUseTool options into the permission prompt record, in process and over the worker IPC (one shared message shape for both sides). The permission kind refuses an Allow-for-session answer to a suppressed ask on every response path, so the prompt stays pending, nothing is granted and the responder is shown it again. No host-side auto-approval answers a rule-forced ask, and no standing approval answers an ask the SDK marked as needing its own answer. The rule is recorded on the resolved event. A prompt_response naming a browser-extension command no longer settles it; only the socket the command was sent to can. Effort changes on a running Claude session go through applyFlagSettings({effortLevel}) on both the in-process and worker paths; the worker handle settles on the worker's real outcome, and the bridge announces an effort only once the query has taken it. The Claude capabilities list permissionModes, and set_permission_mode refuses dontAsk and auto for a session whose vendor does not declare them. A tool-use id shared by two sessions no longer takes over the other session's request index entry, and a recorded AskUserQuestion skip replays as skipped. Folds in lr-5f4691 and lr-543036 item 3. Co-Authored-By: Claude Sonnet 5.5 --- lib/project-notifications.js | 10 ++- lib/project-prompt-responses.js | 7 +- lib/project-sessions.js | 37 +++++++- lib/prompt-kinds/codecs.js | 1 - lib/prompt-kinds/extension.js | 1 + lib/prompt-kinds/index.js | 13 ++- lib/prompt-kinds/permission.js | 73 ++++++++++++++-- lib/prompt-registry.js | 21 +++-- lib/sdk-bridge.js | 36 ++++++-- lib/ws-schema.js | 7 +- lib/yoke/adapters/claude-capabilities.js | 26 ++++++ lib/yoke/adapters/claude-worker.js | 24 +++--- lib/yoke/adapters/claude.js | 102 ++++++++++++++--------- lib/yoke/adapters/effort-control.js | 34 ++++++++ lib/yoke/adapters/permission-relay.js | 58 +++++++++++++ lib/yoke/interface.js | 7 +- 16 files changed, 375 insertions(+), 82 deletions(-) create mode 100644 lib/yoke/adapters/claude-capabilities.js create mode 100644 lib/yoke/adapters/effort-control.js create mode 100644 lib/yoke/adapters/permission-relay.js diff --git a/lib/project-notifications.js b/lib/project-notifications.js index 3722dbbd..d83a8e96 100644 --- a/lib/project-notifications.js +++ b/lib/project-notifications.js @@ -65,7 +65,15 @@ var formatters = { type: "permission_request", title: data.title || "Permission requested", body: data.body || "", - meta: { requestId: data.requestId, kind: data.kind || "permission", toolName: data.toolName, toolInput: data.toolInput || null }, + meta: { + requestId: data.requestId, + kind: data.kind || "permission", + toolName: data.toolName, + toolInput: data.toolInput || null, + // So the banner offers the answers the card does (see prompt-banner.js). + suppressAlwaysAllowRule: data.suppressAlwaysAllowRule === true ? true : undefined, + defaultToNo: data.defaultToNo === true ? true : undefined, + }, }; }, diff --git a/lib/project-prompt-responses.js b/lib/project-prompt-responses.js index fe45209d..7a0d89eb 100644 --- a/lib/project-prompt-responses.js +++ b/lib/project-prompt-responses.js @@ -12,6 +12,7 @@ // upgrade can still answer; each alias is declared in lib/ws-schema.js. var { promptsFor } = require("./prompt-registry"); +var promptKinds = require("./prompt-kinds"); var { mayAnswerPrompt } = require("./prompt-access"); var { ownValue } = require("./prompt-kinds/codecs"); @@ -170,8 +171,12 @@ function attachPromptResponses(ctx) { } }); + // A browser-extension command belongs to the socket it was sent to, which + // answers with extension_result (project-user-message.js): a prompt_response + // naming one is no answer from anyone who could give it. function answer(ws, msg, kinds, staleReplyType) { - var outcome = mayAnswer(ws, msg.requestId) + var answerable = kinds.every(promptKinds.operatorMayAnswer) && mayAnswer(ws, msg.requestId); + var outcome = answerable ? promptsFor(sm).respond(msg.requestId, msg, { session: getSessionForWs(ws), kinds: kinds, responder: ws }) : { status: "stale" }; if (outcome.status === "resolved") return; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 2779289e..be4dc8aa 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -77,6 +77,12 @@ var PROCESS_STATS_BASE_FIELD_KEYS = [ "memAvailableMB", ]; +// auto and dontAsk exist only in Claude Code. A session whose vendor does not +// declare them (Codex, or a vendor whose capabilities are not known yet) cannot +// run them, so a request for one is refused rather than handed to a vendor +// that would reject or ignore it. +var CAPABILITY_GATED_MODES = { auto: true, dontAsk: true }; + function attachSessions(ctx) { var cwd = ctx.cwd; var slug = ctx.slug; @@ -135,6 +141,13 @@ function attachSessions(ctx) { ensureProjectAccessForSession: ensureProjectAccessForSession, }); + function sessionAcceptsMode(session, mode) { + if (!ownValue(CAPABILITY_GATED_MODES, mode)) return true; + var vendor = session.vendor || sm.defaultVendor || "claude"; + var caps = ownValue(sm.capabilitiesByVendor || Object.create(null), vendor); + return !!caps && Array.isArray(caps.permissionModes) && caps.permissionModes.indexOf(mode) !== -1; + } + function handleSessionsMessage(ws, msg) { if (promptResponses.handlePromptMessage(ws, msg)) return true; @@ -954,6 +967,11 @@ function attachSessions(ctx) { if (msg.type === "set_permission_mode" && msg.mode) { var session = getSessionForWs(ws); + if (session && !sessionAcceptsMode(session, msg.mode)) { + sendTo(ws, { type: "error", text: "This session's agent does not support the " + String(msg.mode).slice(0, 40) + " permission mode" }); + sendTo(ws, { type: "config_state", model: sm.effectiveSessionModel(session), mode: session.permissionMode || sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + return true; + } if (session) { sdk.setPermissionMode(session, msg.mode).then(function () { // Targeted reply — only update the requesting client's chip. @@ -992,12 +1010,23 @@ function attachSessions(ctx) { } if (msg.type === "set_effort" && msg.effort) { - sm.currentEffort = msg.effort; var session = getSessionForWs(ws); - if (session) { - sdk.setEffort(session, msg.effort); + if (!session) { + sm.currentEffort = msg.effort; + send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + return true; } - send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }); + // The effort is recorded only once the running query has taken it + // (sdk.setEffort announces it then). A refused change is reported by the + // bridge as an error; the requester's picker is put back on the effort + // the session still runs at. + sdk.setEffort(session, msg.effort).then(function (result) { + var state = { type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort || "medium", betas: sm.currentBetas || [], thinking: sm.currentThinking || "adaptive", thinkingBudget: sm.currentThinkingBudget || 10000 }; + if (result && result.ok === false) sendTo(ws, state); + else send(state); + }).catch(function (err) { + console.error("[project-sessions] set_effort failed:", (err && err.message) || err); + }); return true; } diff --git a/lib/prompt-kinds/codecs.js b/lib/prompt-kinds/codecs.js index 452bfe20..6f1a7217 100644 --- a/lib/prompt-kinds/codecs.js +++ b/lib/prompt-kinds/codecs.js @@ -35,7 +35,6 @@ function sourcePath(name) { return path.join(SOURCE_DIR, FILES[name]); } -/** The file: URL of a shared module, as import() resolves it. */ function sourceUrl(name) { return pathToFileURL(sourcePath(name)).href; } diff --git a/lib/prompt-kinds/extension.js b/lib/prompt-kinds/extension.js index f6a551a0..9b77801a 100644 --- a/lib/prompt-kinds/extension.js +++ b/lib/prompt-kinds/extension.js @@ -10,6 +10,7 @@ module.exports = { name: "extension", scope: "project", journal: false, + operatorAnswerable: false, defaultTimeoutMs: 3000, fields: function (req) { diff --git a/lib/prompt-kinds/index.js b/lib/prompt-kinds/index.js index 68f790d3..ca2ebff0 100644 --- a/lib/prompt-kinds/index.js +++ b/lib/prompt-kinds/index.js @@ -23,7 +23,17 @@ function toolApprovalKind(toolName) { return toolName === "ExitPlanMode" ? "plan" : "permission"; } -/** Session fields that hold pending prompts, one per store. */ +/** + * Whether an operator's answer may settle prompts of this kind. A browser + * extension's command is answered by the extension's own socket and no other + * (lib/extension-commands.js), so no operator-facing answer path reaches it. + * An unknown kind is not answerable. + */ +function operatorMayAnswer(kindName) { + var kind = ownValue(KINDS, kindName); + return !!kind && kind.operatorAnswerable !== false; +} + function sessionStores() { var seen = {}; var out = []; @@ -52,6 +62,7 @@ function kindOfRecord(record, store) { module.exports = { KINDS: KINDS, toolApprovalKind: toolApprovalKind, + operatorMayAnswer: operatorMayAnswer, sessionStores: sessionStores, kindOfRecord: kindOfRecord, }; diff --git a/lib/prompt-kinds/permission.js b/lib/prompt-kinds/permission.js index 59c5e0d2..5968758e 100644 --- a/lib/prompt-kinds/permission.js +++ b/lib/prompt-kinds/permission.js @@ -3,12 +3,51 @@ // Tool-call approval: allow this call, allow the tool for the rest of the // session, or deny. Codex command, file-change and MCP approvals arrive here // through the same canUseTool callback as Claude's. +// +// The Claude Agent SDK may attach presentation hints to an ask, and the +// console holds to them wherever it shows or answers one: +// suppressAlwaysAllowRule the ask must not offer a persistent choice, so +// an Allow-for-session answer is refused here, not +// just left off the card; +// defaultToNo the ask must not open on an approve choice; +// matchedAskRule a user-written ask rule forced the prompt, so no +// host-side auto-approval may answer it. +// An ask without hints (Codex approvals, older CLIs) is unchanged. var toolCall = require("./tool-call"); var { hasOwnKey } = require("./codecs"); var DECISIONS = { allow: true, allow_always: true, deny: true }; +// A rule's source and text are authored outside the console and shown to the +// operator, so they are held to a bound and to strings. +var RULE_TEXT_MAX = 200; + +function ruleText(value) { + return typeof value === "string" ? value.slice(0, RULE_TEXT_MAX) : ""; +} + +// A present rule that is not the expected shape is still a rule-forced ask: +// the hint fails closed. +function matchedRuleOf(raw) { + if (!raw) return undefined; + var rule = typeof raw === "object" ? raw : {}; + var out = { source: ruleText(rule.source), toolName: ruleText(rule.toolName) }; + if (typeof rule.ruleContent === "string") out.ruleContent = ruleText(rule.ruleContent); + return out; +} + +// The hints present on a request or a stored record, normalized; absent ones +// are left out so an ask without hints is described exactly as before. +function hintsOf(source) { + var hints = {}; + if (source.suppressAlwaysAllowRule === true) hints.suppressAlwaysAllowRule = true; + if (source.defaultToNo === true) hints.defaultToNo = true; + var rule = matchedRuleOf(source.matchedAskRule); + if (rule) hints.matchedAskRule = rule; + return hints; +} + module.exports = { name: "permission", scope: "session", @@ -16,28 +55,43 @@ module.exports = { journal: true, fields: function (req) { - return { + return Object.assign({ toolName: req.toolName, toolInput: req.toolInput, decisionReason: req.decisionReason || "", vendor: req.vendor, - }; + }, hintsOf(req)); }, requestFields: function (record) { - return { + return Object.assign({ toolName: record.toolName, toolInput: record.toolInput, decisionReason: record.decisionReason || "", vendor: record.vendor, + }, hintsOf(record)); + }, + + // What a banner needs to offer the same answers as the card. + notificationFields: function (record) { + var hints = hintsOf(record); + return { + suppressAlwaysAllowRule: hints.suppressAlwaysAllowRule, + defaultToNo: hints.defaultToNo, }; }, decisions: Object.keys(DECISIONS), - // Anything but a known decision fails closed. - parse: function (msg) { - return { decision: hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny" }; + // Anything but a known decision fails closed. An Allow-for-session answer to + // an ask that must not offer one settles nothing: the prompt stays pending + // and is offered again to the responder. + parse: function (msg, record) { + var decision = hasOwnKey(DECISIONS, msg.decision) ? msg.decision : "deny"; + if (decision === "allow_always" && record && record.suppressAlwaysAllowRule === true) { + return { invalid: "always_allow_not_offered" }; + } + return { decision: decision }; }, outcome: function (record, response, opts) { @@ -49,8 +103,11 @@ module.exports = { return response.decision === "allow_always"; }, - resolvedFields: function (response) { - return { decision: response.decision }; + resolvedFields: function (response, record) { + var hints = record ? hintsOf(record) : {}; + var fields = { decision: response.decision }; + if (hints.matchedAskRule) fields.matchedAskRule = hints.matchedAskRule; + return fields; }, cancelled: toolCall.cancelled, diff --git a/lib/prompt-registry.js b/lib/prompt-registry.js index 1ba9c09a..41b995c4 100644 --- a/lib/prompt-registry.js +++ b/lib/prompt-registry.js @@ -45,7 +45,7 @@ var crypto = require("crypto"); var utils = require("./utils"); var promptKinds = require("./prompt-kinds"); var codecs = require("./prompt-kinds/codecs"); -var { ownValue, putOwn } = codecs; +var { ownValue, putOwn, hasOwnKey } = codecs; var KINDS = promptKinds.KINDS; var STORES = promptKinds.sessionStores(); @@ -88,6 +88,8 @@ function terminalOf(e) { case "ask_user_answered": if (!e.toolId) return null; if (e.answers && Object.keys(e.answers).length) return { id: e.toolId, state: { state: "resolved", answers: e.answers } }; + // A recorded skip is an answer, not an ending nobody chose. + if (e.skipped === true) return { id: e.toolId, state: { state: "resolved", skipped: true } }; return { id: e.toolId, state: { state: "cancelled", reason: null } }; default: return null; @@ -207,7 +209,11 @@ function createPromptRegistry(deps) { pruneToolOwners(session, taskId); } - // Forget every Task that no longer owns a pending prompt. + // Forget every Task that no longer owns a pending prompt. Only the owning + // query's end and a new query's start call this, and a closed query takes + // its CLI process, and every sub-agent in it, down: a Task tracked without + // a prompt then has nothing left to ask. A replacement query's Tasks are + // never touched (endQuery gates on ownsSession). function pruneTasks(session) { var tasks = trackedTasks(session); Object.keys(tasks).forEach(function (taskId) { @@ -251,8 +257,12 @@ function createPromptRegistry(deps) { }); } + // The request index is shared by every session of the project, so a + // tool-use id is reused as a request id only while no session holds a prompt + // under it; otherwise the index entry that routes the other session's + // answers would be overwritten. function newRequestId(session, kind, toolUseId) { - if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId)) return toolUseId; + if (kind.idFromToolUse && toolUseId && !findInSession(session, toolUseId) && !hasOwnKey(index, toolUseId)) return toolUseId; return crypto.randomUUID(); } @@ -337,7 +347,8 @@ function createPromptRegistry(deps) { if (kind.journal && opts.notification) { var nm = getNotificationsModule(); if (nm && typeof nm.notify === "function") { - nm.notify("permission_request", Object.assign({}, opts.notification, { requestId: requestId, kind: kind.name })); + var kindFields = typeof kind.notificationFields === "function" ? kind.notificationFields(record) : {}; + nm.notify("permission_request", Object.assign({}, opts.notification, kindFields, { requestId: requestId, kind: kind.name })); } } return { requestId: requestId, answer: answer, pending: true }; @@ -418,7 +429,7 @@ function createPromptRegistry(deps) { } if (kind.grantsSession(response)) grant(found.session, found.record.toolName, found.record.toolInput); var event = kind.journal - ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response)) + ? Object.assign({ type: "prompt_resolved", requestId: requestId, kind: kind.name }, kind.resolvedFields(response, found.record)) : null; var prompt = publicView(requestId, found); settle(found.session, requestId, found, kind.outcome(found.record, response, opts), event); diff --git a/lib/sdk-bridge.js b/lib/sdk-bridge.js index de113d68..18f2d937 100644 --- a/lib/sdk-bridge.js +++ b/lib/sdk-bridge.js @@ -1013,15 +1013,22 @@ function createSDKBridge(opts) { } function handleCanUseTool(session, toolName, input, opts) { + opts = opts || {}; + // An ask the user's own ask rule forced is a human prompt by the user's + // stated intent (the SDK's matchedAskRule): none of the host-side + // auto-approvals below - full auto, an autonomous loop, the read-only + // whitelist, an earlier Allow for session - may answer it. + var ruleForced = !!opts.matchedAskRule; + // Full-auto mode: auto-approve everything except AskUserQuestion // (which still needs to go through the user interaction flow). - if (sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") { + if (!ruleForced && sm.currentPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") { return Promise.resolve({ behavior: "allow", updatedInput: input }); } // Ralph Loop execution: auto-approve all tools, deny interactive ones. // Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md. - if (session.loop && session.loop.active && session.loop.role !== "crafting") { + if (!ruleForced && session.loop && session.loop.active && session.loop.role !== "crafting") { if (toolName === "AskUserQuestion") { return Promise.resolve({ behavior: "deny", message: "Autonomous mode. Make your own decision." }); } @@ -1032,7 +1039,7 @@ function createSDKBridge(opts) { } // Check shared whitelist (read-only tools, safe browser tools, safe bash commands) - var whitelisted = checkToolWhitelist(toolName, input); + var whitelisted = ruleForced ? null : checkToolWhitelist(toolName, input); if (whitelisted) { return Promise.resolve(whitelisted); } @@ -1052,7 +1059,11 @@ function createSDKBridge(opts) { // Auto-approve if tool was previously allowed for session (keyed on // toolName + input discriminator, not bare toolName — see // permissionGrantKey's doc comment for why the Skill tool needs this). - if (kind === "permission" && prompts.isGranted(session, toolName, input)) { + // A standing approval is broader than an ask the SDK marked as needing + // its own answer (suppressAlwaysAllowRule: no persistent choice; + // defaultToNo: must open on No), so it does not answer one. + var needsOwnAnswer = ruleForced || opts.suppressAlwaysAllowRule === true || opts.defaultToNo === true; + if (kind === "permission" && !needsOwnAnswer && prompts.isGranted(session, toolName, input)) { return Promise.resolve({ behavior: "allow", updatedInput: input }); } @@ -1074,6 +1085,9 @@ function createSDKBridge(opts) { toolInput: input, decisionReason: opts.decisionReason || "", vendor: session.vendor || (adapter && adapter.vendor) || "claude", + suppressAlwaysAllowRule: opts.suppressAlwaysAllowRule, + defaultToNo: opts.defaultToNo, + matchedAskRule: opts.matchedAskRule, }, { toolUseId: opts.toolUseID, signal: opts.signal, @@ -2417,18 +2431,26 @@ function createSDKBridge(opts) { } } + // Returns { ok: true } or { ok: false, error } like setModel: the effort is + // recorded and announced only once the running query has taken it, and a + // failure is shown to the operator instead of reading as a change. async function setEffort(session, effort) { if (!session.queryInstance) { sm.currentEffort = effort; send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] }); - return; + return { ok: true }; } - // Route through QueryHandle (works for both in-process and worker paths) - if (typeof session.queryInstance.setEffort === "function") { + try { + // Route through QueryHandle (works for both in-process and worker paths) await session.queryInstance.setEffort(effort); + } catch (e) { + var errMsg = (e && e.message) || String(e); + send({ type: "error", text: "Failed to change effort: " + errMsg }); + return { ok: false, error: errMsg }; } sm.currentEffort = effort; send({ type: "config_state", model: sm.currentModel || "", mode: sm.currentPermissionMode || "default", effort: sm.currentEffort, betas: sm.currentBetas || [] }); + return { ok: true }; } async function setPermissionMode(session, mode) { diff --git a/lib/ws-schema.js b/lib/ws-schema.js index 6ab7e171..209217a1 100644 --- a/lib/ws-schema.js +++ b/lib/ws-schema.js @@ -116,13 +116,14 @@ var schema = { // Operator prompts: one shape for every kind (lib/prompt-registry.js). // kind is permission, plan, ask_user or elicitation; the kind's own fields // ride flat on the message (permission/plan: toolName, toolInput, - // decisionReason, vendor; ask_user: input; elicitation: serverName, + // decisionReason, vendor, and for permission the SDK's hints when set: + // suppressAlwaysAllowRule, defaultToNo, matchedAskRule; ask_user: input; elicitation: serverName, // message, mode, url, elicitationId, requestedSchema). // ----------------------------------------------------------------------- "prompt_request": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt awaits the operator: {requestId, kind, toolUseId, ...kind fields}. On history replay it carries promptState {state: pending|resolved|cancelled, decision|answers|skipped|action, reason}, the server's authoritative state for the prompt at replay time" }, "prompt_pending": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt still awaiting the operator, re-presented on connect and session switch; same fields as prompt_request" }, - "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's" }, - "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}" }, + "prompt_response": { direction: "c2s", handler: "lib/project-prompt-responses.js", description: "The operator's answer: {requestId, kind, ...} with decision (+ feedback, planContent) for permission/plan, answers or decision:\"skip\" for ask_user (answers keyed by question index; each a label or text, or for a multi-select question the list of chosen labels), action (+ content, held to the requested schema) for elicitation. The kind is required and must match the prompt's. Decision allow_always to a permission prompt that carries suppressAlwaysAllowRule settles nothing: the prompt is sent again as prompt_pending. A prompt of the extension kind is never answered this way" }, + "prompt_resolved": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt was answered: {requestId, kind, decision|answers|skipped|action}; a permission prompt forced by a user ask rule also carries matchedAskRule" }, "prompt_cancel": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "A prompt ended without an answer: {requestId, kind, reason}; reason is aborted, expired, turn_ended, query_ended, rewind, cleared or shutdown. Reason \"stale\" is sent only to a client whose prompt_response named a prompt the server no longer holds, and is not recorded in history" }, // Aliases kept for compatibility: the s2c types appear in history recorded diff --git a/lib/yoke/adapters/claude-capabilities.js b/lib/yoke/adapters/claude-capabilities.js new file mode 100644 index 00000000..c2a17f1c --- /dev/null +++ b/lib/yoke/adapters/claude-capabilities.js @@ -0,0 +1,26 @@ +"use strict"; + +// What a Claude session can do, as the client sees it (sent with +// session_switched). permissionModes names the SDK permission modes the +// session accepts through setPermissionMode: the mode picker offers dontAsk +// and auto only for a vendor that lists them, so a vendor that cannot run one +// never shows it. + +var PERMISSION_MODES = ["default", "plan", "acceptEdits", "dontAsk", "auto"]; + +function claudeCapabilities() { + return { + thinking: true, + betas: true, + rewind: true, + sessionResume: true, + promptSuggestions: true, + elicitation: true, + fileCheckpointing: true, + contextCompacting: true, + toolPolicy: ["ask", "allow-all"], + permissionModes: PERMISSION_MODES.slice(), + }; +} + +module.exports = { claudeCapabilities: claudeCapabilities, PERMISSION_MODES: PERMISSION_MODES }; diff --git a/lib/yoke/adapters/claude-worker.js b/lib/yoke/adapters/claude-worker.js index f7157912..33017827 100644 --- a/lib/yoke/adapters/claude-worker.js +++ b/lib/yoke/adapters/claude-worker.js @@ -19,6 +19,8 @@ var path = require("path"); var fs = require("fs"); var os = require("os"); var parseDiagnosticLine = require("./diagnostic-patterns").parseDiagnosticLine; +var permissionRelay = require("./permission-relay"); +var effortControl = require("./effort-control"); var socketPath = process.argv[2]; if (!socketPath) { @@ -305,14 +307,7 @@ function handleMessage(msg) { // --- canUseTool: delegates to daemon via IPC --- function canUseTool(toolName, input, opts) { var requestId = crypto.randomUUID(); - sendToDaemon({ - type: "permission_request", - requestId: requestId, - toolName: toolName, - input: input, - toolUseId: opts.toolUseID || "", - decisionReason: opts.decisionReason || "", - }); + sendToDaemon(permissionRelay.toRequestMessage(requestId, toolName, input, opts)); return new Promise(function(resolve) { var timeoutId = setTimeout(function() { if (pendingPermissions[requestId]) { @@ -643,13 +638,18 @@ async function handleSetModel(msg) { } } +// requestId is echoed on both outcomes so the daemon's pending setEffort() +// resolves or rejects on the real result. async function handleSetEffort(msg) { - if (!queryInstance) return; + if (!queryInstance) { + sendToDaemon({ type: "worker_error", error: "Failed to set effort: no running query", requestId: msg.requestId }); + return; + } try { - await queryInstance.setEffort(msg.effort); - sendToDaemon({ type: "effort_changed", effort: msg.effort }); + await effortControl.applyEffort(queryInstance, msg.effort); + sendToDaemon({ type: "effort_changed", effort: msg.effort, requestId: msg.requestId }); } catch (e) { - sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e) }); + sendToDaemon({ type: "worker_error", error: "Failed to set effort: " + (e.message || e), requestId: msg.requestId }); } } diff --git a/lib/yoke/adapters/claude.js b/lib/yoke/adapters/claude.js index 2c3594fd..097a66d4 100644 --- a/lib/yoke/adapters/claude.js +++ b/lib/yoke/adapters/claude.js @@ -13,6 +13,9 @@ var { spawn } = require("child_process"); var { resolveOsUserInfo } = require("../../os-users"); var modelFamilies = require("../../model-families"); var modelCatalog = require("../../model-catalog"); +var permissionRelay = require("./permission-relay"); +var effortControl = require("./effort-control"); +var claudeCapabilities = require("./claude-capabilities").claudeCapabilities; // --- SDK loading --- // Async loader (ESM dynamic import, same pattern as current project.js getSDK) @@ -380,10 +383,11 @@ function createQueryHandle(rawQuery, messageQueue, abortController) { return Promise.resolve(); }, + // The SDK's Query has no setEffort; applyFlagSettings is the live control. + // Rejects when the change did not take effect, so the caller never + // reports an effort the session is not running at. setEffort: function(effort) { - // Claude SDK has no setEffort on active query. - // Stored at the console level for next query. - return Promise.resolve(); + return effortControl.applyEffort(rawQuery, effort); }, setToolPolicy: function(policy) { @@ -806,6 +810,27 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // then project-sessions.js) to surface to the user. var pendingSetModel = Object.create(null); + // Same correlation for setEffort: the returned promise settles on the + // worker's real outcome, or rejects when the worker never answers. + var pendingSetEffort = Object.create(null); + var SET_EFFORT_TIMEOUT_MS = 15000; + + function settleSetEffort(requestId, error) { + var pending = pendingSetEffort[requestId]; + if (!pending) return; + delete pendingSetEffort[requestId]; + clearTimeout(pending.timer); + if (error) pending.reject(error); + else pending.resolve(); + } + + // The worker's query is over: no pending effort change can still land. + function failPendingEfforts() { + Object.keys(pendingSetEffort).forEach(function (requestId) { + settleSetEffort(requestId, new Error("The Claude query ended before the effort change took effect")); + }); + } + // ":" -> AbortController whose signal is handed to // the daemon-side callback, so the daemon-side prompt ends when the worker // stops waiting for it: its own timeout, the SDK cancelling the callback, @@ -886,11 +911,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) case "permission_request": if (canUseTool) { relayPrompt("permission:" + msg.requestId, function (signal) { - return canUseTool(msg.toolName, msg.input, { - toolUseID: msg.toolUseId, - decisionReason: msg.decisionReason, - signal: signal, - }); + return canUseTool(msg.toolName, msg.input, permissionRelay.toCanUseToolOptions(msg, signal)); }, function (result) { worker.send({ type: "permission_response", requestId: msg.requestId, result: result }); }, "permission_response"); @@ -952,12 +973,16 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) break; case "context_usage": - case "effort_changed": case "permission_mode_changed": // Yield these as _worker_meta events so processQueryStream can handle them pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); break; + case "effort_changed": + pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); + settleSetEffort(msg.requestId, null); + break; + case "model_changed": // lr-f22787: still yielded as a _worker_meta event (unchanged // consumer contract for sdk-bridge.js), AND resolves the pending @@ -973,11 +998,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) break; case "worker_error": + // A failure of an in-flight set_effort request is reported by the + // rejection of setEffort() alone: also yielding it as a _worker_meta + // event would show the operator the same error twice. + if (msg.requestId && pendingSetEffort[msg.requestId]) { + settleSetEffort(msg.requestId, new Error(msg.error || "Failed to set effort")); + break; + } // Yielded as a _worker_meta event (unchanged consumer contract), AND // rejects the pending setModel() promise when this worker_error - // belongs to an in-flight set_model request (lr-f22787). worker_error - // is also used for effort/permission-mode failures, which carry no - // requestId and so never match a pendingSetModel entry. + // belongs to an in-flight set_model request (lr-f22787). A + // permission-mode failure carries no requestId and so never matches + // a pendingSetModel entry. pushToIter({ type: "_worker_meta", subtype: msg.type, data: msg }); if (msg.requestId && pendingSetModel[msg.requestId]) { var smErr = pendingSetModel[msg.requestId]; @@ -1001,6 +1033,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) worker._queryEnded = true; // The worker's query is over; nothing in it waits for an answer. abortAllPrompts(); + failPendingEfforts(); endIter(); break; @@ -1010,6 +1043,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) if (msg._fallback && worker._queryEnded) break; worker._queryEnded = true; abortAllPrompts(); + failPendingEfforts(); var err = new Error(msg.error || "Worker query error"); err.exitCode = msg.exitCode; err.stderr = msg.stderr; @@ -1095,8 +1129,18 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) }, setEffort: function(effort) { - worker.send({ type: "set_effort", effort: effort }); - return Promise.resolve(); + var requestId = crypto.randomUUID(); + return new Promise(function(resolve, reject) { + var timer = setTimeout(function() { + settleSetEffort(requestId, new Error("The Claude worker did not confirm the effort change")); + }, SET_EFFORT_TIMEOUT_MS); + pendingSetEffort[requestId] = { resolve: resolve, reject: reject, timer: timer }; + try { + worker.send({ type: "set_effort", requestId: requestId, effort: effort }); + } catch (e) { + settleSetEffort(requestId, e); + } + }); }, setToolPolicy: function(policy) { @@ -1199,7 +1243,7 @@ function createWorkerQueryHandle(worker, canUseTool, onElicitation, callMcpTool) // vendor-reported list always wins outright when present (see // enrichClaudeModel below) — this table exists purely to cover the case // where the vendor reports no levels at all, not as the primary source. -var CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; +var CLAUDE_EFFORT_LEVELS = effortControl.EFFORT_LEVELS; // Display name, thinking/effort capability heuristics, and family/version // parsing all live in ../../model-families.js (lr-d91ecf) — the single @@ -1825,17 +1869,7 @@ function createClaudeAdapter(opts) { skills: [], slashCommands: [], fastModeState: null, - capabilities: { - thinking: true, - betas: true, - rewind: true, - sessionResume: true, - promptSuggestions: true, - elicitation: true, - fileCheckpointing: true, - contextCompacting: true, - toolPolicy: ["ask", "allow-all"], - }, + capabilities: claudeCapabilities(), }; try { @@ -1915,17 +1949,7 @@ function createClaudeAdapter(opts) { skills: r.skills || [], slashCommands: r.slashCommands || [], fastModeState: r.fastModeState || null, - capabilities: { - thinking: true, - betas: true, - rewind: true, - sessionResume: true, - promptSuggestions: true, - elicitation: true, - fileCheckpointing: true, - contextCompacting: true, - toolPolicy: ["ask", "allow-all"], - }, + capabilities: claudeCapabilities(), }); worker.kill(); } else if (msg.type === "warmup_error" && !warmupDone) { @@ -1962,7 +1986,9 @@ module.exports = { // alias untouched, not a substituted concrete ID.) _test_mergeStaticCatalog: mergeStaticCatalog, _test_enrichClaudeModelsWithCatalog: enrichClaudeModelsWithCatalog, - // Test seam: drives the worker IPC prompt relay over a fake worker. + // Test seams: drive the in-process query handle over a fake SDK Query and + // the worker IPC relay over a fake worker. + _test_createQueryHandle: createQueryHandle, _test_createWorkerQueryHandle: createWorkerQueryHandle, }; diff --git a/lib/yoke/adapters/effort-control.js b/lib/yoke/adapters/effort-control.js new file mode 100644 index 00000000..3060c045 --- /dev/null +++ b/lib/yoke/adapters/effort-control.js @@ -0,0 +1,34 @@ +"use strict"; + +// Changing the reasoning effort of a Claude session that is already running. +// The SDK's Query has no setEffort; the live control is applyFlagSettings +// with an effortLevel, which takes effect for the rest of the session. +// 'max' is session-only and runs as 'high' on a model without it, which the +// SDK decides, so the console passes it through. +// +// Shared by the in-process query handle and the worker process, so the two +// paths cannot disagree about what a valid level is or what a failure is. + +var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; + +/** + * @param {object} query - the SDK Query. + * @param {string} effort - one of EFFORT_LEVELS. + * @returns {Promise} rejects with a message fit to show the operator. + */ +function applyEffort(query, effort) { + if (EFFORT_LEVELS.indexOf(effort) === -1) { + return Promise.reject(new Error("Unknown effort level: " + String(effort).slice(0, 40))); + } + if (!query || typeof query.applyFlagSettings !== "function") { + return Promise.reject(new Error("This Claude Code version cannot change effort in a running session")); + } + return Promise.resolve().then(function () { + return query.applyFlagSettings({ effortLevel: effort }); + }); +} + +module.exports = { + EFFORT_LEVELS: EFFORT_LEVELS, + applyEffort: applyEffort, +}; diff --git a/lib/yoke/adapters/permission-relay.js b/lib/yoke/adapters/permission-relay.js new file mode 100644 index 00000000..1ff2d45b --- /dev/null +++ b/lib/yoke/adapters/permission-relay.js @@ -0,0 +1,58 @@ +"use strict"; + +// The wire shape of a tool-permission ask between a Claude worker process and +// the daemon. The worker builds the message from the options the SDK handed +// its canUseTool callback; the daemon turns it back into the options its own +// canUseTool callback takes. Both sides use these two functions so a field the +// SDK adds to an ask cannot be carried by one and dropped by the other. +// +// The presentation hints travel with the ask: suppressAlwaysAllowRule (no +// persistent "don't ask again" choice), defaultToNo (the ask must not open on +// an approve choice) and matchedAskRule (a user-written ask rule forced the +// prompt, so no host-side auto-approval may answer it). Only a literal true +// restricts, and a rule is data, not a flag: anything present but not an object +// is still a rule-forced ask. + +/** + * @param {string} requestId - the worker's id for this ask. + * @param {string} toolName + * @param {object} input + * @param {object} opts - the SDK's canUseTool options. + * @returns {object} the IPC message. + */ +function toRequestMessage(requestId, toolName, input, opts) { + var msg = { + type: "permission_request", + requestId: requestId, + toolName: toolName, + input: input, + toolUseId: opts.toolUseID || "", + decisionReason: opts.decisionReason || "", + }; + if (opts.suppressAlwaysAllowRule === true) msg.suppressAlwaysAllowRule = true; + if (opts.defaultToNo === true) msg.defaultToNo = true; + if (opts.matchedAskRule) msg.matchedAskRule = opts.matchedAskRule; + return msg; +} + +/** + * @param {object} msg - a permission_request IPC message from a worker. + * @param {AbortSignal} signal - ends the ask when the worker stops waiting. + * @returns {object} canUseTool options for the daemon-side callback. + */ +function toCanUseToolOptions(msg, signal) { + var opts = { + toolUseID: msg.toolUseId, + decisionReason: msg.decisionReason, + signal: signal, + }; + if (msg.suppressAlwaysAllowRule === true) opts.suppressAlwaysAllowRule = true; + if (msg.defaultToNo === true) opts.defaultToNo = true; + if (msg.matchedAskRule) opts.matchedAskRule = typeof msg.matchedAskRule === "object" ? msg.matchedAskRule : {}; + return opts; +} + +module.exports = { + toRequestMessage: toRequestMessage, + toCanUseToolOptions: toCanUseToolOptions, +}; diff --git a/lib/yoke/interface.js b/lib/yoke/interface.js index ae13470e..c6b60e64 100644 --- a/lib/yoke/interface.js +++ b/lib/yoke/interface.js @@ -56,7 +56,12 @@ var TOOL_POLICIES = ["ask", "allow-all"]; * [Symbol.asyncIterator]() - yields SDK events (raw in Phase 3, normalized later) * .pushMessage(text, images) * .setModel(model) - * .setEffort(effort) + * .setEffort(effort) - Promise; settles once the running query has + * taken the change and rejects (with a message + * fit to show the operator) when it did not. The + * Claude SDK's Query has no setEffort: the Claude + * adapter applies it through applyFlagSettings + * ({effortLevel}); Codex reads it on the next turn. * .setToolPolicy(policy) - "ask" | "allow-all" * .stopTask(taskId) * .getContextUsage() - Promise From df5c25bf6a1b3eda3bf8d4b6a7185703de3b74e3 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 22:35:52 -0400 Subject: [PATCH 2/4] fix(ui): omit Allow for session on a suppressed ask, open a defaultToNo ask on No, offer Auto and Don't ask by capability (lr-2ebc65) The card and the notification banner read one choice list (permission-choices.js), so they cannot offer different answers to one ask. The card shows the user rule that forced an ask. The mode picker offers Don't ask and Auto only for a session whose vendor declares them. A send that throws is reported as such instead of as a dropped connection, and a recorded AskUserQuestion skip replays as skipped. Co-Authored-By: Claude Sonnet 5.5 --- lib/public/css/rewind.css | 17 +++++ lib/public/modules/app-notifications.js | 23 ++----- lib/public/modules/app-panels.js | 24 ++----- lib/public/modules/mode-options.js | 38 ++++++++++ lib/public/modules/prompt-banner.js | 31 +++++++++ lib/public/modules/prompt-card.js | 10 ++- .../prompt-kinds/permission-choices.js | 31 +++++++++ lib/public/modules/prompt-kinds/permission.js | 69 ++++++++++++++----- lib/public/modules/prompt-state.js | 4 ++ 9 files changed, 189 insertions(+), 58 deletions(-) create mode 100644 lib/public/modules/mode-options.js create mode 100644 lib/public/modules/prompt-kinds/permission-choices.js diff --git a/lib/public/css/rewind.css b/lib/public/css/rewind.css index 18a01d2f..2462cf47 100644 --- a/lib/public/css/rewind.css +++ b/lib/public/css/rewind.css @@ -616,6 +616,23 @@ .permission-deny { background: transparent; border-color: var(--border); color: var(--text-muted); } .permission-deny:hover { background: var(--error-8); border-color: var(--error); color: var(--error); opacity: 1; } +/* The user's own ask rule that forced the prompt. */ +.permission-rule { font-size: 12px; color: var(--text-muted); margin-top: 6px; } + +/* An ask that must open on No: the approve choices lose their primary + styling and the decline choice leads. */ +.permission-container[data-default-to-no="true"] .permission-allow, +.permission-container[data-default-to-no="true"] .perm-allow, +.permission-container[data-default-to-no="true"] .perm-allow:hover { + background: transparent; + color: var(--text-muted); + border-color: var(--border); +} +.permission-container[data-default-to-no="true"] [data-default-choice="true"] { + border-color: var(--text-dimmer); + color: var(--text); +} + /* Resolved states */ .permission-container.resolved .permission-btn { display: none; } .permission-decision-label { font-size: 13px; font-weight: 500; } diff --git a/lib/public/modules/app-notifications.js b/lib/public/modules/app-notifications.js index e4212369..e1b98c40 100644 --- a/lib/public/modules/app-notifications.js +++ b/lib/public/modules/app-notifications.js @@ -12,23 +12,10 @@ import { switchProject } from './app-projects.js'; import { userAvatarUrl } from './avatar.js'; import { openTerminal } from './terminal.js'; import { askWording } from './prompt-kinds/index.js'; -import { ownValue } from './prompt-kinds/own-key.js'; -import { wirePromptBanner, restoreUnconfirmedBanners } from './prompt-banner.js'; - -// What a prompt banner offers, by prompt kind. Kinds without an entry offer -// only "Go to session". The server dismisses the banner once the prompt -// settles, however it was answered. -var BANNER_ACTIONS = { - permission: [ - { decision: "allow", label: "Sure", className: "notif-banner-allow" }, - { decision: "allow_always", label: "Allow for session", className: "notif-banner-always" }, - { decision: "deny", label: "No", className: "notif-banner-deny" }, - ], - plan: [ - { decision: "allow", label: "Approve", className: "notif-banner-allow" }, - { decision: "deny", label: "Reject", className: "notif-banner-deny" }, - ], -}; +import { wirePromptBanner, restoreUnconfirmedBanners, bannerActions } from './prompt-banner.js'; + +// The server dismisses a prompt's banner once the prompt settles, however it +// was answered. var notifications = []; var unreadCount = 0; var bannerContainer = null; @@ -139,7 +126,7 @@ function showBanner(notif, autoDismissMs) { if (isPermission) { actionsHtml = '
' + - (ownValue(BANNER_ACTIONS, promptKind) || []).map(function (a) { + bannerActions(promptKind, notif.meta).map(function (a) { return ''; }).join("") + '' + diff --git a/lib/public/modules/app-panels.js b/lib/public/modules/app-panels.js index a027893c..caf5444d 100644 --- a/lib/public/modules/app-panels.js +++ b/lib/public/modules/app-panels.js @@ -8,6 +8,7 @@ import { getWs } from './ws-ref.js'; import { VENDOR_NAMES } from './app-rendering.js'; import { splitModelsByTier } from './settings-defaults.js'; import { isSonnetModel } from './model-families.js'; +import { modeOptions } from './mode-options.js'; // --- Module-owned state (not in store) --- // sessionUsage.context = last-turn context fill (lastStreamInputTokens) — not cumulative, @@ -82,12 +83,6 @@ var contextMiniFill = null; var contextMiniLabel = null; // --- Constants --- -var MODE_OPTIONS = [ - { value: "default", label: "Default" }, - { value: "plan", label: "Plan" }, - { value: "acceptEdits", label: "Auto-accept edits" }, -]; -var MODE_FULL_AUTO = { value: "bypassPermissions", label: "Full auto" }; var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; var EFFORT_LEVELS_BY_VENDOR = { claude: ["low", "medium", "high", "xhigh", "max"], @@ -132,15 +127,6 @@ function modelDisplayName(value, models) { return value; } -function modeDisplayName(value) { - for (var i = 0; i < MODE_OPTIONS.length; i++) { - if (MODE_OPTIONS[i].value === value) return MODE_OPTIONS[i].label; - } - if (value === "bypassPermissions") return "Full auto"; - if (value === "dontAsk") return "Don\u2019t ask"; - return value; -} - function effortDisplayName(value) { if (!value) return ""; if (value === "xhigh") return "X-High"; @@ -266,10 +252,7 @@ function rebuildModelList() { function rebuildModeList() { if (!configModeList) return; configModeList.innerHTML = ""; - var options = MODE_OPTIONS.slice(); - if (store.get('skipPermsEnabled')) { - options.push(MODE_FULL_AUTO); - } + var options = modeOptions(store.get('vendorCapabilities'), store.get('skipPermsEnabled')); for (var i = 0; i < options.length; i++) { var opt = options[i]; var btn = document.createElement("button"); @@ -480,7 +463,7 @@ export function initPanels() { if (vendorBtnCodex) vendorBtnCodex.addEventListener("click", function() { onVendorClick("codex"); }); // --- Reactive UI sync --- - store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking'], function (state, prev) { + store.subscribe(['availableVendors', 'installedVendors', 'currentVendor', 'richContextUsage', 'ctxPopoverVisible', 'currentModel', 'currentModels', 'currentMode', 'currentEffort', 'currentBetas', 'currentThinking', 'vendorCapabilities'], function (state, prev) { // Vendor toggle state if (state.availableVendors !== prev.availableVendors || state.installedVendors !== prev.installedVendors || @@ -513,6 +496,7 @@ export function initPanels() { state.currentBetas !== prev.currentBetas || state.currentThinking !== prev.currentThinking || state.currentVendor !== prev.currentVendor || + state.vendorCapabilities !== prev.vendorCapabilities || state.codexApproval !== prev.codexApproval || state.codexSandbox !== prev.codexSandbox || state.codexWebSearch !== prev.codexWebSearch) { diff --git a/lib/public/modules/mode-options.js b/lib/public/modules/mode-options.js new file mode 100644 index 00000000..dd2f7f88 --- /dev/null +++ b/lib/public/modules/mode-options.js @@ -0,0 +1,38 @@ +// mode-options.js - the permission modes the config chip offers, and their +// labels. Import-free so it can be exercised without the frontend module +// graph. +// +// Default, Plan and Auto-accept edits are offered for every session. Don't +// ask and Auto exist only in Claude Code: they are offered for a session whose +// vendor declares them in capabilities.permissionModes, so a vendor that does +// not (Codex, or one whose capabilities are not known yet) never shows them. +// Full auto is offered when the daemon allows it. Labels follow the Claude +// Code CLI's wording. + +var BASE_MODES = [ + { value: "default", label: "Default" }, + { value: "plan", label: "Plan" }, + { value: "acceptEdits", label: "Auto-accept edits" }, +]; + +var CAPABILITY_MODES = [ + { value: "dontAsk", label: "Don’t ask" }, + { value: "auto", label: "Auto" }, +]; + +var FULL_AUTO = { value: "bypassPermissions", label: "Full auto" }; + +/** + * @param {?object} capabilities - the session vendor's capabilities. + * @param {boolean} fullAutoEnabled + * @returns {{value: string, label: string}[]} + */ +export function modeOptions(capabilities, fullAutoEnabled) { + var declared = capabilities && Array.isArray(capabilities.permissionModes) ? capabilities.permissionModes : []; + var options = BASE_MODES.slice(); + CAPABILITY_MODES.forEach(function (m) { + if (declared.indexOf(m.value) !== -1) options.push(m); + }); + if (fullAutoEnabled) options.push(FULL_AUTO); + return options; +} diff --git a/lib/public/modules/prompt-banner.js b/lib/public/modules/prompt-banner.js index 7fb3bed8..c8116e66 100644 --- a/lib/public/modules/prompt-banner.js +++ b/lib/public/modules/prompt-banner.js @@ -10,10 +10,41 @@ // it can be exercised without the rest of the frontend module graph. import { definePromptSurface, submitPromptResponse, restoreUnconfirmedUnder } from './prompt-card.js'; +import { permissionChoices } from './prompt-kinds/permission-choices.js'; var DECISION_BUTTONS = "button[data-decision]"; var ACTIONS = ".notif-banner-actions"; +var PERMISSION_BUTTONS = { + allow: { label: "Sure", className: "notif-banner-allow" }, + allow_always: { label: "Allow for session", className: "notif-banner-always" }, + deny: { label: "No", className: "notif-banner-deny" }, +}; + +var PLAN_BUTTONS = [ + { decision: "allow", label: "Approve", className: "notif-banner-allow" }, + { decision: "deny", label: "Reject", className: "notif-banner-deny" }, +]; + +/** + * The answer buttons a prompt's banner offers; a kind with none offers only + * "Go to session". A tool-permission banner offers the card's answers, in the + * card's order: no Allow for session for an ask that may not offer it, and + * No first for an ask that must open on No. + * @param {string} kind + * @param {object} [meta] - the notification's meta (the ask's hints). + * @returns {{decision: string, label: string, className: string}[]} + */ +export function bannerActions(kind, meta) { + if (kind === "permission") { + return permissionChoices(meta).map(function (decision) { + return Object.assign({ decision: decision }, PERMISSION_BUTTONS[decision]); + }); + } + if (kind === "plan") return PLAN_BUTTONS.map(function (a) { return Object.assign({}, a); }); + return []; +} + /** * Wire a banner's decision buttons to answer its prompt. * @param {Element} banner diff --git a/lib/public/modules/prompt-card.js b/lib/public/modules/prompt-card.js index 21e3ac7f..a15bbd38 100644 --- a/lib/public/modules/prompt-card.js +++ b/lib/public/modules/prompt-card.js @@ -120,7 +120,15 @@ export function submitPromptResponse(container, trySend, opts) { if (isSettled(container) || isUnconfirmed(container)) return false; var sent = false; - try { sent = !!trySend(); } catch (e) { sent = false; } + try { + sent = !!trySend(); + } catch (e) { + // A failure of the send itself is not a dropped connection, and saying so + // would send the operator to reconnect for nothing. + console.error("[prompt-card] sending an answer failed:", e); + addNote(container, "Could not send the answer (" + ((e && e.message) || "unexpected error") + "). Choose again to retry.", true); + return false; + } if (!sent) { addNote(container, "Not connected. Reconnect, then choose again.", true); return false; diff --git a/lib/public/modules/prompt-kinds/permission-choices.js b/lib/public/modules/prompt-kinds/permission-choices.js new file mode 100644 index 00000000..dad74d7f --- /dev/null +++ b/lib/public/modules/prompt-kinds/permission-choices.js @@ -0,0 +1,31 @@ +// permission-choices.js - which answers a tool-permission ask offers, and in +// what order. The card (permission.js) and the notification banner +// (../prompt-banner.js) both read it, so they cannot offer different answers +// to one ask. +// +// The Claude Agent SDK may attach presentation hints to an ask: +// suppressAlwaysAllowRule no persistent "allow for session" choice; +// defaultToNo the ask opens on its decline choice, so a stray +// keystroke or tap cannot approve it. +// The server refuses an Allow-for-session answer to a suppressed ask whatever +// a client sends (lib/prompt-kinds/permission.js); omitting the choice here is +// the presentation of that rule, not its enforcement. +// +// Deliberately free of imports so the banner can load it without the rest of +// the frontend module graph. + +/** + * @param {object} [hints] - the ask's request fields. + * @returns {string[]} decisions in the order they are shown; the first is the + * default choice when the ask is defaultToNo. + */ +export function permissionChoices(hints) { + var h = hints || {}; + var approve = h.suppressAlwaysAllowRule === true ? ["allow"] : ["allow", "allow_always"]; + return h.defaultToNo === true ? ["deny"].concat(approve) : approve.concat(["deny"]); +} + +/** Whether an ask must open on its decline choice. */ +export function opensOnNo(hints) { + return !!hints && hints.defaultToNo === true; +} diff --git a/lib/public/modules/prompt-kinds/permission.js b/lib/public/modules/prompt-kinds/permission.js index dedc258c..9b660e96 100644 --- a/lib/public/modules/prompt-kinds/permission.js +++ b/lib/public/modules/prompt-kinds/permission.js @@ -1,9 +1,50 @@ // Tool-call approval card: Allow Once / Allow for Session / Deny, drawn as a // formal dialog (bubble layout) or a conversational "Can I ...?" (channel -// layout). The shell (prompts.js, prompt-card.js) owns its state. +// layout). The shell (prompts.js, prompt-card.js) owns its state. Which +// answers it offers, and which leads, follows the ask's SDK hints +// (permission-choices.js). import { escapeHtml } from '../utils.js'; import { iconHtml } from '../icons.js'; +import { permissionChoices, opensOnNo } from './permission-choices.js'; + +var FORMAL_CHOICES = { + allow: { className: "permission-btn permission-allow", label: "Allow Once" }, + allow_always: { className: "permission-btn permission-allow-session", label: "Allow for Session" }, + deny: { className: "permission-btn permission-deny", label: "Deny" }, +}; + +var CONVERSATIONAL_CHOICES = { + allow: { className: "perm-reply perm-allow", label: "Sure" }, + allow_always: { className: "perm-reply perm-always", label: "Allow for session" }, + deny: { className: "perm-reply perm-deny", label: "No" }, +}; + +// The buttons for an ask's answers, in the order the ask allows. An ask that +// must open on No leads with its decline button and marks it the default; +// no approve button is ever marked. +function choiceButtons(fields, table, container, host) { + var leadsWithNo = opensOnNo(fields); + return permissionChoices(fields).map(function (decision, i) { + var spec = table[decision]; + var btn = button(spec.className, spec.label, function () { + host.respond(container, { decision: decision }); + }); + if (leadsWithNo && i === 0) btn.dataset.defaultChoice = "true"; + return btn; + }); +} + +// The user's own ask rule that forced this prompt, when the SDK says so. +function ruleNote(fields) { + var rule = fields.matchedAskRule; + if (!rule || typeof rule !== "object") return null; + var rendered = (rule.toolName || "") + (rule.ruleContent ? "(" + rule.ruleContent + ")" : ""); + var note = document.createElement("div"); + note.className = "permission-rule"; + note.textContent = "Asked because of your rule" + (rendered ? " " + rendered : "") + (rule.source ? " (" + rule.source + ")" : ""); + return note; +} function shortPath(p) { if (!p) return ""; @@ -95,19 +136,14 @@ function drawFormal(fields, requestId, host) { reason.textContent = fields.decisionReason; body.appendChild(reason); } + var rule = ruleNote(fields); + if (rule) body.appendChild(rule); body.appendChild(detailsElement("permission-details", fields.toolInput)); var actions = document.createElement("div"); actions.className = "permission-actions"; - actions.appendChild(button("permission-btn permission-allow", "Allow Once", function () { - host.respond(container, { decision: "allow" }); - })); - actions.appendChild(button("permission-btn permission-allow-session", "Allow for Session", function () { - host.respond(container, { decision: "allow_always" }); - })); - actions.appendChild(button("permission-btn permission-deny", "Deny", function () { - host.respond(container, { decision: "deny" }); - })); + choiceButtons(fields, FORMAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); }); + if (opensOnNo(fields)) container.dataset.defaultToNo = "true"; container.appendChild(header); container.appendChild(body); @@ -143,19 +179,14 @@ function drawConversational(fields, requestId, host) { askEl.className = "perm-ask"; askEl.textContent = "Can I " + wording.verb + (wording.target ? " " + wording.target : "") + "?"; content.appendChild(askEl); + var rule = ruleNote(fields); + if (rule) content.appendChild(rule); content.appendChild(detailsElement("perm-details", fields.toolInput)); var actions = document.createElement("div"); actions.className = "permission-actions"; - actions.appendChild(button("perm-reply perm-allow", "Sure", function () { - host.respond(container, { decision: "allow" }); - })); - actions.appendChild(button("perm-reply perm-always", "Allow for session", function () { - host.respond(container, { decision: "allow_always" }); - })); - actions.appendChild(button("perm-reply perm-deny", "No", function () { - host.respond(container, { decision: "deny" }); - })); + choiceButtons(fields, CONVERSATIONAL_CHOICES, container, host).forEach(function (btn) { actions.appendChild(btn); }); + if (opensOnNo(fields)) container.dataset.defaultToNo = "true"; content.appendChild(actions); container.appendChild(content); return container; diff --git a/lib/public/modules/prompt-state.js b/lib/public/modules/prompt-state.js index 951d9832..eb04b09f 100644 --- a/lib/public/modules/prompt-state.js +++ b/lib/public/modules/prompt-state.js @@ -128,6 +128,10 @@ export function normalizePromptMessage(msg) { if (msg.answers && Object.keys(msg.answers).length) { return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { answers: msg.answers }, serverState: null }; } + // A recorded skip is an answer, not an ending nobody chose. + if (msg.skipped === true) { + return { phase: "resolved", requestId: msg.toolId, kind: "ask_user", fields: { skipped: true }, serverState: null }; + } return { phase: "cancel", requestId: msg.toolId, kind: "ask_user", fields: { reason: null }, serverState: null }; case "prompt_cancel": case "permission_cancel": From 0ffa94398e16425b0d4de0b71e554844661a4b03 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 22:36:01 -0400 Subject: [PATCH 3/4] test(permission): cover the SDK ask hints, worker effort and mode gating on every caller shape (lr-2ebc65) Hint fields join the prompt harness world, the round-trip property test (every kind, with the connect snapshot) and the invariant model (I9). A real claude-worker.js process runs against a stand-in SDK to prove the IPC payloads and the applyFlagSettings path. Adds the schema generator's missing one-sided bounds, tracks rejected callbacks in the hostile-id test, makes a lib that cannot express an answer explicit in the invariant harness, and documents the hints, the effort control and the capability-gated modes. Co-Authored-By: Claude Sonnet 5.5 --- docs/guides/MODULE_MAP.md | 6 +- docs/guides/architecture.md | 14 + test/claude-effort-control.test.js | 248 ++++++++++++ test/claude-worker-process.test.js | 155 ++++++++ test/fake-dom-prompt-cards.js | 1 - test/fixtures/fake-agent-sdk/register.mjs | 5 + test/fixtures/fake-agent-sdk/resolve-hook.mjs | 7 + test/fixtures/fake-agent-sdk/sdk.mjs | 64 +++ test/permission-hints.test.js | 375 ++++++++++++++++++ test/permission-mode-picker.test.js | 116 ++++++ test/prompt-harness-world.js | 25 +- test/prompt-hostile-request-id.test.js | 4 +- test/prompt-invariants.test.js | 50 ++- test/prompt-kind-roundtrip.test.js | 101 ++++- test/prompt-path-followups.test.js | 109 +++++ test/shared-codecs.test.js | 1 - 16 files changed, 1268 insertions(+), 13 deletions(-) create mode 100644 test/claude-effort-control.test.js create mode 100644 test/claude-worker-process.test.js create mode 100644 test/fixtures/fake-agent-sdk/register.mjs create mode 100644 test/fixtures/fake-agent-sdk/resolve-hook.mjs create mode 100644 test/fixtures/fake-agent-sdk/sdk.mjs create mode 100644 test/permission-hints.test.js create mode 100644 test/permission-mode-picker.test.js create mode 100644 test/prompt-path-followups.test.js diff --git a/docs/guides/MODULE_MAP.md b/docs/guides/MODULE_MAP.md index f90af54f..cf099a04 100644 --- a/docs/guides/MODULE_MAP.md +++ b/docs/guides/MODULE_MAP.md @@ -80,6 +80,9 @@ YOKE is the vendor-agnostic interface layer. Each adapter implements the same co | `yoke/index.js` | Adapter factory, wraps createQuery with project instructions | | `yoke/interface.js` | YOKE interface contract definition | | `yoke/adapters/claude.js` | Claude adapter using `@anthropic-ai/claude-agent-sdk`. In-process + worker (OS user isolation) paths | +| `yoke/adapters/permission-relay.js` | The wire shape of a tool-permission ask between a Claude worker and the daemon, including the SDK's presentation hints (`suppressAlwaysAllowRule`, `defaultToNo`, `matchedAskRule`); both sides build and read it here | +| `yoke/adapters/effort-control.js` | Changing a running Claude session's effort through `applyFlagSettings({effortLevel})`; shared by the in-process handle and the worker | +| `yoke/adapters/claude-capabilities.js` | What a Claude session can do, sent to the client (including the `permissionModes` the mode picker offers) | | `yoke/adapters/codex.js` | Codex adapter using `codex app-server` JSON-RPC protocol. Handles approval events, skill injection, MCP bridge config | | `yoke/codex-app-server.js` | Codex `app-server` child process manager. JSON-RPC 2.0 over stdin/stdout, request ID tracking, event routing | | `yoke/mcp-bridge-server.js` | Stdio MCP server spawned by Codex. Proxies tool list/call to the daemon via HTTP at `/api/mcp-bridge` | @@ -144,7 +147,8 @@ Bootstraps UI, initializes store, wires remaining Tier 3 modules. All business l | `prompts.js` | The client's single owner of operator-prompt cards: every prompt message, of every kind and recorded shape, applies through `apply()`; a card is a function of the server's state for its requestId | | `prompt-state.js` | Per-requestId mirror of the server's prompt state (terminal states are final) and the mapping of older recorded message shapes onto the one prompt shape | | `prompt-card.js` | The card shell every kind shares: pending/sending/confirmed state machine, terminal outcome rendering | -| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels | +| `prompt-kinds/` | One renderer per prompt kind (permission, plan, ask-user, elicitation): body and answer controls, outcome labels. `permission-choices.js` decides which answers a tool-permission ask offers, and in what order, from the SDK's hints; the card and the banner both read it | +| `mode-options.js` | The permission modes the config chip offers: the three every session has, Don't ask and Auto only for a vendor that declares them, Full auto when the daemon allows it | | `app-skills-install.js` | Skill install dialog, requireSkills | | `app-favicon.js` | Dynamic favicon, IO blink, urgent blink, send button mode, activity indicator | | `app-header.js` | Session rename, session info popover, progressive history loading | diff --git a/docs/guides/architecture.md b/docs/guides/architecture.md index e6872721..0645c17c 100644 --- a/docs/guides/architecture.md +++ b/docs/guides/architecture.md @@ -155,6 +155,8 @@ graph LR For deeper Codex integration patterns and gotchas, see [CODEX-INTEGRATION.md](./CODEX-INTEGRATION.md). +**Mid-session controls.** The Claude SDK's `Query` has no `setEffort`; the Claude adapter changes a running session's effort through `applyFlagSettings({effortLevel})` (`yoke/adapters/effort-control.js`, shared by the in-process handle and the worker). A QueryHandle's `setEffort` settles on the real outcome and rejects when the change did not take effect, so the bridge announces a new effort only after the query has taken it and shows the operator the failure otherwise; Codex reads its effort on the next turn. Which permission modes a session accepts is a capability: the Claude adapter lists `permissionModes` (including `dontAsk` and `auto`, which are Claude Code modes) in the capabilities it sends with `session_switched`, the mode picker (`public/modules/mode-options.js`) offers those two only when the session's vendor declares them, and `set_permission_mode` refuses them for a session whose vendor does not. A vendor whose capabilities are not known is treated as declaring nothing. + ## Multi-User and OS-Level Isolation Clagentic:Console supports two modes: @@ -248,6 +250,18 @@ A prompt is `pending` until exactly one transition settles it — an answer (`re Ownership decides scope. A top-level prompt ends with its turn or its query. A prompt whose tool call belongs to a tracked sub-agent (a running Task, or one that reported completion while that prompt was still open) survives the parent's turn and query boundaries, so the operator can still answer a backgrounded sub-agent after its parent finished; it ends only on an answer, its own abort, or the session or daemon ending. The abort is the vendor's word that the callback is gone: the Claude SDK aborts every callback still waiting when it closes a query, the worker relay does the same when the worker's query ends or the worker cancels a callback, and the Codex adapter when the app-server clears a request or the query ends. The registry never decides on its own that a sub-agent is dead. "Allow for Session" grants are written and consulted only through the registry, keyed by `permissionGrantKey()`, flushed to the session file immediately, and granted only from the authenticated in-app card: the HTTP push route answers tool approvals with allow or deny only. +#### Presentation hints on a tool-permission ask + +The Claude Agent SDK may attach three hints to an ask, and the console holds to each wherever it carries, shows or answers one. They travel from the SDK's `canUseTool` options into the prompt record on both Claude paths: in process, and over the worker IPC (`lib/yoke/adapters/permission-relay.js` builds the message in the worker and the options in the daemon, so neither side can drop a field the other carries). Codex approvals arrive through the same callback with no hints and are unchanged. + +- `suppressAlwaysAllowRule`: the ask must not offer a persistent choice. The permission kind refuses an `allow_always` answer to it as `invalid` on every response path (the card and its older aliases, the notification banner), which settles nothing, writes no grant and re-offers the prompt to the responder; the HTTP push route answers allow or deny only. Leaving the button off the card and the banner is the presentation of that rule, not its enforcement. +- `defaultToNo`: the ask must not open on an approve choice. The card and the banner lead with the decline choice, mark it the default and mark no approve choice; there is no single-key approve shortcut. +- `matchedAskRule`: a rule the user wrote forced the prompt, so the user's stated intent is a human prompt. The sdk bridge's host-side auto-approvals (full auto, an autonomous loop, the read-only whitelist, an earlier Allow for session) never answer such an ask. They do not answer an ask marked `suppressAlwaysAllowRule` or `defaultToNo` with a standing approval either, since a standing approval grants more than the ask itself. The rule (bounded text, whatever shape it arrives in) is recorded on the prompt record and on the `prompt_resolved` event, and the card shows it. + +The hints are request fields, so the recorded `prompt_request`, the `prompt_pending` snapshot sent on connect and the history pages all carry them, and a replayed card renders the same restrictions. The banner reads them from the notification's `meta`. + +A browser-extension command (kind `extension`) is answered by the socket it was sent to and no other: `extension_result` is checked against that socket, and a `prompt_response` naming an extension command is no operator answer on any path. + The registry's state lives where readers already looked for it: one pending map per store (`session.pendingPermissions` for tool and plan approvals, `session.pendingAskUser`, `session.pendingElicitations`), `session.subagentToolOwners`, `session.activeTaskToolIds` (Task tracking), and the session manager's `permissionRequestIndex`. A requestId, message type, decision, cancel reason or kind is whatever a client sent, so every map keyed by one, on the server and in the client, is read through `own-key.js` (`ownValue()` / `hasOwnKey()`: own properties only, strings only) and written through `putOwn()`. A name every object inherits (`constructor`, `__proto__`, `toString`, ...) is no prompt and no table entry: on every answer path it gets the not-found reply (`test/prompt-hostile-request-id.test.js`). #### Supported elicitation schemas diff --git a/test/claude-effort-control.test.js b/test/claude-effort-control.test.js new file mode 100644 index 00000000..c924e474 --- /dev/null +++ b/test/claude-effort-control.test.js @@ -0,0 +1,248 @@ +"use strict"; +// Changing a Claude session's effort while it runs. The SDK's Query has no +// setEffort; the live control is applyFlagSettings({effortLevel}). Every path +// that takes an effort change - the in-process query handle, the OS-isolated +// worker, the bridge and the WS handler - must act on that and must say so +// when the change did not take effect, instead of announcing an effort the +// session is not running at. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var world = require("./prompt-harness-world"); +var effortControl = require("../lib/yoke/adapters/effort-control"); +var claudeAdapter = require("../lib/yoke/adapters/claude"); + +// The shape of the real Query for this purpose: applyFlagSettings, no setEffort. +function fakeQuery(calls, failWith) { + return { + applyFlagSettings: function (settings) { + calls.push(settings); + return failWith ? Promise.reject(failWith) : Promise.resolve(); + }, + }; +} + +function inbox(client) { return client.inbox; } + +function client() { + var box = []; + return { inbox: box, ws: { readyState: 1, _clagenticUser: null, send: function (data) { box.push(JSON.parse(data)); } } }; +} + +async function withServer(fn) { + var server = await world.createServer(); + try { + await server.startQuery(); + await fn(server); + } finally { + server.shutdown(); + world.removeHome(server.home); + } +} + +test("every effort level the SDK names is applied through applyFlagSettings", async function () { + assert.deepEqual(effortControl.EFFORT_LEVELS, ["low", "medium", "high", "xhigh", "max"]); + for (var i = 0; i < effortControl.EFFORT_LEVELS.length; i++) { + var calls = []; + await effortControl.applyEffort(fakeQuery(calls), effortControl.EFFORT_LEVELS[i]); + assert.deepEqual(calls, [{ effortLevel: effortControl.EFFORT_LEVELS[i] }]); + } +}); + +test("a level the SDK does not name, or a query that cannot change effort, is a rejection and changes nothing", async function () { + var hostile = ["minimal", "", undefined, null, 3, {}, ["high"], "__proto__", "HIGH"]; + for (var i = 0; i < hostile.length; i++) { + var calls = []; + await assert.rejects(effortControl.applyEffort(fakeQuery(calls), hostile[i]), /Unknown effort level/, JSON.stringify(hostile[i])); + assert.deepEqual(calls, [], "nothing was applied for " + JSON.stringify(hostile[i])); + } + await assert.rejects(effortControl.applyEffort({}, "high"), /cannot change effort/); + await assert.rejects(effortControl.applyEffort(null, "high"), /cannot change effort/); + var boom = new Error("organization effort limit"); + await assert.rejects(effortControl.applyEffort(fakeQuery([], boom), "max"), /organization effort limit/, "the SDK's refusal reaches the caller"); + await assert.rejects(effortControl.applyEffort({ applyFlagSettings: function () { throw new Error("sync throw"); } }, "low"), /sync throw/, "a throw is a rejection too"); +}); + +test("the in-process query handle changes effort through the query, and rejects when it cannot", async function () { + var calls = []; + var queue = { push: function () {}, end: function () {} }; + var handle = claudeAdapter._test_createQueryHandle(fakeQuery(calls), queue, null); + await handle.setEffort("xhigh"); + assert.deepEqual(calls, [{ effortLevel: "xhigh" }]); + + var bare = claudeAdapter._test_createQueryHandle({}, queue, null); + await assert.rejects(bare.setEffort("high"), /cannot change effort/); + await assert.rejects(handle.setEffort("minimal"), /Unknown effort level/); +}); + +// --- the worker's daemon-side handle --------------------------------------- + +function fakeWorker() { + var worker = { sent: [], handler: null, process: null, exitPromise: null }; + worker.onMessage = function (fn) { worker.handler = fn; }; + worker.send = function (msg) { worker.sent.push(msg); }; + return worker; +} + +async function drain(handle, worker) { + worker.handler({ type: "query_done" }); + var seen = []; + var iter = handle[Symbol.asyncIterator](); + for (;;) { + var r = await iter.next(); + if (r.done) return seen; + seen.push(r.value); + } +} + +test("a worker effort change settles on the worker's own answer", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var settled = false; + var change = handle.setEffort("high").then(function () { settled = true; }); + + assert.equal(worker.sent.length, 1); + assert.equal(worker.sent[0].type, "set_effort"); + assert.equal(worker.sent[0].effort, "high"); + assert.equal(typeof worker.sent[0].requestId, "string"); + await Promise.resolve(); + assert.equal(settled, false, "not settled until the worker answers"); + + worker.handler({ type: "effort_changed", effort: "high", requestId: worker.sent[0].requestId }); + await change; + var seen = await drain(handle, worker); + assert.ok(seen.some(function (e) { return e.type === "_worker_meta" && e.subtype === "effort_changed"; }), "the bridge still hears effort_changed"); +}); + +test("a worker effort change the worker refuses is a rejection, reported once", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var change = handle.setEffort("max"); + worker.handler({ type: "worker_error", error: "Failed to set effort: organization effort limit", requestId: worker.sent[0].requestId }); + await assert.rejects(change, /organization effort limit/); + var seen = await drain(handle, worker); + assert.equal(seen.filter(function (e) { return e.type === "_worker_meta" && e.subtype === "worker_error"; }).length, 0, + "the rejection is the report: no second copy as a stream event"); +}); + +test("an answer for another request, or none, does not settle a worker effort change", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var outcome = null; + var change = handle.setEffort("low").then(function () { outcome = "resolved"; }, function (e) { outcome = e.message; }); + + worker.handler({ type: "effort_changed", effort: "low", requestId: "someone-else" }); + worker.handler({ type: "worker_error", error: "other", requestId: "__proto__" }); + worker.handler({ type: "effort_changed", effort: "low" }); + await Promise.resolve(); + assert.equal(outcome, null); + + t.mock.timers.tick(15000); + await change; + assert.match(outcome, /did not confirm the effort change/); +}); + +test("a worker query that ends rejects an effort change still waiting", async function () { + var worker = fakeWorker(); + var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); + var change = handle.setEffort("medium"); + worker.handler({ type: "query_error", error: "worker died" }); + await assert.rejects(change, /ended before the effort change/); + var iter = handle[Symbol.asyncIterator](); + await assert.rejects(iter.next()); +}); + +// --- the bridge and the WS handler ----------------------------------------- + +test("the bridge announces an effort only once the running query has taken it", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setEffort = function (effort) { applied.push(effort); return Promise.resolve(); }; + server.sm.currentEffort = "medium"; + inbox(c).length = 0; + + var ok = await server.bridge.setEffort(server.session, "high"); + assert.deepEqual(ok, { ok: true }); + assert.deepEqual(applied, ["high"]); + assert.equal(server.sm.currentEffort, "high"); + var states = inbox(c).filter(function (m) { return m.type === "config_state"; }); + assert.equal(states[states.length - 1].effort, "high"); + + server.session.queryInstance.setEffort = function () { return Promise.reject(new Error("organization effort limit")); }; + inbox(c).length = 0; + var failed = await server.bridge.setEffort(server.session, "max"); + assert.deepEqual(failed, { ok: false, error: "organization effort limit" }); + assert.equal(server.sm.currentEffort, "high", "the effort the session runs at is unchanged"); + assert.equal(inbox(c).filter(function (m) { return m.type === "config_state"; }).length, 0, "no change is announced"); + var errors = inbox(c).filter(function (m) { return m.type === "error"; }); + assert.equal(errors.length, 1); + assert.match(errors[0].text, /Failed to change effort: organization effort limit/); + }); +}); + +test("a session with no running query records the effort for its next query", async function () { + await withServer(async function (server) { + await server.endQuery(); + assert.equal(server.session.queryInstance, null); + var result = await server.bridge.setEffort(server.session, "low"); + assert.deepEqual(result, { ok: true }); + assert.equal(server.sm.currentEffort, "low"); + await server.startQuery(); + assert.equal(server.queryOptions[server.queryOptions.length - 1].effort, "low", "the next query starts at that effort"); + }); +}); + +test("set_effort over the socket: a refused change tells the requester and puts its picker back; an accepted one is broadcast", async function () { + await withServer(async function (server) { + var asker = client(); + var other = client(); + server.connect(asker.ws); + server.connect(other.ws); + server.sm.currentEffort = "medium"; + + // Mid-turn, as well as between turns: the path does not depend on it. + server.session.isProcessing = true; + server.session.queryInstance.setEffort = function (effort) { + return effort === "max" ? Promise.reject(new Error("organization effort limit")) : Promise.resolve(); + }; + + inbox(asker).length = 0; + inbox(other).length = 0; + server.handlers.handleSessionsMessage(asker.ws, { type: "set_effort", effort: "max" }); + await world.flush(); + var askerStates = inbox(asker).filter(function (m) { return m.type === "config_state"; }); + assert.equal(askerStates.length, 1, "the requester's picker is put back"); + assert.equal(askerStates[0].effort, "medium"); + assert.equal(inbox(asker).filter(function (m) { return m.type === "error"; }).length, 1); + assert.equal(inbox(other).filter(function (m) { return m.type === "config_state"; }).length, 0, "nobody else is told of a change that did not happen"); + + inbox(asker).length = 0; + inbox(other).length = 0; + server.handlers.handleSessionsMessage(asker.ws, { type: "set_effort", effort: "high" }); + await world.flush(); + var otherStates = inbox(other).filter(function (m) { return m.type === "config_state"; }); + assert.ok(otherStates.length >= 1); + assert.equal(otherStates[otherStates.length - 1].effort, "high"); + assert.equal(server.sm.currentEffort, "high"); + }); +}); + +test("a restored session starts its query at the persisted permission mode and effort", async function () { + await withServer(async function (server) { + await server.endQuery(); + server.session.permissionMode = "dontAsk"; + server.sm.currentEffort = "xhigh"; + await server.startQuery(); + var opts = server.queryOptions[server.queryOptions.length - 1]; + assert.equal(opts.effort, "xhigh"); + assert.equal(opts.adapterOptions.CLAUDE.permissionMode, "dontAsk"); + + await server.endQuery(); + server.session.permissionMode = "auto"; + await server.startQuery(); + assert.equal(server.queryOptions[server.queryOptions.length - 1].adapterOptions.CLAUDE.permissionMode, "auto"); + }); +}); diff --git a/test/claude-worker-process.test.js b/test/claude-worker-process.test.js new file mode 100644 index 00000000..e4e61d6b --- /dev/null +++ b/test/claude-worker-process.test.js @@ -0,0 +1,155 @@ +"use strict"; +// A real claude-worker.js process, over a real unix socket, with the Claude +// Agent SDK swapped for a stand-in (test/fixtures/fake-agent-sdk) whose Query +// has applyFlagSettings and setPermissionMode and no setEffort, as the real +// 0.3.x Query. What the worker puts on the socket is what the daemon-side +// relay (lib/yoke/adapters/claude.js) receives in OS-isolated multi-user mode. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var os = require("os"); +var net = require("net"); +var path = require("path"); +var { spawn } = require("child_process"); +var { pathToFileURL } = require("url"); + +var WORKER = path.join(__dirname, "..", "lib", "yoke", "adapters", "claude-worker.js"); +var REGISTER = pathToFileURL(path.join(__dirname, "fixtures", "fake-agent-sdk", "register.mjs")).href; +var WAIT_MS = 20000; + +// Starts the worker against a fresh socket; returns helpers to talk to it. +async function startWorker() { + var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clagentic-worker-test-")); + var socketPath = path.join(dir, "w.sock"); + var received = []; + var waiters = []; + var connection = null; + var buffer = ""; + + function deliver() { + waiters = waiters.filter(function (w) { + var index = received.findIndex(w.predicate); + if (index === -1) return true; + w.resolve(received.splice(index, 1)[0]); + return false; + }); + } + + var server = net.createServer(function (conn) { + connection = conn; + conn.on("data", function (chunk) { + buffer += chunk.toString(); + var lines = buffer.split("\n"); + buffer = lines.pop(); + lines.filter(Boolean).forEach(function (line) { received.push(JSON.parse(line)); }); + deliver(); + }); + conn.on("error", function () {}); + }); + await new Promise(function (resolve) { server.listen(socketPath, resolve); }); + + var child = spawn(process.execPath, ["--import", REGISTER, WORKER, socketPath], { + env: { PATH: process.env.PATH, HOME: dir }, + cwd: dir, + stdio: ["ignore", "ignore", "ignore"], + }); + + var worker = { + next: function (predicate, label) { + return new Promise(function (resolve, reject) { + var timer = setTimeout(function () { reject(new Error("timed out waiting for " + label)); }, WAIT_MS); + waiters.push({ predicate: predicate, resolve: function (m) { clearTimeout(timer); resolve(m); } }); + deliver(); + }); + }, + probe: function (call, label) { + return worker.next(function (m) { return m.type === "sdk_event" && m.event.type === "fake_probe" && m.event.call === call; }, label || call); + }, + send: function (msg) { connection.write(JSON.stringify(msg) + "\n"); }, + stop: async function () { + try { child.kill("SIGKILL"); } catch (e) { /* already gone */ } + await new Promise(function (resolve) { server.close(resolve); if (connection) connection.destroy(); }); + fs.rmSync(dir, { recursive: true, force: true }); + }, + }; + await worker.next(function (m) { return m.type === "ready"; }, "ready"); + return worker; +} + +async function withQuery(fn) { + var worker = await startWorker(); + try { + worker.send({ type: "query_start", prompt: null, options: { cwd: os.tmpdir() }, singleTurn: false }); + await worker.next(function (m) { return m.type === "sdk_event" && m.event.subtype === "init"; }, "the query's first event"); + await fn(worker); + } finally { + await worker.stop(); + } +} + +test("a tool-permission ask in the worker reaches the daemon with its presentation hints, and the answer comes back", async function () { + await withQuery(async function (worker) { + worker.send({ type: "push_message", content: { type: "user", message: { role: "user", content: [{ type: "text", text: "ask" }] } } }); + var request = await worker.next(function (m) { return m.type === "permission_request"; }, "permission_request"); + + assert.equal(request.toolName, "Bash"); + assert.deepEqual(request.input, { command: "make" }); + assert.equal(request.toolUseId, "tu-fake"); + assert.equal(request.decisionReason, "a rule asks"); + assert.equal(request.suppressAlwaysAllowRule, true, "no persistent choice"); + assert.equal(request.defaultToNo, true, "opens on No"); + assert.deepEqual(request.matchedAskRule, { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }); + + worker.send({ type: "permission_response", requestId: request.requestId, result: { behavior: "deny", message: "no" } }); + var answered = await worker.probe("canUseTool"); + assert.deepEqual(answered.event.result, { behavior: "deny", message: "no" }); + }); +}); + +test("an effort change reaches the running query through applyFlagSettings and is confirmed with its requestId", async function () { + await withQuery(async function (worker) { + worker.send({ type: "set_effort", requestId: "e-1", effort: "high" }); + var confirmed = await worker.next(function (m) { return m.type === "effort_changed"; }, "effort_changed"); + assert.equal(confirmed.requestId, "e-1"); + assert.equal(confirmed.effort, "high"); + var probe = await worker.probe("applyFlagSettings"); + assert.deepEqual(probe.event.args, { effortLevel: "high" }); + }); +}); + +test("an effort change the SDK refuses, or the console does not know, is reported as a failure with its requestId", async function () { + await withQuery(async function (worker) { + worker.send({ type: "set_effort", requestId: "e-2", effort: "max" }); + var refused = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-2"; }, "worker_error for e-2"); + assert.match(refused.error, /organization effort limit/); + + worker.send({ type: "set_effort", requestId: "e-3", effort: "minimal" }); + var unknown = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-3"; }, "worker_error for e-3"); + assert.match(unknown.error, /Unknown effort level/); + }); +}); + +test("an effort change with no running query is a failure, not silence", async function () { + var worker = await startWorker(); + try { + worker.send({ type: "set_effort", requestId: "e-4", effort: "low" }); + var failure = await worker.next(function (m) { return m.type === "worker_error" && m.requestId === "e-4"; }, "worker_error for e-4"); + assert.match(failure.error, /no running query/); + } finally { + await worker.stop(); + } +}); + +test("the new permission modes are passed to the running query as the SDK names them", async function () { + await withQuery(async function (worker) { + var modes = ["auto", "dontAsk"]; + for (var i = 0; i < modes.length; i++) { + worker.send({ type: "set_permission_mode", mode: modes[i] }); + var probe = await worker.probe("setPermissionMode"); + assert.equal(probe.event.args, modes[i]); + var confirmed = await worker.next(function (m) { return m.type === "permission_mode_changed"; }, "permission_mode_changed"); + assert.equal(confirmed.mode, modes[i]); + } + }); +}); diff --git a/test/fake-dom-prompt-cards.js b/test/fake-dom-prompt-cards.js index 2a7d9fdd..d6893614 100644 --- a/test/fake-dom-prompt-cards.js +++ b/test/fake-dom-prompt-cards.js @@ -101,7 +101,6 @@ function matches(el, sel) { }); } -/** What typing text into a field does: set its value, then fire "input". */ function typeInto(el, text) { el.value = text; (el._listeners.input || []).slice().forEach(function (fn) { fn({}); }); diff --git a/test/fixtures/fake-agent-sdk/register.mjs b/test/fixtures/fake-agent-sdk/register.mjs new file mode 100644 index 00000000..0b6fbcf8 --- /dev/null +++ b/test/fixtures/fake-agent-sdk/register.mjs @@ -0,0 +1,5 @@ +// Preloaded with --import: installs the resolve hook that swaps in the +// stand-in SDK for the process it is loaded into. +import { register } from "node:module"; + +register("./resolve-hook.mjs", import.meta.url); diff --git a/test/fixtures/fake-agent-sdk/resolve-hook.mjs b/test/fixtures/fake-agent-sdk/resolve-hook.mjs new file mode 100644 index 00000000..9050711a --- /dev/null +++ b/test/fixtures/fake-agent-sdk/resolve-hook.mjs @@ -0,0 +1,7 @@ +// Resolves the Claude Agent SDK's package name to the stand-in beside this file. +const FAKE = new URL("./sdk.mjs", import.meta.url).href; + +export async function resolve(specifier, context, nextResolve) { + if (specifier === "@anthropic-ai/claude-agent-sdk") return { url: FAKE, shortCircuit: true }; + return nextResolve(specifier, context); +} diff --git a/test/fixtures/fake-agent-sdk/sdk.mjs b/test/fixtures/fake-agent-sdk/sdk.mjs new file mode 100644 index 00000000..3d4f8b3f --- /dev/null +++ b/test/fixtures/fake-agent-sdk/sdk.mjs @@ -0,0 +1,64 @@ +// A stand-in for @anthropic-ai/claude-agent-sdk, loaded by a real +// claude-worker.js process in test/claude-worker-process.test.js (see +// register.mjs). Its Query has the control surface the real 0.3.x Query has +// and none it lacks: applyFlagSettings and setPermissionMode, and no +// setEffort. Every control call is reported back as a fake_probe event on the +// query's stream, which the worker forwards to the test over its socket. +// +// A user message whose text is "ask" makes the query ask its canUseTool +// callback for permission, carrying the presentation hints an SDK ask can +// carry, and report the answer it got. + +export function query({ prompt, options }) { + const events = []; + let wake = null; + function emit(event) { + events.push(event); + if (wake) { + const w = wake; + wake = null; + w(); + } + } + + (async function readPrompt() { + for await (const message of prompt) { + const parts = message && message.message && message.message.content; + const text = Array.isArray(parts) && parts[0] ? parts[0].text : ""; + if (text !== "ask") continue; + const abort = new AbortController(); + const result = await options.canUseTool("Bash", { command: "make" }, { + toolUseID: "tu-fake", + decisionReason: "a rule asks", + signal: abort.signal, + suppressAlwaysAllowRule: true, + defaultToNo: true, + matchedAskRule: { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }, + }); + emit({ type: "fake_probe", call: "canUseTool", result }); + } + })(); + + emit({ type: "system", subtype: "init" }); + + return { + async applyFlagSettings(settings) { + emit({ type: "fake_probe", call: "applyFlagSettings", args: settings }); + if (settings.effortLevel === "max") throw new Error("organization effort limit"); + }, + async setPermissionMode(mode) { + emit({ type: "fake_probe", call: "setPermissionMode", args: mode }); + }, + async getContextUsage() { + return null; + }, + [Symbol.asyncIterator]() { + return { + async next() { + while (!events.length) await new Promise((resolve) => { wake = resolve; }); + return { value: events.shift(), done: false }; + }, + }; + }, + }; +} diff --git a/test/permission-hints.test.js b/test/permission-hints.test.js new file mode 100644 index 00000000..401c0e55 --- /dev/null +++ b/test/permission-hints.test.js @@ -0,0 +1,375 @@ +"use strict"; +// The Claude Agent SDK tells the host how an ask may be presented: a +// persistent "don't ask again" choice must not be offered (suppressAlwaysAllowRule), +// the ask must not open on an approve choice (defaultToNo), and a user-written +// ask rule forced it so no host-side auto-approval may answer it (matchedAskRule). +// These tests hold the console to that on every path that carries, shows or +// answers a tool-permission prompt, on the real server modules +// (prompt-harness-world.js) and the real client card code. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var world = require("./prompt-harness-world"); +var dom = require("./fake-dom-prompt-cards"); +var { promptsFor } = require("../lib/prompt-registry"); + +var RULE = { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }; +var TOOL_INPUT = { command: "make" }; + +function client() { + var inbox = []; + return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; +} + +async function withServer(fn) { + var server = await world.createServer(); + try { + await server.startQuery(); + await fn(server); + } finally { + server.shutdown(); + world.removeHome(server.home); + } +} + +// An in-process canUseTool call, as the SDK makes it, carrying the hints. +function ask(server, hints, toolName, input) { + return server.ask(toolName || "Bash", input || TOOL_INPUT, hints); +} + +function isSettled(promise) { + var state = { settled: false }; + promise.then(function () { state.settled = true; }); + return state; +} + +// Every WS shape that carries an Allow-for-session answer: the card, the +// notification banner (the card's message plus the project it belongs to), and +// the older per-kind alias. +var WS_SHAPES = [ + { name: "card prompt_response", build: function (id) { return { type: "prompt_response", requestId: id, kind: "permission", decision: "allow_always" }; } }, + { name: "banner prompt_response", build: function (id) { return { type: "prompt_response", requestId: id, kind: "permission", decision: "allow_always", targetSlug: "harness" }; } }, + { name: "permission_response alias", build: function (id) { return { type: "permission_response", requestId: id, decision: "allow_always" }; } }, +]; + +test("a suppressed ask refuses allow_always on every answer path, stays pending and grants nothing", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var opened = ask(server, { suppressAlwaysAllowRule: true }); + var state = isSettled(opened.promise); + assert.equal(opened.opened.length, 1, "the ask opened one prompt"); + + for (var i = 0; i < WS_SHAPES.length; i++) { + c.inbox.length = 0; + server.handlers.handleSessionsMessage(c.ws, WS_SHAPES[i].build(opened.id)); + await world.flush(); + var where = WS_SHAPES[i].name; + assert.equal(state.settled, false, where + ": the call is still waiting"); + assert.deepEqual(server.heldPromptIds(), [opened.id], where + ": the prompt is still pending"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), [], where + ": nothing was granted for the session"); + var reoffer = c.inbox.filter(function (m) { return m.type === "prompt_pending" && m.requestId === opened.id; }); + assert.equal(reoffer.length, 1, where + ": the prompt is offered again to the responder"); + assert.equal(reoffer[0].suppressAlwaysAllowRule, true, where + ": and still carries the restriction"); + assert.equal(c.inbox.filter(function (m) { return m.type === "prompt_resolved" || m.type === "prompt_cancel"; }).length, 0, where + ": nothing was announced as settled"); + } + + assert.equal(await server.httpRespond(opened.id, "allow_always"), 400, "the push route offers allow and deny only"); + assert.deepEqual(server.heldPromptIds(), [opened.id]); + assert.equal(state.settled, false); + + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: opened.id, kind: "permission", decision: "allow" }); + var outcome = await opened.promise; + assert.equal(outcome.behavior, "allow", "Allow once still works"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), [], "and grants nothing"); + + var again = ask(server, { suppressAlwaysAllowRule: true }); + assert.equal(again.opened.length, 1, "the same call asks again: no standing approval was written"); + }); +}); + +test("an ask without hints keeps offering and honouring Allow for session (Claude without hints, Codex approvals)", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var plain = ask(server, {}); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: plain.id, kind: "permission", decision: "allow_always" }); + assert.equal((await plain.promise).behavior, "allow"); + assert.equal(Object.keys(server.session.allowedTools).length, 1, "the grant was written"); + var second = ask(server, {}); + assert.equal(second.opened.length, 0, "the granted call is approved without a prompt"); + assert.equal((await second.promise).behavior, "allow"); + + // Codex approvals arrive through the same callback with no hint fields. + server.session.vendor = "codex"; + server.session.allowedTools = Object.create(null); + var codexAsk = ask(server, {}, "Bash", { command: "make codex" }); + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, undefined, "no restriction is invented"); + assert.equal(pending[0].defaultToNo, undefined); + assert.equal(pending[0].matchedAskRule, undefined); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: codexAsk.id, kind: "permission", decision: "allow_always" }); + assert.equal((await codexAsk.promise).behavior, "allow", "Allow for session works for an ask with no hints"); + }); +}); + +test("the hints reach the prompt record, the recorded request, the connect snapshot and the notification", async function () { + await withServer(async function (server) { + var opened = ask(server, { suppressAlwaysAllowRule: true, defaultToNo: true, matchedAskRule: RULE }); + + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, true); + assert.equal(pending[0].defaultToNo, true); + assert.deepEqual(pending[0].matchedAskRule, RULE); + + var recorded = server.session.history.filter(function (m) { return m.type === "prompt_request" && m.requestId === opened.id; }); + assert.equal(recorded.length, 1); + assert.equal(recorded[0].suppressAlwaysAllowRule, true); + assert.equal(recorded[0].defaultToNo, true); + assert.deepEqual(recorded[0].matchedAskRule, RULE, "history paging replays the same restrictions"); + + // A client connecting now gets the snapshot (replayed request and the + // pending re-send), each carrying the hints. + var late = client(); + server.connect(late.ws); + var seen = late.inbox.filter(function (m) { return (m.type === "prompt_pending" || m.type === "prompt_request") && m.requestId === opened.id; }); + assert.ok(seen.some(function (m) { return m.type === "prompt_pending"; }), "the pending snapshot is sent"); + seen.forEach(function (m) { + assert.equal(m.suppressAlwaysAllowRule, true, m.type + " carries suppressAlwaysAllowRule"); + assert.equal(m.defaultToNo, true, m.type + " carries defaultToNo"); + assert.deepEqual(m.matchedAskRule, RULE, m.type + " carries matchedAskRule"); + }); + + var state = null; + server.notifications.sendConnectionState({}, function (ws, msg) { state = msg; }); + var notif = state.notifications.filter(function (n) { return n.type === "permission_request" && n.meta.requestId === opened.id; })[0]; + assert.ok(notif, "the banner notification exists"); + assert.equal(notif.meta.suppressAlwaysAllowRule, true, "the banner is told not to offer Allow for session"); + assert.equal(notif.meta.defaultToNo, true); + }); +}); + +test("matchedAskRule is recorded on the resolved event; a malformed one still counts as rule-forced", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var opened = ask(server, { matchedAskRule: RULE }); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: opened.id, kind: "permission", decision: "deny" }); + await opened.promise; + var resolved = server.session.history.filter(function (m) { return m.type === "prompt_resolved" && m.requestId === opened.id; }); + assert.equal(resolved.length, 1); + assert.equal(resolved[0].decision, "deny"); + assert.deepEqual(resolved[0].matchedAskRule, RULE); + + var long = "x".repeat(5000); + var hostile = ask(server, { matchedAskRule: { source: long, toolName: 42, ruleContent: { nested: true } }, suppressAlwaysAllowRule: "yes", defaultToNo: 1 }); + var held = promptsFor(server.sm).pendingMessages(server.session).filter(function (m) { return m.requestId === hostile.id; })[0]; + assert.equal(typeof held.matchedAskRule, "object", "an object that is not the expected shape is still a rule-forced ask"); + assert.ok(held.matchedAskRule.source.length <= 200, "text is bounded"); + assert.equal(typeof held.matchedAskRule.toolName, "string"); + assert.equal(held.suppressAlwaysAllowRule, undefined, "only a real boolean true restricts: a string is not one"); + assert.equal(held.defaultToNo, undefined); + }); +}); + +test("a rule-forced ask is never answered by a host-side auto-approval", async function () { + await withServer(async function (server) { + var prompts = promptsFor(server.sm); + + // Read-only whitelist. + var control = ask(server, {}, "Read", { file_path: "/srv/app/a.txt" }); + assert.equal(control.opened.length, 0); + assert.equal((await control.promise).behavior, "allow", "control: a read-only tool is approved without a prompt"); + var forcedRead = ask(server, { matchedAskRule: RULE }, "Read", { file_path: "/srv/app/a.txt" }); + assert.equal(forcedRead.opened.length, 1, "whitelist: a rule-forced Read asks the operator"); + prompts.respond(forcedRead.id, { decision: "deny" }); + await forcedRead.promise; + + // Full-auto mode. + server.sm.currentPermissionMode = "bypassPermissions"; + var controlBypass = ask(server, {}, "Write", { file_path: "/srv/app/b.txt" }); + assert.equal(controlBypass.opened.length, 0); + assert.equal((await controlBypass.promise).behavior, "allow", "control: full auto approves"); + var forcedBypass = ask(server, { matchedAskRule: RULE }, "Write", { file_path: "/srv/app/b.txt" }); + assert.equal(forcedBypass.opened.length, 1, "full auto: a rule-forced ask asks the operator"); + prompts.respond(forcedBypass.id, { decision: "deny" }); + await forcedBypass.promise; + server.sm.currentPermissionMode = "default"; + + // Autonomous loop execution. + server.session.loop = { active: true, role: "worker" }; + var controlLoop = ask(server, {}, "Edit", { file_path: "/srv/app/c.txt" }); + assert.equal(controlLoop.opened.length, 0); + assert.equal((await controlLoop.promise).behavior, "allow", "control: a loop approves"); + var forcedLoop = ask(server, { matchedAskRule: RULE }, "Edit", { file_path: "/srv/app/c.txt" }); + assert.equal(forcedLoop.opened.length, 1, "loop: a rule-forced ask asks the operator"); + prompts.respond(forcedLoop.id, { decision: "deny" }); + await forcedLoop.promise; + server.session.loop = null; + + // An earlier Allow for session. + prompts.grant(server.session, "Bash", TOOL_INPUT); + var controlGrant = ask(server, {}); + assert.equal(controlGrant.opened.length, 0); + assert.equal((await controlGrant.promise).behavior, "allow", "control: a granted call is approved"); + var forcedGrant = ask(server, { matchedAskRule: RULE }); + assert.equal(forcedGrant.opened.length, 1, "grant: a rule-forced ask asks the operator"); + var suppressedGrant = ask(server, { suppressAlwaysAllowRule: true }); + assert.equal(suppressedGrant.opened.length, 1, "grant: an ask the SDK marked as broader than a standing approval asks the operator"); + var declinedGrant = ask(server, { defaultToNo: true }); + assert.equal(declinedGrant.opened.length, 1, "grant: an ask that must open on No asks the operator"); + }); +}); + +test("a tool-permission ask relayed from a worker carries the hints through to the prompt", async function () { + await withServer(async function (server) { + var claudeAdapter = require("../lib/yoke/adapters/claude"); + var relay = require("../lib/yoke/adapters/permission-relay"); + var sent = []; + var worker = { sent: sent, handler: null, process: null, exitPromise: null, onMessage: function (fn) { worker.handler = fn; }, send: function (m) { sent.push(m); } }; + claudeAdapter._test_createWorkerQueryHandle(worker, function (toolName, input, opts) { + return server.bridge.handleCanUseTool(server.session, toolName, input, opts); + }, null, null); + + // What the worker process puts on the socket for an SDK ask carrying the hints. + var wire = JSON.parse(JSON.stringify(relay.toRequestMessage("w-1", "Bash", TOOL_INPUT, { + toolUseID: "tu-worker", decisionReason: "rule", suppressAlwaysAllowRule: true, defaultToNo: true, matchedAskRule: RULE, + }))); + worker.handler(wire); + await world.flush(); + + var pending = promptsFor(server.sm).pendingMessages(server.session); + assert.equal(pending.length, 1); + assert.equal(pending[0].suppressAlwaysAllowRule, true); + assert.equal(pending[0].defaultToNo, true); + assert.deepEqual(pending[0].matchedAskRule, RULE); + + var c = client(); + server.connect(c.ws); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: pending[0].requestId, kind: "permission", decision: "allow_always" }); + await world.flush(); + assert.equal(sent.length, 0, "the worker was not answered: Allow for session was refused"); + assert.deepEqual(Object.keys(server.session.allowedTools || {}), []); + + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: pending[0].requestId, kind: "permission", decision: "allow" }); + await world.flush(); + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "permission_response"); + assert.equal(sent[0].requestId, "w-1"); + assert.equal(sent[0].result.behavior, "allow"); + }); +}); + +test("a request relayed from a worker with no hints is a plain ask", async function () { + var relay = require("../lib/yoke/adapters/permission-relay"); + var wire = JSON.parse(JSON.stringify(relay.toRequestMessage("w-2", "Bash", TOOL_INPUT, { toolUseID: "tu", decisionReason: "" }))); + var opts = relay.toCanUseToolOptions(wire, new AbortController().signal); + assert.equal(opts.suppressAlwaysAllowRule, undefined); + assert.equal(opts.defaultToNo, undefined); + assert.equal(opts.matchedAskRule, undefined); + assert.equal(opts.toolUseID, "tu"); +}); + +test("the hints a worker sends are taken as data: only a real true restricts, a rule is a bounded object", function () { + var relay = require("../lib/yoke/adapters/permission-relay"); + var opts = relay.toCanUseToolOptions({ toolUseId: "t", suppressAlwaysAllowRule: "true", defaultToNo: 1, matchedAskRule: "yes" }, new AbortController().signal); + assert.equal(opts.suppressAlwaysAllowRule, undefined); + assert.equal(opts.defaultToNo, undefined); + assert.deepEqual(opts.matchedAskRule, {}, "a rule that is present but not an object is still a rule-forced ask"); +}); + +test("a prompt_response of the extension kind never settles a browser-extension command", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var prompts = promptsFor(server.sm); + var command = prompts.open(null, "extension", { command: "tab_list", args: {} }, { timeoutMs: 60000 }); + var state = isSettled(command.answer); + server.handlers.handleSessionsMessage(c.ws, { type: "prompt_response", requestId: command.requestId, kind: "extension", result: { forged: true } }); + await world.flush(); + assert.equal(state.settled, false, "another client's prompt_response does not answer the extension's command"); + assert.equal(c.inbox.filter(function (m) { return m.type === "prompt_cancel" && m.reason === "stale"; }).length, 1, "and is answered as not found"); + assert.equal(prompts.respond(command.requestId, { result: { ok: true } }, { kinds: ["extension"] }).status, "resolved", "the extension path still answers it"); + assert.deepEqual(await command.answer, { ok: true }); + }); +}); + +// --- the client ------------------------------------------------------------ + +function request(id, extra) { + return Object.assign({ type: "prompt_request", requestId: id, kind: "permission", toolName: "Bash", toolInput: TOOL_INPUT, decisionReason: "" }, extra || {}); +} + +async function clientFor(t, layout) { + var env = await dom.setupToolsEnv(t); + global.localStorage.setItem("clagentic-wide-view", layout); + return env; +} + +function approveControls(card) { + return card.querySelectorAll("button").filter(function (b) { + return /permission-allow|perm-allow|perm-always/.test(b.className); + }); +} + +["bubble", "channel"].forEach(function (layout) { + test("the " + layout + " card omits Allow for session for a suppressed ask, in a live request and in a replayed snapshot", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("c1")); + assert.ok(dom.cardFor(env, "c1").querySelector(".permission-allow-session, .perm-always"), "control: an ordinary ask offers it"); + + env.tools.applyPromptMessage(request("c2", { suppressAlwaysAllowRule: true })); + assert.equal(dom.cardFor(env, "c2").querySelector(".permission-allow-session, .perm-always"), null, "live request"); + assert.ok(dom.cardFor(env, "c2").querySelector(".permission-allow, .perm-allow"), "Allow once stays"); + assert.ok(dom.cardFor(env, "c2").querySelector(".permission-deny, .perm-deny"), "Deny stays"); + + env.tools.applyPromptMessage(Object.assign(request("c3", { suppressAlwaysAllowRule: true }), { type: "prompt_pending" })); + assert.equal(dom.cardFor(env, "c3").querySelector(".permission-allow-session, .perm-always"), null, "pending snapshot"); + + // The older recorded shape. + env.tools.applyPromptMessage({ type: "permission_request", requestId: "c4", toolName: "Bash", toolInput: TOOL_INPUT, suppressAlwaysAllowRule: true }); + assert.equal(dom.cardFor(env, "c4").querySelector(".permission-allow-session, .perm-always"), null, "older permission_request shape"); + }); + + test("the " + layout + " card for a defaultToNo ask opens on No: Deny first, no approve choice marked or focused", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("d1", { defaultToNo: true })); + var card = dom.cardFor(env, "d1"); + var buttons = card.querySelectorAll(".permission-actions button"); + assert.match(buttons[0].className, /permission-deny|perm-deny/, "the decline option comes first"); + assert.equal(buttons[0].dataset.defaultChoice, "true", "and is the default choice"); + approveControls(card).forEach(function (b) { + assert.equal(b.dataset.defaultChoice, undefined, "no approve option is the default"); + }); + assert.equal(card.dataset.defaultToNo, "true"); + + env.tools.applyPromptMessage(request("d2")); + var plain = dom.cardFor(env, "d2"); + assert.equal(plain.dataset.defaultToNo, undefined, "an ordinary ask is drawn as before"); + assert.match(plain.querySelectorAll(".permission-actions button")[0].className, /permission-allow|perm-allow/); + }); + + test("the " + layout + " card shows the rule that forced the ask", async function (t) { + var env = await clientFor(t, layout); + env.tools.applyPromptMessage(request("m1", { matchedAskRule: RULE })); + var text = dom.cardFor(env, "m1").textContent; + assert.match(text, /make:\*/, "the operator can see which of their rules asked"); + }); +}); + +test("a banner offers Allow for session only for an ask that allows it, and Deny stays first for defaultToNo", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + dom.setupGlobals(); + var banner = await import(dom.moduleUrl("prompt-banner.js")); + var decisions = function (meta) { return banner.bannerActions("permission", meta).map(function (a) { return a.decision; }); }; + + assert.deepEqual(decisions({}), ["allow", "allow_always", "deny"], "control: an ordinary ask"); + assert.deepEqual(decisions({ suppressAlwaysAllowRule: true }), ["allow", "deny"]); + assert.deepEqual(decisions({ defaultToNo: true }), ["deny", "allow", "allow_always"], "No first when the ask must open on No"); + assert.deepEqual(decisions({ defaultToNo: true, suppressAlwaysAllowRule: true }), ["deny", "allow"]); + assert.deepEqual(banner.bannerActions("plan", { suppressAlwaysAllowRule: true }).map(function (a) { return a.decision; }), ["allow", "deny"], "plan banners are unchanged"); + assert.deepEqual(banner.bannerActions("nonsense", {}), [], "a kind with no entry offers nothing"); + assert.deepEqual(banner.bannerActions("constructor", {}), [], "an inherited name is no kind"); +}); diff --git a/test/permission-mode-picker.test.js b/test/permission-mode-picker.test.js new file mode 100644 index 00000000..c8176e15 --- /dev/null +++ b/test/permission-mode-picker.test.js @@ -0,0 +1,116 @@ +"use strict"; +// The mode picker offers Don't ask and Auto only for a session whose vendor +// declares them (capabilities.permissionModes), and the server refuses the +// two for a session that does not: a Codex session must never be handed a +// Claude Code mode. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var path = require("path"); +var { pathToFileURL } = require("url"); +var world = require("./prompt-harness-world"); +var { claudeCapabilities } = require("../lib/yoke/adapters/claude-capabilities"); + +function modeOptionsModule() { + return import(pathToFileURL(path.join(__dirname, "..", "lib", "public", "modules", "mode-options.js")).href); +} + +function values(options) { return options.map(function (o) { return o.value; }); } + +test("the picker offers the three base modes always, and Don't ask and Auto only when the vendor declares them", async function () { + var { modeOptions } = await modeOptionsModule(); + var BASE = ["default", "plan", "acceptEdits"]; + + assert.deepEqual(values(modeOptions(undefined, false)), BASE, "no capabilities known"); + assert.deepEqual(values(modeOptions({}, false)), BASE, "a vendor that declares none (Codex)"); + assert.deepEqual(values(modeOptions({ rewind: false, permissionModes: "auto" }, false)), BASE, "a malformed declaration offers nothing extra"); + assert.deepEqual(values(modeOptions({ permissionModes: ["default", "auto"] }, false)), BASE.concat("auto"), "only what is declared"); + assert.deepEqual(values(modeOptions(claudeCapabilities(), false)), BASE.concat(["dontAsk", "auto"])); + assert.deepEqual(values(modeOptions(claudeCapabilities(), true)), BASE.concat(["dontAsk", "auto", "bypassPermissions"]), "Full auto stays last, when the daemon allows it"); + assert.deepEqual(values(modeOptions({}, true)), BASE.concat("bypassPermissions")); + + var labels = {}; + modeOptions(claudeCapabilities(), true).forEach(function (o) { labels[o.value] = o.label; }); + assert.equal(labels.dontAsk, "Don’t ask"); + assert.equal(labels.auto, "Auto"); + assert.equal(labels.bypassPermissions, "Full auto"); +}); + +test("a Claude session declares every mode the picker can offer", function () { + var caps = claudeCapabilities(); + assert.deepEqual(caps.permissionModes, ["default", "plan", "acceptEdits", "dontAsk", "auto"]); + caps.permissionModes.push("mutated"); + assert.equal(claudeCapabilities().permissionModes.length, 5, "each call gets its own list"); +}); + +function client() { + var inbox = []; + return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; +} + +async function withServer(fn) { + var server = await world.createServer(); + try { + await server.startQuery(); + await fn(server); + } finally { + server.shutdown(); + world.removeHome(server.home); + } +} + +test("set_permission_mode: auto and dontAsk reach a session whose vendor declares them, and only that session", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setPermissionMode = function (mode) { applied.push(mode); return Promise.resolve(); }; + + // Capabilities are recorded per vendor; Claude's are known, Codex's are not. + server.sm.capabilitiesByVendor = { claude: claudeCapabilities() }; + + ["auto", "dontAsk"].forEach(function (mode) { + server.session.vendor = "claude"; + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, ["auto", "dontAsk"]); + assert.equal(server.session.permissionMode, "dontAsk"); + + applied.length = 0; + c.inbox.length = 0; + server.session.vendor = "codex"; + server.session.permissionMode = "default"; + ["auto", "dontAsk"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, [], "a Codex session is never handed a Claude Code mode"); + assert.equal(server.session.permissionMode, "default"); + assert.equal(c.inbox.filter(function (m) { return m.type === "error"; }).length, 2, "and the requester is told"); + var states = c.inbox.filter(function (m) { return m.type === "config_state"; }); + assert.equal(states[states.length - 1].mode, "default", "its picker is put back"); + + // The modes every vendor has are not gated. + ["plan", "acceptEdits", "default"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, ["plan", "acceptEdits", "default"]); + }); +}); + +test("set_permission_mode: with no capabilities known, auto and dontAsk are refused rather than guessed", async function () { + await withServer(async function (server) { + var c = client(); + server.connect(c.ws); + var applied = []; + server.session.queryInstance.setPermissionMode = function (mode) { applied.push(mode); return Promise.resolve(); }; + server.sm.capabilitiesByVendor = null; + ["auto", "dontAsk"].forEach(function (mode) { + server.handlers.handleSessionsMessage(c.ws, { type: "set_permission_mode", mode: mode }); + }); + await world.flush(); + assert.deepEqual(applied, []); + }); +}); diff --git a/test/prompt-harness-world.js b/test/prompt-harness-world.js index d03660d2..6ac9fd8e 100644 --- a/test/prompt-harness-world.js +++ b/test/prompt-harness-world.js @@ -75,7 +75,6 @@ function makeQueryHandle() { }; } -/** requestIds of every prompt the session currently holds. */ function heldPromptIds(session) { var ids = []; PROMPT_STORES.forEach(function (store) { @@ -126,9 +125,13 @@ async function createServer(opts) { }); var handles = []; + // The options each query was created with, for tests that check what the + // bridge asks the adapter to start (permission mode, effort). + var queryOptions = []; var adapter = { vendor: "claude", - createQuery: function () { + createQuery: function (queryOpts) { + queryOptions.push(queryOpts); var h = makeQueryHandle(); handles.push(h); return Promise.resolve(h); @@ -185,6 +188,7 @@ async function createServer(opts) { sm.saveSessionFile(session); } + var askSeq = 0; var server = { home: home, sm: sm, @@ -209,6 +213,23 @@ async function createServer(opts) { return session.queryInstance.issueSignal(); }, + queryOptions: queryOptions, + + /** + * A canUseTool call as the SDK makes it, in process. hints carries the + * SDK's presentation hints (suppressAlwaysAllowRule, defaultToNo, + * matchedAskRule); the vendor's own options (toolUseID, signal) are the + * harness's. Returns the call's promise, the id of the prompt it opened + * (null when it was answered without one) and every id it opened. + */ + ask: function (toolName, input, hints) { + var before = heldPromptIds(session); + var opts = Object.assign({ toolUseID: "tu-ask-" + (++askSeq), signal: server.issueSignal().signal }, hints || {}); + var promise = bridge.handleCanUseTool(session, toolName, input, opts); + var opened = heldPromptIds(session).filter(function (id) { return before.indexOf(id) === -1; }); + return { promise: promise, id: opened[0] || null, opened: opened }; + }, + // Closes the live query; its processQueryStream finally runs. A stream // that rejects fails the step that ended it. endQuery: async function () { diff --git a/test/prompt-hostile-request-id.test.js b/test/prompt-hostile-request-id.test.js index 448afc85..d3bf7edf 100644 --- a/test/prompt-hostile-request-id.test.js +++ b/test/prompt-hostile-request-id.test.js @@ -56,7 +56,9 @@ function openOneOfEach(server) { var settled = {}; Object.keys(opened).forEach(function (kind) { var answer = kind === "extension" ? opened[kind].answer : opened[kind]; - answer.then(function () { settled[kind] = true; }); + // A callback that fails is settled too, and must not read as pending. + var mark = function () { settled[kind] = true; }; + answer.then(mark, mark); }); var ids = { extension: opened.extension.requestId }; prompts.pendingMessages(session).forEach(function (m) { ids[m.kind] = m.requestId; }); diff --git a/test/prompt-invariants.test.js b/test/prompt-invariants.test.js index f3ad19f0..2f3968eb 100644 --- a/test/prompt-invariants.test.js +++ b/test/prompt-invariants.test.js @@ -27,6 +27,11 @@ // I7 Allow for Session auto-approves the same grant key afterwards, across // turns and restart, and nothing else // I8 every prompt that ends records exactly one terminal event +// I9 an ask carrying the SDK's hints is held to them: Allow for session on +// a suppressAlwaysAllowRule ask is refused on every path (the card and +// its aliases, the banner) and writes no grant, and no standing approval +// answers an ask marked suppressAlwaysAllowRule, defaultToNo or +// matchedAskRule // // Set CLAGENTIC_CONSOLE_PROMPT_HARNESS_LIB to another checkout's lib/ to run the // wire-level checks against it; the client render layer always exercises @@ -55,7 +60,25 @@ var PROMPTS = [ key: "Elicit", kind: "elicitation", request: { serverName: "deploy", message: "Token?", mode: "form", requestedSchema: { type: "object", properties: { token: { type: "string" } } } }, }, + // Asks carrying the SDK's presentation hints. Each uses a tool of its own, + // so no Allow for session on another entry can stand in for them. + { key: "Edit!", kind: "permission", toolName: "Edit", input: { file_path: "/srv/app/main.js" }, hints: { suppressAlwaysAllowRule: true } }, + { + key: "Drop!", kind: "permission", toolName: "mcp__db__drop", input: { table: "users" }, + hints: { defaultToNo: true, matchedAskRule: { source: "projectSettings", toolName: "mcp__db__drop", ruleContent: "*" } }, + }, ]; + +// An ask the SDK marked as not offering a persistent choice. +function suppressesAlways(prompt) { + return !!(prompt.hints && prompt.hints.suppressAlwaysAllowRule); +} + +// An ask a standing approval may not answer: a grant for its tool is broader +// than what the SDK says the ask itself allows. +function needsOwnAnswer(prompt) { + return !!(prompt.hints && (prompt.hints.suppressAlwaysAllowRule || prompt.hints.defaultToNo || prompt.hints.matchedAskRule)); +} var BY_KIND = {}; PROMPTS.forEach(function (p) { if (!BY_KIND[p.kind]) BY_KIND[p.kind] = p; }); @@ -168,6 +191,7 @@ function Harness(label) { this.queryGen = 0; this.clients = []; this.terminalEvents = {}; + this.unexpressible = 0; } Harness.prototype.fail = function (msg) { @@ -309,10 +333,11 @@ Harness.prototype.request = async function (prompt, ownerTask) { var abort = this.server.issueSignal(); var promise = prompt.kind === "elicitation" ? this.server.bridge.handleElicitation(session, prompt.request, { signal: abort.signal }) - : this.server.bridge.handleCanUseTool(session, prompt.toolName, prompt.input, { toolUseID: toolUseId, signal: abort.signal }); + : this.server.bridge.handleCanUseTool(session, prompt.toolName, prompt.input, Object.assign({ toolUseID: toolUseId, signal: abort.signal }, prompt.hints || {})); var opened = this.server.heldPromptIds().filter(function (id) { return before.indexOf(id) === -1; }); - var grantable = prompt.kind === "permission"; + // A standing approval never answers an ask that needs its own answer (I9). + var grantable = prompt.kind === "permission" && !needsOwnAnswer(prompt); if (grantable && this.granted[prompt.key] && opened.length === 0) { var auto = await promise; if (!allows(auto)) this.fail(prompt.key + " was granted for the session but was not auto-approved (I7)"); @@ -405,12 +430,28 @@ Harness.prototype.respond = async function (client, r, answer) { if (!clicked) { var legacy = world.overridesLib || this.toolSeq % 2 === 0; var msg = responseMessage(r.kind, r.id, answer, legacy); - if (msg.type !== "prompt_response" || !world.overridesLib) { - this.server.handlers.handleSessionsMessage(client.ws, msg); + // A lib that predates prompt_response has no message that can carry an + // answer only prompt_response expresses (an AskUserQuestion skip), so the + // step cannot be made there. It is counted, and a run against this + // checkout fails if any step was lost this way, instead of judging a + // message that was never sent. + if (msg.type === "prompt_response" && world.overridesLib) { + this.unexpressible++; + return; } + this.server.handlers.handleSessionsMessage(client.ws, msg); } var replies = client.inbox.filter(function (m) { return isEnding(m) && promptIdOf(m) === r.id; }); + var reoffered = client.inbox.some(function (m) { return PENDING[m.type] && promptIdOf(m) === r.id; }); await this.settle(); + if (wasPending && answer === "allow_always" && suppressesAlways(r.prompt)) { + // I9: an ask that may not offer Allow for session is refused it whatever + // the client sent: nothing settles, nothing is granted, and the responder + // is shown the prompt again. + if (r.settled) this.fail(r.id + " was settled by an Allow for session its ask may not offer (I9)"); + else if (client.connected && !reoffered) this.fail(client.name + "'s refused Allow for session on " + r.id + " was not offered again (I9)"); + return; + } if (client.connected) { var stale = replies.some(function (m) { return m.reason === "stale"; }); var resolved = replies.some(function (m) { return m.type !== "prompt_cancel" && m.type !== "permission_cancel"; }); @@ -599,6 +640,7 @@ Harness.prototype.close = function () { Harness.prototype.assertClean = function () { assert.deepEqual(this.violations, [], "ops: " + this.log.join(" | ")); + if (!world.overridesLib) assert.equal(this.unexpressible, 0, "every answer the run chose could be sent to this checkout"); }; // --- random interleavings ------------------------------------------------- diff --git a/test/prompt-kind-roundtrip.test.js b/test/prompt-kind-roundtrip.test.js index f65ff2b6..4180ab40 100644 --- a/test/prompt-kind-roundtrip.test.js +++ b/test/prompt-kind-roundtrip.test.js @@ -55,6 +55,41 @@ function click(card, selector) { el.click(); } +// --- the SDK's presentation hints ------------------------------------------- + +var RULES = [ + { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }, + { source: "userSettings", toolName: "Write" }, +]; + +// Hints on a request, generated for every kind: a kind that does not use them +// must be unmoved by them, and the permission kind must hold to them. +function hintsFor(p) { + var hints = {}; + if (p.chance(0.4)) hints.suppressAlwaysAllowRule = true; + if (p.chance(0.4)) hints.defaultToNo = true; + if (p.chance(0.4)) hints.matchedAskRule = p.one(RULES); + return hints; +} + +var HINT_KEYS = ["suppressAlwaysAllowRule", "defaultToNo", "matchedAskRule"]; + +// What the permission card shows for an ask with these hints. +function assertHintsShown(card, req) { + var session = card.querySelector(".permission-allow-session, .perm-always"); + assert.equal(!!session, !req.suppressAlwaysAllowRule, "Allow for session is offered exactly when the ask may offer it"); + var buttons = card.querySelectorAll(".permission-actions button"); + var approvals = buttons.filter(function (b) { return /permission-allow|perm-allow|perm-always/.test(b.className); }); + approvals.forEach(function (b) { assert.equal(b.dataset.defaultChoice, undefined, "no approve choice is the default"); }); + if (req.defaultToNo) { + assert.match(buttons[0].className, /permission-deny|perm-deny/, "the ask opens on No"); + assert.equal(buttons[0].dataset.defaultChoice, "true"); + } else { + assert.match(buttons[buttons.length - 1].className, /permission-deny|perm-deny/, "an ordinary ask keeps its order"); + } + assert.equal(/Asked because of your rule/.test(card.textContent), !!req.matchedAskRule, "the rule that forced the ask is shown"); +} + // --- per-kind generators and readers -------------------------------------- var LABEL_POOL = ["staging", "prod", "staging, eu", "qa", "eu", "a, b", "b", "Other", "x"]; @@ -69,14 +104,19 @@ var FIELD_SHAPES = [ { prop: { type: "string" }, defaults: ["preset", 5] }, { prop: { type: "string", maxLength: 8 }, defaults: ["short", "far too long a default"] }, { prop: { type: "string", minLength: 2, title: "Code", description: "two or more" }, defaults: ["ok", "x"] }, + { prop: { type: "string", minLength: 2, maxLength: 5 }, defaults: ["abc", "abcdef"] }, { prop: { type: "string", format: "email" }, defaults: ["ops@example.test", "not an address"] }, { prop: { type: "string", format: "uri" }, defaults: ["https://example.test/x", "relative/x"] }, { prop: { type: "string", format: "date" }, defaults: ["2026-02-28", "2026-02-30"] }, { prop: { type: "string", format: "date-time" }, defaults: ["2026-10-06T12:30:00Z", "2026-10-06 12:30"] }, { prop: { type: "integer" }, defaults: [7, 1.5, "7"] }, { prop: { type: "integer", minimum: 0, maximum: 10 }, defaults: [3, 11] }, + { prop: { type: "integer", minimum: 1 }, defaults: [5, 0] }, + { prop: { type: "integer", maximum: 9 }, defaults: [3, 10] }, { prop: { type: "number" }, defaults: [2.25, "2.25"] }, { prop: { type: "number", minimum: -2.5, maximum: 2.5 }, defaults: [0.5, 3] }, + { prop: { type: "number", minimum: 0.5 }, defaults: [1.5, 0] }, + { prop: { type: "number", maximum: 0.5 }, defaults: [0, 1] }, { prop: { type: "boolean" }, defaults: [true, false, "yes"] }, { prop: { type: "string", enum: ["eu", "us", "apac"] }, defaults: ["us", "mars"] }, { prop: { type: "string", enum: ["s", "m", "l"], enumNames: ["Small", "Medium", "Large"] }, defaults: ["m", "xl"] }, @@ -184,12 +224,18 @@ var SPECS = { req: { toolName: p.one(["Bash", "Write"]), toolInput: { command: "make " + p.int(0, 9) }, decisionReason: "", vendor: "claude" }, }; }, - answer: function (card, p) { - var choice = p.one([ + // The hints (generated for every kind, see hintsFor) decide what the card + // offers: no Allow for session when suppressed, Deny first when the ask + // must open on No. + answer: function (card, p, req) { + var choices = [ { selector: ".permission-allow, .perm-allow", label: "Allowed", allows: true }, { selector: ".permission-allow-session, .perm-always", label: "Allowed for session", allows: true }, { selector: ".permission-deny, .perm-deny", label: "Denied", allows: false }, - ]); + ]; + assertHintsShown(card, req); + if (req.suppressAlwaysAllowRule) choices.splice(1, 1); + var choice = p.one(choices); click(card, choice.selector); return choice; }, @@ -474,13 +520,27 @@ async function roundTrip(kind, seed) { var where = kind + " seed " + seed; var server = makeServer(); var opened = spec.request(p); + Object.assign(opened.req, hintsFor(p)); var prompt = server.registry.open(server.session, kind, opened.req, { toolUseId: "tu-" + seed }); var live = await dom.createClient("roundtrip-live"); var requestEvent = JSON.parse(JSON.stringify(server.history[0])); + HINT_KEYS.forEach(function (key) { + if (kind === "permission") assert.deepEqual(requestEvent[key], opened.req[key], where + ": the request carries " + key); + else assert.equal(requestEvent[key], undefined, where + ": " + kind + " does not carry " + key); + }); + var pendingMessage = JSON.parse(JSON.stringify(server.registry.pendingMessages(server.session)[0])); live.tools.applyPromptMessage(requestEvent); var card = dom.cardFor(live, prompt.requestId); assert.ok(card, where + ": the request draws a card"); + + // The connect snapshot of a prompt that is still pending draws the same + // card, with the same restrictions. + if (kind === "permission") { + var snapshot = await dom.createClient("roundtrip-snapshot"); + snapshot.tools.applyPromptMessage(pendingMessage); + assertHintsShown(dom.cardFor(snapshot, prompt.requestId), opened.req); + } var choice = spec.answer(card, p, opened.req, live.sent); assert.equal(live.sent.length, 1, where + ": one answer is sent"); var sent = JSON.parse(JSON.stringify(live.sent[0])); @@ -493,6 +553,8 @@ async function roundTrip(kind, seed) { var stored = JSON.parse(JSON.stringify(server.history)); var resolution = stored.filter(function (m) { return m.type === "prompt_resolved"; }); assert.equal(resolution.length, 1, where + ": one outcome is recorded"); + assert.deepEqual(resolution[0].matchedAskRule, kind === "permission" ? opened.req.matchedAskRule : undefined, + where + ": the rule that forced an ask is recorded with its outcome"); live.tools.applyPromptMessage(resolution[0]); var want = spec.expectedRead(choice, opened.req); @@ -567,6 +629,39 @@ test("elicitation answers that bypass the card are held to the requested schema" } }); +// An answer that did not come from the card is held to the ask's hints too: +// Allow for session to an ask that may not offer it settles nothing and +// grants nothing, and any other answer behaves as it always did. +test("permission answers that bypass the card are held to the ask's hints", async function () { + for (var seed = 1; seed <= RUNS_PER_KIND * 2; seed++) { + var p = picker(mulberry32(2000 + seed)); + var where = "permission seed " + seed; + var server = makeServer(); + var req = SPECS.permission.request(p).req; + Object.assign(req, hintsFor(p)); + var prompt = server.registry.open(server.session, "permission", req, { toolUseId: "tu-" + seed }); + var decision = p.one(["allow", "allow_always", "deny", "bogus", undefined]); + + var outcome = server.registry.respond(prompt.requestId, { decision: decision }); + var grants = Object.keys(server.session.allowedTools || {}).length; + if (decision === "allow_always" && req.suppressAlwaysAllowRule) { + assert.equal(outcome.status, "invalid", where + ": refused"); + assert.equal(outcome.pending.type, "prompt_pending", where + ": and offered again"); + assert.equal(outcome.pending.suppressAlwaysAllowRule, true, where + ": with the restriction"); + assert.equal(grants, 0, where + ": nothing was granted"); + assert.equal(server.history.filter(function (m) { return m.type === "prompt_resolved"; }).length, 0, where + ": nothing settled"); + assert.equal(server.registry.respond(prompt.requestId, { decision: "allow" }).status, "resolved", where + ": still answerable"); + assert.equal((await prompt.answer).behavior, "allow", where); + assert.equal(Object.keys(server.session.allowedTools || {}).length, 0, where + ": Allow once grants nothing"); + } else { + assert.equal(outcome.status, "resolved", where); + assert.equal(grants, decision === "allow_always" ? 1 : 0, where + ": a grant is written exactly for an accepted Allow for session"); + var expected = decision === "allow" || decision === "allow_always" ? "allow" : "deny"; + assert.equal((await prompt.answer).behavior, expected, where + ": an unknown answer fails closed"); + } + } +}); + Object.keys(SPECS).forEach(function (kind) { test("round trip, " + kind + ": the operator's answer survives answer, settle, serialize, replay and render", async function (t) { await dom.setupToolsEnv(t); diff --git a/test/prompt-path-followups.test.js b/test/prompt-path-followups.test.js new file mode 100644 index 00000000..56698949 --- /dev/null +++ b/test/prompt-path-followups.test.js @@ -0,0 +1,109 @@ +"use strict"; +// Three corners of the prompt path found in review of the prompt registry: +// a tool-use id that two sessions share must not take over the other +// session's request index entry; a recorded AskUserQuestion skip is an answer, +// not an ending nobody chose; and a send that throws is not "Not connected". + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var dom = require("./fake-dom-prompt-cards"); +var { createPromptRegistry } = require("../lib/prompt-registry"); + +var QUESTIONS = { questions: [{ question: "Which?", options: [{ label: "a" }, { label: "b" }] }] }; + +function twoSessionRegistry() { + var sessions = { 1: { localId: 1, queryInstance: null }, 2: { localId: 2, queryInstance: null } }; + var index = Object.create(null); + var registry = createPromptRegistry({ + index: index, + getSession: function (localId) { return sessions[localId] || null; }, + sendAndRecord: function () {}, + saveSessionFile: function () {}, + }); + return { sessions: sessions, index: index, registry: registry }; +} + +test("a tool-use id two sessions share is the request id of only the first; the other session's prompt gets its own", async function () { + var w = twoSessionRegistry(); + var a = w.registry.open(w.sessions[1], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-same" }); + var b = w.registry.open(w.sessions[2], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-same" }); + + assert.equal(a.requestId, "tu-same", "the first session's AskUserQuestion card and prompt are one request"); + assert.notEqual(b.requestId, "tu-same", "the second session's does not take that id over"); + assert.equal(w.index["tu-same"], 1, "the index still routes the id to the session that holds it"); + + var wrong = w.registry.respond("tu-same", { decision: "skip" }); + assert.equal(wrong.status, "resolved"); + assert.equal(wrong.session.localId, 1, "an answer for the shared id reaches the first session's prompt"); + assert.equal(w.registry.lookup(b.requestId).session.localId, 2, "the second session's prompt is still pending, under its own id"); + + var other = w.registry.respond(b.requestId, { decision: "skip" }); + assert.equal(other.status, "resolved"); + assert.equal(other.session.localId, 2); + assert.equal((await a.answer).behavior, "deny"); + assert.equal((await b.answer).behavior, "deny"); +}); + +test("once the first session's prompt has settled, its tool-use id is free to name another session's", async function () { + var w = twoSessionRegistry(); + var a = w.registry.open(w.sessions[1], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-reuse" }); + w.registry.respond(a.requestId, { decision: "skip" }); + var b = w.registry.open(w.sessions[2], "ask_user", { input: QUESTIONS }, { toolUseId: "tu-reuse" }); + assert.equal(b.requestId, "tu-reuse"); +}); + +function askHistory(ended) { + return [{ type: "tool_executing", name: "AskUserQuestion", id: "q1", input: QUESTIONS }, ended]; +} + +test("a recorded AskUserQuestion skip replays as skipped, an ending with no answer as cancelled, on the server", function () { + var w = twoSessionRegistry(); + var session = w.sessions[1]; + var skipped = askHistory({ type: "ask_user_answered", toolId: "q1", answers: {}, skipped: true }); + assert.deepEqual(w.registry.replayAnnotator(session, skipped, 0)(skipped[0]).promptState, { state: "resolved", skipped: true }); + + var answered = askHistory({ type: "ask_user_answered", toolId: "q1", answers: { 0: "a" } }); + assert.deepEqual(w.registry.replayAnnotator(session, answered, 0)(answered[0]).promptState, { state: "resolved", answers: { 0: "a" } }); + + var ended = askHistory({ type: "ask_user_answered", toolId: "q1" }); + assert.deepEqual(w.registry.replayAnnotator(session, ended, 0)(ended[0]).promptState, { state: "cancelled", reason: null }); +}); + +test("the client reads the same three recorded endings the same way", async function () { + dom.setupGlobals(); + var { normalizePromptMessage } = await import(dom.moduleUrl("prompt-state.js")); + var skipped = normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", answers: {}, skipped: true }); + assert.equal(skipped.phase, "resolved"); + assert.deepEqual(skipped.fields, { skipped: true }); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", answers: { 0: "a" } }).phase, "resolved"); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1" }).phase, "cancel"); + assert.equal(normalizePromptMessage({ type: "ask_user_answered", toolId: "q1", skipped: "yes" }).phase, "cancel", "only a real true is a skip"); +}); + +test("a send that throws is reported as what it is, not as a dropped connection, and the card stays answerable", async function (t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + dom.setupGlobals(); + var card = await import(dom.moduleUrl("prompt-card.js")); + var logged = []; + var original = console.error; + console.error = function () { logged.push(Array.prototype.join.call(arguments, " ")); }; + try { + var container = new dom.FakeElement("div"); + var actions = container.appendChild(new dom.FakeElement("div")); + actions.className = "permission-actions"; + var button = actions.appendChild(new dom.FakeElement("button")); + + var sent = card.submitPromptResponse(container, function () { throw new Error("socket write failed"); }); + assert.equal(sent, false); + assert.match(container.textContent, /Could not send the answer \(socket write failed\)/); + assert.doesNotMatch(container.textContent, /Not connected/); + assert.equal(container.classList.contains("sending"), false); + assert.equal(button.disabled, false, "the operator can choose again"); + assert.equal(logged.length, 1, "and the failure is logged, not swallowed"); + + assert.equal(card.submitPromptResponse(container, function () { return false; }), false); + assert.match(container.textContent, /Not connected/, "a socket that is not usable still says so"); + } finally { + console.error = original; + } +}); diff --git a/test/shared-codecs.test.js b/test/shared-codecs.test.js index 39fa1935..d3e9818a 100644 --- a/test/shared-codecs.test.js +++ b/test/shared-codecs.test.js @@ -17,7 +17,6 @@ var PUBLIC_DIR = path.join(LIB, "public"); var MODULES_DIR = path.join(PUBLIC_DIR, "modules"); // accessor on lib/prompt-kinds/codecs.js -> the file the browser imports var SHARED = { ownKey: "own-key.js", askUser: "ask-user-codec.js", elicitation: "elicitation-codec.js" }; -// The codecs that bound typed text. var ANSWER_CODECS = ["askUser", "elicitation"]; function browserPath(file) { return path.join(MODULES_DIR, "prompt-kinds", file); } From 80e1c1f37c417d5afb9e7d55f20e11bfa32327c6 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" Date: Sat, 10 Oct 2026 22:53:08 -0400 Subject: [PATCH 4/4] test(permission): share settle detector and server helpers, make the unexpressible-step guard reachable (lr-2ebc65) A settle detector now counts a rejection as settled (shared isSettled, also used by the worker effort test), client() and withServer() live in the prompt harness world, the lost-step guard has a case that trips it, and the grant check documents why a session grant never answers a restricted ask. Co-Authored-By: Claude Sonnet 5.5 --- lib/sdk-bridge.js | 11 ++++++--- test/claude-effort-control.test.js | 23 +++++-------------- test/permission-hints.test.js | 30 ++++++++++++------------- test/permission-mode-picker.test.js | 17 ++------------ test/prompt-harness-world.js | 31 ++++++++++++++++++++++++++ test/prompt-hostile-request-id.test.js | 16 ++----------- test/prompt-invariants.test.js | 30 +++++++++++++++++++++++-- 7 files changed, 91 insertions(+), 67 deletions(-) diff --git a/lib/sdk-bridge.js b/lib/sdk-bridge.js index 18f2d937..1a3f58b5 100644 --- a/lib/sdk-bridge.js +++ b/lib/sdk-bridge.js @@ -1059,9 +1059,14 @@ function createSDKBridge(opts) { // Auto-approve if tool was previously allowed for session (keyed on // toolName + input discriminator, not bare toolName — see // permissionGrantKey's doc comment for why the Skill tool needs this). - // A standing approval is broader than an ask the SDK marked as needing - // its own answer (suppressAlwaysAllowRule: no persistent choice; - // defaultToNo: must open on No), so it does not answer one. + // An Allow-for-session grant is a standing rule for every later call with + // the same key. The SDK forbids that for an ask marked + // suppressAlwaysAllowRule (a rule would grant more than this ask's own + // action), so such an ask is never answered by an earlier grant. An ask + // marked defaultToNo must open on No, which a silent approval would + // bypass; that is the stricter, safe reading of the hint. A rule-forced ask + // (matched_ask_rule) was put to the operator on purpose and is never + // auto-approved either. var needsOwnAnswer = ruleForced || opts.suppressAlwaysAllowRule === true || opts.defaultToNo === true; if (kind === "permission" && !needsOwnAnswer && prompts.isGranted(session, toolName, input)) { return Promise.resolve({ behavior: "allow", updatedInput: input }); diff --git a/test/claude-effort-control.test.js b/test/claude-effort-control.test.js index c924e474..35873595 100644 --- a/test/claude-effort-control.test.js +++ b/test/claude-effort-control.test.js @@ -24,21 +24,8 @@ function fakeQuery(calls, failWith) { function inbox(client) { return client.inbox; } -function client() { - var box = []; - return { inbox: box, ws: { readyState: 1, _clagenticUser: null, send: function (data) { box.push(JSON.parse(data)); } } }; -} - -async function withServer(fn) { - var server = await world.createServer(); - try { - await server.startQuery(); - await fn(server); - } finally { - server.shutdown(); - world.removeHome(server.home); - } -} +var client = world.client; +var withServer = world.withServer; test("every effort level the SDK names is applied through applyFlagSettings", async function () { assert.deepEqual(effortControl.EFFORT_LEVELS, ["low", "medium", "high", "xhigh", "max"]); @@ -98,15 +85,15 @@ async function drain(handle, worker) { test("a worker effort change settles on the worker's own answer", async function () { var worker = fakeWorker(); var handle = claudeAdapter._test_createWorkerQueryHandle(worker, null, null, null); - var settled = false; - var change = handle.setEffort("high").then(function () { settled = true; }); + var change = handle.setEffort("high"); + var state = world.isSettled(change); assert.equal(worker.sent.length, 1); assert.equal(worker.sent[0].type, "set_effort"); assert.equal(worker.sent[0].effort, "high"); assert.equal(typeof worker.sent[0].requestId, "string"); await Promise.resolve(); - assert.equal(settled, false, "not settled until the worker answers"); + assert.equal(state.settled, false, "not settled until the worker answers"); worker.handler({ type: "effort_changed", effort: "high", requestId: worker.sent[0].requestId }); await change; diff --git a/test/permission-hints.test.js b/test/permission-hints.test.js index 401c0e55..752e1d0f 100644 --- a/test/permission-hints.test.js +++ b/test/permission-hints.test.js @@ -16,21 +16,9 @@ var { promptsFor } = require("../lib/prompt-registry"); var RULE = { source: "projectSettings", toolName: "Bash", ruleContent: "make:*" }; var TOOL_INPUT = { command: "make" }; -function client() { - var inbox = []; - return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; -} - -async function withServer(fn) { - var server = await world.createServer(); - try { - await server.startQuery(); - await fn(server); - } finally { - server.shutdown(); - world.removeHome(server.home); - } -} +var client = world.client; +var withServer = world.withServer; +var isSettled = world.isSettled; // An in-process canUseTool call, as the SDK makes it, carrying the hints. function ask(server, hints, toolName, input) { @@ -43,6 +31,18 @@ function isSettled(promise) { return state; } +test("the settle detector sees a rejecting call as settled, and a call still waiting as pending", async function () { + var rejecting = isSettled(Promise.reject(new Error("handler failed"))); + var fulfilling = isSettled(Promise.resolve("ok")); + var waiting = isSettled(new Promise(function () {})); + await world.flush(); + assert.equal(rejecting.settled, true, "a rejection ends the call"); + assert.equal(rejecting.rejected, true); + assert.equal(fulfilling.settled, true); + assert.equal(fulfilling.rejected, false); + assert.equal(waiting.settled, false); +}); + // Every WS shape that carries an Allow-for-session answer: the card, the // notification banner (the card's message plus the project it belongs to), and // the older per-kind alias. diff --git a/test/permission-mode-picker.test.js b/test/permission-mode-picker.test.js index c8176e15..0171cbca 100644 --- a/test/permission-mode-picker.test.js +++ b/test/permission-mode-picker.test.js @@ -43,21 +43,8 @@ test("a Claude session declares every mode the picker can offer", function () { assert.equal(claudeCapabilities().permissionModes.length, 5, "each call gets its own list"); }); -function client() { - var inbox = []; - return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; -} - -async function withServer(fn) { - var server = await world.createServer(); - try { - await server.startQuery(); - await fn(server); - } finally { - server.shutdown(); - world.removeHome(server.home); - } -} +var client = world.client; +var withServer = world.withServer; test("set_permission_mode: auto and dontAsk reach a session whose vendor declares them, and only that session", async function () { await withServer(async function (server) { diff --git a/test/prompt-harness-world.js b/test/prompt-harness-world.js index 6ac9fd8e..091947f5 100644 --- a/test/prompt-harness-world.js +++ b/test/prompt-harness-world.js @@ -306,7 +306,38 @@ function removeHome(home) { fs.rmSync(home, { recursive: true, force: true }); } +/** An in-memory WS client: the socket to connect and the messages it received. */ +function client() { + var inbox = []; + return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; +} + +/** Runs fn against a server with a live query, then tears it down. */ +async function withServer(fn) { + var server = await createServer(); + try { + await server.startQuery(); + await fn(server); + } finally { + server.shutdown(); + removeHome(server.home); + } +} + +/** + * Tracks whether a promise has ended. A rejection ends it too: a detector that + * only watched fulfilment would read a failed call as one still waiting. + */ +function isSettled(promise) { + var state = { settled: false, rejected: false }; + promise.then(function () { state.settled = true; }, function () { state.settled = true; state.rejected = true; }); + return state; +} + module.exports = { + client: client, + withServer: withServer, + isSettled: isSettled, LIB_ROOT: LIB_ROOT, overridesLib: !!process.env.CLAGENTIC_CONSOLE_PROMPT_HARNESS_LIB, createServer: createServer, diff --git a/test/prompt-hostile-request-id.test.js b/test/prompt-hostile-request-id.test.js index d3bf7edf..9f71ba19 100644 --- a/test/prompt-hostile-request-id.test.js +++ b/test/prompt-hostile-request-id.test.js @@ -36,10 +36,7 @@ var WS_ANSWERS = [ { path: "elicitation_response", stale: "prompt_cancel", build: function (id) { return { type: "elicitation_response", requestId: id, action: "accept", content: { token: "t" } }; } }, ]; -function client() { - var inbox = []; - return { inbox: inbox, ws: { readyState: 1, _clagenticUser: null, send: function (data) { inbox.push(JSON.parse(data)); } } }; -} +var client = world.client; // One prompt of every kind, pending, so every store and the index hold a // real entry while the hostile answers arrive. @@ -71,16 +68,7 @@ function assertPrototypesClean() { assert.equal(typeof Object.prototype.hasOwnProperty, "function"); } -async function withServer(fn) { - var server = await world.createServer(); - try { - await server.startQuery(); - await fn(server); - } finally { - server.shutdown(); - world.removeHome(server.home); - } -} +var withServer = world.withServer; test("an inherited name is no prompt on any WS answer path: a stale reply, no throw, nothing settled", async function () { await withServer(async function (server) { diff --git a/test/prompt-invariants.test.js b/test/prompt-invariants.test.js index 2f3968eb..8a39c688 100644 --- a/test/prompt-invariants.test.js +++ b/test/prompt-invariants.test.js @@ -192,6 +192,9 @@ function Harness(label) { this.clients = []; this.terminalEvents = {}; this.unexpressible = 0; + // Whether the lib under test lacks prompt_response. Set only by a foreign + // lib, except in the test that proves a lost step fails a run. + this.predatesPromptResponse = world.overridesLib; } Harness.prototype.fail = function (msg) { @@ -428,14 +431,14 @@ Harness.prototype.respond = async function (client, r, answer) { var clicked = false; if (client.view && client.connected) clicked = this.clickAnswer(client, r, answer); if (!clicked) { - var legacy = world.overridesLib || this.toolSeq % 2 === 0; + var legacy = this.predatesPromptResponse || this.toolSeq % 2 === 0; var msg = responseMessage(r.kind, r.id, answer, legacy); // A lib that predates prompt_response has no message that can carry an // answer only prompt_response expresses (an AskUserQuestion skip), so the // step cannot be made there. It is counted, and a run against this // checkout fails if any step was lost this way, instead of judging a // message that was never sent. - if (msg.type === "prompt_response" && world.overridesLib) { + if (msg.type === "prompt_response" && this.predatesPromptResponse) { this.unexpressible++; return; } @@ -762,6 +765,29 @@ async function scenario(t, label, clientCount, body) { } } +// The guard that keeps a run honest: a step the lib cannot be sent is counted, +// and a run against this checkout fails on it instead of passing on a message +// that was never delivered. +test("a run against this checkout fails when a step it chose could not be sent", async function (t) { + if (world.overridesLib) { t.skip("only meaningful against this checkout"); return; } + if (RENDER) t.mock.timers.enable({ apis: ["setTimeout"] }); + var h = new Harness("unexpressible-guard"); + await h.init(1); + try { + var r = null; + // A skip is expressible only as prompt_response, whatever the parity. + await h.step(async function () { r = await h.request(BY_KIND.ask_user, null); }); + h.predatesPromptResponse = true; + h.clients[0].view = null; + await h.step(function () { return h.respond(h.clients[0], r, "skip"); }); + assert.equal(h.unexpressible, 1, "the lost step was counted"); + assert.deepEqual(h.violations, [], "and is no invariant violation by itself"); + assert.throws(function () { h.assertClean(); }, /every answer the run chose could be sent/); + } finally { + h.close(); + } +}); + test("a sub-agent's request survives its parent's turn result", async function (t) { await scenario(t, "subagent-vs-parent-result", 1, async function (h) { var task = null;