From 6489664f93e126185155f7826326bc339384d83b Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 16:38:12 +0800 Subject: [PATCH 1/6] feat(engine): add intent_analyzer dependency injection to TurnEngine Add an optional intent_analyzer callable to TurnEngine (dependency injection, default None = feature off, upstream behavior unchanged). Before emitting PERMISSION_REQUIRED, _authorize synchronously calls the analyzer to produce a consequence summary; the result is attached to both the event payload (as 'intent') and the PermissionRequest, so surfaces can render it on the approval card. Robustness: - wait_for timeout is wrapped in try/except so a TimeoutError never crashes _authorize (analysis degrades to intent=None). - The call runs inside _interruptible, so a user Stop during analysis resolves immediately; if Stop fired, no card is surfaced (the turn goes straight to the interrupted path, avoiding flash-then-deny). - intent_analyzer_timeout defaults to 20s (cloud-model tail latency); tests can inject smaller values. PermissionRequest gains an optional 'intent' field so approvers / Inbox snapshots carry the annotation through every park path. --- coworker/engine.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/coworker/engine.py b/coworker/engine.py index 341eba05..c0c15227 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -41,6 +41,7 @@ class PermissionRequest: metadata: Any reason: str tool_call_id: Optional[str] = None # for durable resume (idempotent inbox item) + intent: Optional[str] = None # AI-generated consequence summary, surfaced to the approver Approver = Callable[[PermissionRequest], Awaitable[ApprovalOutcome]] @@ -77,8 +78,16 @@ def __init__( # Called (thread-safe, best-effort) when the user stops the turn — e.g. the # executor's kill for a running shell command. interrupt_hooks: Optional[list[Callable[[], None]]] = None, + # Optional AI intent analyzer (dependency injection). None = feature off + # (upstream behavior unchanged). Signature: (tool_call, provider, model) -> str | None. + intent_analyzer: Optional[Callable] = None, + # Analyzer timeout in seconds (default 20s covers cloud-model tail latency; + # tests inject smaller values to avoid real waits). + intent_analyzer_timeout: float = 20.0, ) -> None: self.provider = provider + self.intent_analyzer = intent_analyzer # None = feature off + self.intent_analyzer_timeout = intent_analyzer_timeout self.registry = registry self.permissions = permissions self.model = model @@ -695,6 +704,40 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]" ) if not allowed and decision.needs_user: + # AI intent analysis: synchronously generate a consequence summary before + # surfacing the approval prompt, so the card appears already annotated. + # None = feature off (or analysis failed/timed out/stopped) → no annotation. + intent: Optional[str] = None + if self.intent_analyzer: + async def _do_analyze(): + return await asyncio.wait_for( + asyncio.to_thread( + self.intent_analyzer, tool_call, self.provider, self.model + ), + timeout=self.intent_analyzer_timeout, + ) + # try/except is required: wait_for re-raises TimeoutError via task.result(), + # and _interruptible does not swallow it — without this the approval flow + # would crash on timeout. + try: + intent = await self._interruptible(_do_analyze(), interrupted=None) + except Exception: + intent = None + + # If the user stopped during analysis, don't surface a card at all — go + # straight to the interrupted path (avoids a flash-then-deny flicker). + if self._cancel.is_set(): + self.messages.append(_tool_error_message(tool_call, "interrupted by user")) + self._audit( + tool_call, stage="finished", status="interrupted", reason="user stop" + ) + yield Event( + EventType.TOOL_FINISHED, + {"name": tool_call.name, "status": "interrupted", "reason": "stopped"}, + ) + yield False + return + yield Event( EventType.PERMISSION_REQUIRED, { @@ -711,6 +754,7 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]" metadata, self.permissions.risk_overrides, ), + "intent": intent, }, ) self._audit(tool_call, stage="approval_requested", reason=decision.reason) @@ -722,6 +766,7 @@ async def _authorize(self, tool_call: ToolCall) -> "AsyncIterator[Event | bool]" metadata=metadata, reason=decision.reason, tool_call_id=tool_call.id, + intent=intent, ) ), interrupted=ApprovalOutcome.DENY, From 8978f72e9a79d7215e185b066b6aea675093579a Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 16:46:00 +0800 Subject: [PATCH 2/6] feat(intent-analysis): add command intent analysis module + wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add coworker/intent_analysis/ — a small module that turns a tool call into a short, plain-English consequence summary via a single blocking provider.complete call (tools disabled, mirroring compaction's summarize_span pattern): - prompts.py: English system prompt (bullet points, bold for the most critical terms, severity emphasis for dangerous operations) + user prompt builder. No language routing — upstream is English-only. - analyzer.py: extract_input() shapes the tool call into a structured description (run_shell command / file path / message target); analyze() runs one round trip and cleans the output to bullet lines, returning None on any failure or empty result. Wiring: - agent.build_engine accepts and forwards intent_analyzer. - SessionManager injects analyze() at both build_engine sites when the 'intent_analysis' pref is on (default on), carries request.intent through approval_prompt_data so Inbox snapshots stay annotated, and exposes the pref via get_settings + set_intent_analysis. - POST /v1/settings/intent-analysis toggles it. The pref defaults to True; timeout defaults to 20s (covers cloud-model tail latency). Both are readily adjustable if maintainers prefer opt-in or a tighter bound. --- coworker/agent.py | 3 ++ coworker/intent_analysis/__init__.py | 0 coworker/intent_analysis/analyzer.py | 69 ++++++++++++++++++++++++++++ coworker/intent_analysis/prompts.py | 56 ++++++++++++++++++++++ coworker/server/app.py | 4 ++ coworker/server/manager.py | 21 +++++++++ 6 files changed, 153 insertions(+) create mode 100644 coworker/intent_analysis/__init__.py create mode 100644 coworker/intent_analysis/analyzer.py create mode 100644 coworker/intent_analysis/prompts.py diff --git a/coworker/agent.py b/coworker/agent.py index 9ed546fa..711b6008 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -167,6 +167,8 @@ def build_engine( connector_filter: Optional[set[str]] = None, # A set (static snapshot) or a zero-arg callable (live, re-evaluated per load_skill). skill_filter: Optional[set[str] | Callable[[], set[str]]] = None, + # Optional AI intent analyzer (dependency injection): None = feature off. + intent_analyzer: Optional[Callable] = None, ) -> TurnEngine: ws = Path(workspace).expanduser().resolve() if workspace else None if agent.needs_workspace and ws is None: @@ -395,6 +397,7 @@ def context_provider() -> str: directory_requester=directory_requester, plan_approver=plan_approver, question_asker=question_asker, + intent_analyzer=intent_analyzer, ) engine.executor = executor # type: ignore[attr-defined] engine.todo = todo # type: ignore[attr-defined] diff --git a/coworker/intent_analysis/__init__.py b/coworker/intent_analysis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/coworker/intent_analysis/analyzer.py b/coworker/intent_analysis/analyzer.py new file mode 100644 index 00000000..155c7886 --- /dev/null +++ b/coworker/intent_analysis/analyzer.py @@ -0,0 +1,69 @@ +"""AI command intent analysis — generate a consequence summary before approval. + +Uses a single blocking provider.complete call (tools disabled, no side effects), +mirroring the compaction.summarize_span pattern. Returns None on failure/empty +so the engine simply omits the annotation. + +Positional signature: the engine calls + asyncio.to_thread(self.intent_analyzer, tool_call, self.provider, self.model) +so positional args must align. +""" +import json +from typing import Any, Optional, Protocol + +from .prompts import build_system_prompt, build_user_prompt + + +class _ToolCallLike(Protocol): + name: str + arguments: dict[str, Any] + + +# Aligned with coworker/risk.py WRITE_TOOLS (workspace-mutating tools whose +# path argument is the meaningful thing to show the user). +_WRITE_TOOLS = {"write_file", "replace_in_file", "apply_patch", "apply_unified_diff"} +_SEND_TOOLS = {"send_message", "send_file"} + + +def extract_input(tool_call: _ToolCallLike) -> str: + """Extract a structured 'operation description' from the tool call.""" + name = tool_call.name + args = tool_call.arguments or {} + if name == "run_shell" and args.get("command"): + return str(args["command"]) + if name in _WRITE_TOOLS: + path = args.get("path", "") + return f"Operation: {name}\nPath: {path}" + if name in _SEND_TOOLS: + target = args.get("target") or args.get("destination") or args.get("channel") or "" + content = args.get("text") or args.get("content") or "" + return f"Operation: {name}\nTarget: {target}\nContent: {content}" + return f"Operation: {name}\nArgs: {json.dumps(args, ensure_ascii=False)}" + + +def _clean(text: str) -> Optional[str]: + """Keep only '• '/'- ' bullet lines, strip code fences and leading labels.""" + if not text: + return None + lines = [] + for line in text.splitlines(): + line = line.strip().strip("`") + if line.startswith("• ") or line.startswith("- "): + lines.append(line) + return "\n".join(lines) if lines else None + + +def analyze(tool_call, provider, model) -> Optional[str]: + """Generate the consequence summary. Synchronous blocking (the engine + wraps it in asyncio.to_thread). Returns None on failure/empty.""" + try: + messages = [ + {"role": "system", "content": build_system_prompt(2)}, + {"role": "user", "content": build_user_prompt(extract_input(tool_call))}, + ] + turn = provider.complete( + model=model, messages=messages, tools=None, max_tokens=300 + ) + return _clean(getattr(turn, "text", None)) + except Exception: + return None diff --git a/coworker/intent_analysis/prompts.py b/coworker/intent_analysis/prompts.py new file mode 100644 index 00000000..df7b1e5e --- /dev/null +++ b/coworker/intent_analysis/prompts.py @@ -0,0 +1,56 @@ +"""Prompts for AI command intent analysis.""" + +MAX_INPUT_CHARS = 2000 +MAX_BULLETS = 6 + +_EN_SYSTEM = """You are an expert at explaining operation consequences to users. The user is about to approve an operation. Your job is to clearly explain the consequences using bullet points so they can make an informed decision. + +# Rules +1. Format: Use bullet points (starting with "• "). Each point describes one specific consequence. 1-{n} bullet points total. +2. Language: STRICTLY output in English. +3. Emphasis: Wrap the most critical keywords with **double asterisks** for bold. Only bold the most important terms (1-2 per bullet), such as action verbs and irreversible consequences. +4. Focus: Each bullet should answer one of: What will happen? What will be affected? What is the risk/consequence? +5. CRITICAL - Dangerous operations: If the operation involves destructive or risky actions (rm, delete, drop, kill, format, overwrite, force push, reset --hard, chmod 777, truncate, revoke, clear, etc.), you MUST emphasize severity and irreversibility. +6. Non-dangerous operations: Still use bullet points, but in a neutral helpful tone without severity emphasis. Do not call tools. Do not include greetings, analysis, code fences, or extra explanation. Output only the bullet points. + +# Examples +Operation: run_shell +Command: rm ~/Desktop/test.sh +Intent: +• The rm command will **permanently delete** the file, this action is irreversible +• The file **cannot be recovered** from Trash after deletion + +Operation: run_shell +Command: git push --force origin main +Intent: +• Will **forcefully overwrite** the remote main branch history +• Other people's code on this branch **may be lost** +• This action **cannot be easily undone** + +Operation: write_file +Path: /etc/config +Intent: +• Will **overwrite** the file's current contents +• The original contents **cannot be recovered**, confirm you don't need to keep them + +Operation: send_message +Target: slack:#ops-channel +Intent: +• Will post a message to **#ops-channel**, visible to everyone in the channel +• The message **cannot be unsent** once delivered +""" + + +def build_system_prompt(max_bullets: int) -> str: + """Build the system prompt. max_bullets is clamped to 1..MAX_BULLETS.""" + n = max(1, min(MAX_BULLETS, max_bullets)) + return _EN_SYSTEM.format(n=n) + + +def build_user_prompt(operation_input: str) -> str: + """Build the user prompt. Long inputs are truncated to MAX_INPUT_CHARS.""" + operation_input = operation_input or "" + truncated = operation_input[:MAX_INPUT_CHARS] + if len(operation_input) > MAX_INPUT_CHARS: + truncated += "..." + return f"# Input\n{truncated}\n\n# Output\nReturn only the bullet points." diff --git a/coworker/server/app.py b/coworker/server/app.py index 65eea877..b7802a80 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -1434,6 +1434,10 @@ def settings_set_context_bar(body: dict) -> dict[str, Any]: # Composer: show the context-window fill bar, or just the popover (owner ask). return manager.set_context_bar((body or {}).get("context_bar", True)) + @app.post("/v1/settings/intent-analysis") + def settings_set_intent_analysis(body: dict) -> dict[str, Any]: + return manager.set_intent_analysis(bool((body or {}).get("enabled", True))) + @app.post("/v1/settings/pdf") def settings_set_pdf(body: dict) -> dict[str, Any]: # Token savings (owner ask, 2026-07-17): fallback mode for models without native diff --git a/coworker/server/manager.py b/coworker/server/manager.py index ad76e996..07183322 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -434,6 +434,11 @@ def get_engine( if Path(str(r.get("path", ""))).is_dir() ] roots = [{"path": ws, "writable": True, "label": "scratch"}, *extra] + # AI intent analysis: inject when the pref is on (default on). + from ..intent_analysis.analyzer import analyze + intent_analyzer = None + if self._prefs.get("intent_analysis", True): + intent_analyzer = analyze engine = build_engine( agent=ag, workspace=ws, @@ -467,6 +472,7 @@ def get_engine( # Per-session skill menu, LIVE (SKILLS-SPEC §3): a callable so load_skill sees # disables/new skills immediately; the catalog snapshot is taken at build. skill_filter=lambda sid=session_id, w=ws: self.effective_skill_names(sid, w), + intent_analyzer=intent_analyzer, ) # An automation run rebuilt here (manual "Run now" over WS, durable resume) still # carries its task's standing allowances — the rules live on the task record. @@ -1851,6 +1857,7 @@ def _selectable(m: str) -> bool: "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), "context_bar": self.context_bar(), + "intent_analysis": self._prefs.get("intent_analysis", True), "scratch_base": self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE, # Real on-disk secrets location, so the UI shows the OS-native path instead of a @@ -1920,6 +1927,11 @@ def set_context_bar(self, shown: Any) -> dict[str, Any]: self._save_prefs() return {"ok": True, "context_bar": self.context_bar()} + def set_intent_analysis(self, enabled: bool) -> dict[str, Any]: + self._prefs["intent_analysis"] = bool(enabled) + self._save_prefs() + return {"ok": True, "intent_analysis": bool(enabled)} + # -- PDF attachments / token savings (owner ask, 2026-07-17) ---------------- DEFAULT_PDF_MAX_PAGES = 20 DEFAULT_PDF_MAX_MB = 10 @@ -2604,6 +2616,9 @@ def approval_prompt_data(self, session_id: str, request) -> dict[str, Any]: "tool": request.tool_name, "arguments": getattr(request, "arguments", None) or {}, } + # Carry the AI intent annotation into the Inbox snapshot. + if getattr(request, "intent", None): + data["intent"] = request.intent task = self.task_store.task_for_run_session(session_id) if task is None: return data @@ -2719,6 +2734,11 @@ def _seed_task_permissions(self, engine: TurnEngine, task) -> None: def _build_task_engine(self, task, *, session_id: str) -> TurnEngine: ag = get_agent(task.agent) Path(task.workspace).mkdir(parents=True, exist_ok=True) + # AI intent analysis: inject when the pref is on (default on). + from ..intent_analysis.analyzer import analyze + intent_analyzer = None + if self._prefs.get("intent_analysis", True): + intent_analyzer = analyze engine = build_engine( agent=ag, workspace=task.workspace, @@ -2740,6 +2760,7 @@ def _build_task_engine(self, task, *, session_id: str) -> TurnEngine: skill_filter=lambda sid=session_id, w=task.workspace: ( self.effective_skill_names(sid, w) ), + intent_analyzer=intent_analyzer, ) self._seed_task_permissions(engine, task) return engine From 4202444ae19398f12301e3171f1c246f954e8ee5 Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 16:49:03 +0800 Subject: [PATCH 3/6] feat(approval): render AI intent analysis on approval cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the 'intent' annotation from PERMISSION_REQUIRED on both the live ApprovalCard and the parked InboxItemCard: - types.ts: optional intent?: string on ApprovalItem. - App.tsx: read d.intent when building the approval item. - ApprovalCard.tsx: renderIntentText() strips a leading bullet marker (the dot is CSS) and turns **bold** into ; the full card shows an
    of intent lines under the title. Routine file writes that normally render as a compact one-line row fall back to the full card when an intent is present (the row can't show it). - InboxItemCard.tsx: same
      for parked approvals (reads item.data.intent). - styles.css: .approval-intent* — transparent background, a 4px CSS dot, secondary-grey body text, body-ink bold spans. --- surfaces/gui/src/App.tsx | 1 + surfaces/gui/src/components/ApprovalCard.tsx | 31 +++++++++++++++++-- surfaces/gui/src/components/InboxItemCard.tsx | 11 +++++++ surfaces/gui/src/styles.css | 7 +++++ surfaces/gui/src/types.ts | 2 ++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index d18825cf..7aa48f71 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -671,6 +671,7 @@ export function App() { reason: d.reason, category: d.category, standingTarget: d.standing_target || undefined, + intent: d.intent || undefined, }, ]); break; diff --git a/surfaces/gui/src/components/ApprovalCard.tsx b/surfaces/gui/src/components/ApprovalCard.tsx index 8f2f2541..5b208c8f 100644 --- a/surfaces/gui/src/components/ApprovalCard.tsx +++ b/surfaces/gui/src/components/ApprovalCard.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import type { ApprovalDecision, Item } from "../types"; import { humanizeApprovalTitle, type HumanLine } from "../humanize"; import { Icon } from "./Icon"; @@ -30,6 +30,21 @@ const FILE_WRITES = new Set(["write_file", "replace_in_file", "apply_patch", "ap // Actions that leave the Mac get the warm border + explicit destination note. const EXTERNAL = new Set(["send_message", "send_file"]); +// Render a single intent-analysis line: strip a leading bullet marker (the dot is +// rendered via CSS) and turn **bold** spans into . +export function renderIntentText(line: string): ReactNode { + const stripped = line.replace(/^[\s•·\-*]+/, "").trim(); + if (!stripped) return null; + const parts = stripped.split(/(\*\*[^*]+\*\*)/g); + return parts.map((p, i) => + p.startsWith("**") && p.endsWith("**") ? ( + {p.slice(2, -2)} + ) : ( + {p} + ) + ); +} + type ApprovalItem = Extract; // Per-tool button copy (§7): a skill proposal is an "add", not an "allow". Shared with the @@ -243,7 +258,8 @@ export function ApprovalCard({ // §35 compact row: routine workspace writes — one line, preview expands inline from the // tool args. Standing/grant flows keep the full card (they carry §25 consent weight). const content = typeof item.args?.content === "string" ? item.args.content : ""; - if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved) { + // An intent annotation forces the full card (the compact row can't render it). + if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved && !item.intent) { return (
      @@ -274,6 +290,17 @@ export function ApprovalCard({ {scope.text}
      + {item.intent && ( +
        + {item.intent.split("\n").map((line, i) => ( +
      • + + {renderIntentText(line)} +
      • + ))} +
      + )} + {/* Tool-shaped previews — the proposal, not an args dump. */} {item.name === "run_shell" && item.args?.command && ( diff --git a/surfaces/gui/src/components/InboxItemCard.tsx b/surfaces/gui/src/components/InboxItemCard.tsx index a4075636..873d4ad6 100644 --- a/surfaces/gui/src/components/InboxItemCard.tsx +++ b/surfaces/gui/src/components/InboxItemCard.tsx @@ -4,6 +4,7 @@ import { humanizeApprovalTitle } from "../humanize"; import { approvalActionLabels, PreviewBlock, + renderIntentText, SaveSkillPreview, scopeNote, TitleText, @@ -94,6 +95,16 @@ export function InboxItemCard({
      {item.title}
      )} + {item.kind === "approval" && item.data?.intent && ( +
        + {String(item.data.intent).split("\n").map((line, i) => ( +
      • + + {renderIntentText(line)} +
      • + ))} +
      + )} {item.kind === "approval" && item.data?.tool === "save_skill" ? ( // Parked skill proposals wear the same review surface as the live card (§5.2). diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 3de7841c..b070972e 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -432,6 +432,13 @@ body { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .approval-reason { margin-top: 8px; font-size: 12.5px; color: var(--muted); } +/* AI intent analysis: a restrained annotation — transparent background, a small + CSS dot (not a • glyph), and secondary-grey text. It reads as supporting copy + inside the card rather than a separate panel. Bold spans pick up the body ink. */ +.approval-intent { margin-top: 12px; display: flex; flex-direction: column; gap: 2px; list-style: none; padding: 0; } +.approval-intent > li { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 22px; color: var(--muted); } +.approval-intent-dot { margin-top: 9px; width: 4px; height: 4px; flex: none; border-radius: 9999px; background: var(--faint); } +.approval-intent-em { color: var(--ink); font-weight: 600; } /* Standing-approval consent lines (§25): reads = quiet disclosure, writes = the grants. */ .approval-grants { margin-top: 11px; border: 1px solid var(--line); border-radius: 8px; background: var(--paper); diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 28fca293..a1bd542f 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -116,6 +116,8 @@ export type Item = // context, the card offers "Allow every time" (§25). standingTarget?: string; resolved?: ApprovalDecision; + // AI-generated consequence summary (from PERMISSION_REQUIRED event's d.intent). + intent?: string; } | { kind: "dirreq"; From 9399301ac30d2e7c82bbc223302fe9cc7b756097 Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 16:52:44 +0800 Subject: [PATCH 4/6] feat(settings): add intent-analysis toggle Add an 'Explain commands before I approve them' checkbox in Settings, modeled on the existing context-bar toggle (local useState + getSettings + setIntentAnalysis). When off, the manager stops injecting the analyzer and approval cards render without the annotation. - api.ts: intent_analysis? on ModelSettings + setIntentAnalysis(). - SettingsView.tsx: IntentAnalysisCard, mounted next to ContextBarCard. --- surfaces/gui/src/api.ts | 15 ++++++++ surfaces/gui/src/components/SettingsView.tsx | 40 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index ad9debd5..b3c2e0c3 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -697,6 +697,9 @@ export interface ModelSettings { // Composer: show the context-window fill bar (default FALSE; absent → the chip shows // the session total). The usage popover keeps both numbers regardless. context_bar?: boolean; + // AI command intent analysis (default true): annotate approval prompts with a + // short LLM-generated consequence summary before the user decides. + intent_analysis?: boolean; // Curated-matrix display names ({full id → "GLM-5.2 · via Together"}); custom models absent. model_labels?: Record; // {full id → context window in tokens}, verified matrix entries only — drives the @@ -775,6 +778,18 @@ export async function setContextBar( return res.json(); } +/** Toggle AI command intent analysis on approval prompts. */ +export async function setIntentAnalysis( + enabled: boolean, +): Promise<{ ok: boolean; intent_analysis?: boolean; error?: string }> { + const res = await fetch(`${httpBase()}/v1/settings/intent-analysis`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + return res.json(); +} + /** Persist how many sessions a sidebar group shows before "Show more". */ export async function setSessionsPeek( n: number, diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index 8a722f12..ae51c81a 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -4,6 +4,7 @@ import { getTrustedWorkspaces, setCompactionSettings, setContextBar, + setIntentAnalysis, setOnboarded, setPdfSettings, setScratchBase, @@ -438,6 +439,7 @@ function AppearanceSection() { + @@ -843,6 +845,44 @@ function ContextBarCard() { ); } +function IntentAnalysisCard() { + const [enabled, setEnabled] = useState(null); + + useEffect(() => { + getSettings() + .then((s) => setEnabled(s.intent_analysis !== false)) + .catch(() => setEnabled(false)); + }, []); + + const save = async (next: boolean) => { + setEnabled(next); + await setIntentAnalysis(next); + }; + + if (enabled === null) return null; + return ( +
      +
      Approvals
      + +
      + ); +} + function SidebarCard() { const [peek, setPeek] = useState(null); From 37d779bd9ef311c2c18943f7d233e5a58c2c7543 Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 16:54:36 +0800 Subject: [PATCH 5/6] test: cover intent analysis injection, timeout, and stop paths - test_intent_analysis.py: prompts (rules/clamp/truncation/None), extract_input (shell/file/send/fallback), _clean (fences/labels/empty), analyze (positional signature, success, provider error, empty output). - test_engine_intent.py: PermissionRequest.intent + TurnEngine fields, _authorize branches (None/success/timeout-no-crash/raises/stop-before/ stop-mid-flight), build_engine passthrough, manager approval_prompt_data intent carry-through, get_settings field, and the REST toggle roundtrip. - ApprovalCard.test.tsx: intent block renders when present, absent when missing, and forces the full card for routine file writes. - pyproject.toml: register the 'slow' marker used by the timeout/stop tests. --- pyproject.toml | 3 + .../gui/src/components/ApprovalCard.test.tsx | 36 +++ tests/test_engine_intent.py | 297 ++++++++++++++++++ tests/test_intent_analysis.py | 141 +++++++++ 4 files changed, 477 insertions(+) create mode 100644 tests/test_engine_intent.py create mode 100644 tests/test_intent_analysis.py diff --git a/pyproject.toml b/pyproject.toml index 687dad70..b2d6c66a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,3 +62,6 @@ coworker = ["personas/builtin/*.md"] [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] diff --git a/surfaces/gui/src/components/ApprovalCard.test.tsx b/surfaces/gui/src/components/ApprovalCard.test.tsx index 7f4570b5..9a9db518 100644 --- a/surfaces/gui/src/components/ApprovalCard.test.tsx +++ b/surfaces/gui/src/components/ApprovalCard.test.tsx @@ -304,3 +304,39 @@ describe("InboxItemCard — parked save_skill proposals (SKILLS-SPEC §5.2)", () expect(onResolve).toHaveBeenCalledWith("i9", "deny"); }); }); + +describe("ApprovalCard — AI intent block", () => { + it("renders the intent block when item.intent is present", () => { + render( + , + ); + expect(screen.getByText(/dangerous op/)).toBeTruthy(); + expect(screen.getByText(/irreversible/)).toBeTruthy(); + }); + + it("does not render an intent block when item.intent is absent", () => { + const { container } = render(); + expect(container.querySelector(".approval-intent")).toBeNull(); + }); + + it("renders the intent for file_write (forces the full card, not the compact row)", () => { + // Routine file writes normally render as a compact one-line row; when an intent is + // present they must fall back to the full card so the annotation is visible. + render( + , + ); + expect(screen.getByText(/overwrites the file/)).toBeTruthy(); + }); +}); diff --git a/tests/test_engine_intent.py b/tests/test_engine_intent.py new file mode 100644 index 00000000..64863e92 --- /dev/null +++ b/tests/test_engine_intent.py @@ -0,0 +1,297 @@ +"""Engine + manager integration tests for AI intent analysis.""" +import asyncio +from unittest.mock import MagicMock + +import pytest + +from coworker.engine import PermissionRequest, TurnEngine, EventType + + +# -- PermissionRequest + TurnEngine.__init__ -- + + +def test_permission_request_has_intent_field(): + """PermissionRequest has an optional intent field (default None).""" + req = PermissionRequest( + tool_name="run_shell", arguments={}, metadata=None, reason="test" + ) + assert req.intent is None + req2 = PermissionRequest( + tool_name="run_shell", arguments={}, metadata=None, reason="test", intent="• x" + ) + assert req2.intent == "• x" + + +def test_turn_engine_accepts_intent_analyzer_none(): + """TurnEngine.__init__ accepts intent_analyzer, default None.""" + engine = _build_minimal_engine(intent_analyzer=None) + assert engine.intent_analyzer is None + + +def test_turn_engine_accepts_intent_analyzer_callable(): + analyzer = lambda tc, p, m: "• test" + engine = _build_minimal_engine(intent_analyzer=analyzer) + assert engine.intent_analyzer is analyzer + + +def test_turn_engine_accepts_intent_analyzer_timeout(): + """TurnEngine.__init__ accepts intent_analyzer_timeout, default 20.0.""" + engine = _build_minimal_engine() + assert engine.intent_analyzer_timeout == 20.0 + engine2 = _build_minimal_engine(intent_analyzer_timeout=0.1) + assert engine2.intent_analyzer_timeout == 0.1 + + +def _build_minimal_engine(intent_analyzer=None, intent_analyzer_timeout=None): + """Build a minimal TurnEngine (no turn run, just verify field assignment).""" + from coworker.engine import TurnEngine + from coworker.permissions import PermissionEngine, Mode + from coworker.tools import ToolRegistry + from coworker.providers.base import ProviderClient + + kwargs = dict( + provider=MagicMock(spec=ProviderClient), + registry=ToolRegistry(), + permissions=PermissionEngine(mode=Mode.INTERACTIVE, workspace_root="."), + model="test", + intent_analyzer=intent_analyzer, + ) + if intent_analyzer_timeout is not None: + kwargs["intent_analyzer_timeout"] = intent_analyzer_timeout + return TurnEngine(**kwargs) + + +# -- _authorize synchronous analysis branch -- + + +@pytest.mark.asyncio +async def test_authorize_no_analyzer_payload_intent_none(): + """intent_analyzer=None → event payload.intent is None (upstream behavior).""" + events = await _run_authorize(intent_analyzer=None) + perm_event = next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) + assert perm_event.data["intent"] is None + + +@pytest.mark.asyncio +async def test_authorize_analyzer_success_payload_has_intent(): + """Injected analyzer returning text → event payload.intent carries it.""" + + def analyzer(tc, p, m): + return "• dangerous\n• irreversible" + + events = await _run_authorize(intent_analyzer=analyzer) + perm_event = next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) + assert "dangerous" in perm_event.data["intent"] + + +@pytest.mark.asyncio +@pytest.mark.slow +async def test_authorize_analyzer_timeout_does_not_crash(): + """A timeout must not crash _authorize (analysis degrades to intent=None). + + Uses a small injectable timeout (0.1s) to avoid a real 20s wait. The engine's + intent_analyzer_timeout defaults to 20.0; tests inject 0.1. + """ + import time + + def slow_analyzer(tc, p, m): + time.sleep(0.3) # well over the 0.1s test timeout + return "never" + + events = await _run_authorize(intent_analyzer=slow_analyzer, analyzer_timeout=0.1) + perm_event = next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) + assert perm_event.data["intent"] is None + + +@pytest.mark.asyncio +async def test_authorize_analyzer_raises_returns_none(): + """An analyzer that raises → intent=None.""" + + def bad_analyzer(tc, p, m): + raise RuntimeError("boom") + + events = await _run_authorize(intent_analyzer=bad_analyzer) + perm_event = next(e for e in events if e.type == EventType.PERMISSION_REQUIRED) + assert perm_event.data["intent"] is None + + +@pytest.mark.asyncio +async def test_authorize_stop_before_emit_no_card(): + """If Stop fired before the card is emitted, no PERMISSION_REQUIRED surfaces.""" + engine = _build_minimal_engine_with_shell(intent_analyzer=lambda *a: None) + engine._cancel.set() # stopped before analysis + events = await _collect_authorize_events(engine, _shell_tool_call()) + perm_events = [e for e in events if e.type == EventType.PERMISSION_REQUIRED] + assert len(perm_events) == 0 # no card flashed + + +@pytest.mark.asyncio +@pytest.mark.slow +async def test_authorize_stop_mid_analysis_no_card(): + """Stopping mid-analysis must also avoid flashing a card — _interruptible + resolves early via its cancel_wait path.""" + import asyncio, time + + def slow_analyzer(tc, p, m): + time.sleep(0.3) # leaves room for a mid-flight cancel + return "never" + + engine = _build_minimal_engine_with_shell(intent_analyzer=slow_analyzer, analyzer_timeout=1.0) + + async def cancel_after_start(): + await asyncio.sleep(0.05) # analysis has started + engine._cancel.set() + + asyncio.create_task(cancel_after_start()) + events = await _collect_authorize_events(engine, _shell_tool_call()) + perm_events = [e for e in events if e.type == EventType.PERMISSION_REQUIRED] + assert len(perm_events) == 0 + + +# -- build_engine passthrough -- + + +def test_build_engine_passes_intent_analyzer(): + """build_engine forwards intent_analyzer to TurnEngine.""" + from coworker.agent import build_engine + from coworker.agents import code_agent + + analyzer = lambda *a: "test" + engine = build_engine(agent=code_agent(), workspace=".", intent_analyzer=analyzer) + assert engine.intent_analyzer is analyzer + + +def test_build_engine_default_intent_analyzer_none(): + """Omitting intent_analyzer defaults to None (upstream behavior unchanged).""" + from coworker.agent import build_engine + from coworker.agents import code_agent + + engine = build_engine(agent=code_agent(), workspace=".") + assert engine.intent_analyzer is None + + +# -- manager: approval_prompt_data + settings -- + + +def test_approval_prompt_data_carries_intent(): + """approval_prompt_data writes request.intent into data (Inbox snapshot). + + Uses a real SessionManager (matching tests/test_settings.py); with no + automation run, task_for_run_session returns None and we hit the early + return, isolating the intent-carry behavior. + """ + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + req = PermissionRequest( + tool_name="run_shell", + arguments={"command": "rm x"}, + metadata=None, + reason="test", + intent="• dangerous\n• irreversible", + ) + data = manager.approval_prompt_data("session-1", req) + assert data["intent"] == "• dangerous\n• irreversible" + + +def test_approval_prompt_data_no_intent_omits_field(): + """intent=None → data has no 'intent' key (avoids null pollution).""" + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + req = PermissionRequest( + tool_name="run_shell", arguments={}, metadata=None, reason="test", intent=None + ) + data = manager.approval_prompt_data("session-1", req) + assert "intent" not in data + + +def test_get_settings_includes_intent_analysis_default_true(): + """get_settings returns intent_analysis, defaulting to True.""" + import tempfile + + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + settings = manager.get_settings() + assert settings.get("intent_analysis") is True + + +def test_set_intent_analysis_via_rest(): + """POST /v1/settings/intent-analysis persists, GET reflects it.""" + import tempfile + from fastapi.testclient import TestClient + from coworker.server.app import create_app + from coworker.server.manager import SessionManager + + with tempfile.TemporaryDirectory() as tmp: + manager = SessionManager(data_dir=tmp) + app = create_app(manager) + client = TestClient(app) + # default True + assert client.get("/v1/settings").json().get("intent_analysis") is True + # turn off + r = client.post("/v1/settings/intent-analysis", json={"enabled": False}) + assert r.json()["intent_analysis"] is False + # GET reflects + assert client.get("/v1/settings").json().get("intent_analysis") is False + + +# -- helpers -- + + +def _shell_tool_call(): + from coworker.engine import ToolCall + + return ToolCall(id="tc1", name="run_shell", arguments={"command": "rm x"}) + + +async def _run_authorize(intent_analyzer=None, analyzer_timeout=None): + """Run _authorize for a run_shell call and collect yielded events. Approver denies.""" + engine = _build_minimal_engine_with_shell( + intent_analyzer=intent_analyzer, analyzer_timeout=analyzer_timeout + ) + return await _collect_authorize_events(engine, _shell_tool_call()) + + +async def _collect_authorize_events(engine, tool_call): + from coworker.engine import ApprovalOutcome + + async def deny_approver(req): + return ApprovalOutcome.DENY + + engine.approver = deny_approver + events = [] + async for item in engine._authorize(tool_call): + if hasattr(item, "type"): + events.append(item) + return events + + +def _build_minimal_engine_with_shell(intent_analyzer=None, analyzer_timeout=None): + """Build an engine that can run run_shell _authorize (shell tool registered).""" + from coworker.engine import TurnEngine + from coworker.permissions import PermissionEngine, Mode + from coworker.tools import ToolRegistry + from coworker.tools.shell import shell_tools + from coworker.providers.base import ProviderClient + + registry = ToolRegistry() + registry.register_all(shell_tools(MagicMock())) + kwargs = dict( + provider=MagicMock(spec=ProviderClient), + registry=registry, + permissions=PermissionEngine(mode=Mode.INTERACTIVE, workspace_root="."), + model="test", + intent_analyzer=intent_analyzer, + ) + if analyzer_timeout is not None: + kwargs["intent_analyzer_timeout"] = analyzer_timeout + return TurnEngine(**kwargs) diff --git a/tests/test_intent_analysis.py b/tests/test_intent_analysis.py new file mode 100644 index 00000000..52123056 --- /dev/null +++ b/tests/test_intent_analysis.py @@ -0,0 +1,141 @@ +"""Tests for the AI command intent analysis module.""" +from unittest.mock import MagicMock + +from coworker.intent_analysis.analyzer import analyze, extract_input, _clean +from coworker.intent_analysis.prompts import ( + build_system_prompt, + build_user_prompt, + MAX_INPUT_CHARS, + MAX_BULLETS, +) + + +def _tc(name, **args): + """Build a duck-typed tool_call fixture.""" + tc = MagicMock() + tc.name = name + tc.arguments = args + return tc + + +# -- prompts -- + + +def test_build_system_prompt_has_rules(): + s = build_system_prompt(2) + assert "bullet" in s.lower() + assert "1-2" in s # max_bullets is injected + + +def test_build_system_prompt_clamps_bullets(): + s = build_system_prompt(MAX_BULLETS + 10) + assert f"1-{MAX_BULLETS}" in s # clamped to the cap + + +def test_build_system_prompt_clamps_lower_bound(): + s = build_system_prompt(0) + assert "1-1" in s # clamped to 1 + s2 = build_system_prompt(-5) + assert "1-1" in s2 + + +def test_build_user_prompt_includes_input(): + u = build_user_prompt("rm -rf /tmp") + assert "rm -rf /tmp" in u + assert "Return only the bullet points" in u + + +def test_build_user_prompt_truncates_long_input(): + long = "x" * (MAX_INPUT_CHARS + 50) + u = build_user_prompt(long) + assert len(u) < len(long) + 200 # truncated + + +def test_build_user_prompt_none_is_safe(): + """None/empty input must not raise (defensive guard).""" + u = build_user_prompt(None) + assert "Return only the bullet points" in u + + +# -- extract_input -- + + +def test_extract_input_shell(): + tc = _tc("run_shell", command="rm -rf /tmp") + assert extract_input(tc) == "rm -rf /tmp" + + +def test_extract_input_file_write(): + tc = _tc("write_file", path="/etc/config") + out = extract_input(tc) + assert "write_file" in out and "/etc/config" in out + + +def test_extract_input_replace_in_file(): + tc = _tc("replace_in_file", path="/app/main.py") + out = extract_input(tc) + assert "replace_in_file" in out + + +def test_extract_input_send_message_target(): + """send_message's real param is 'target', not 'destination'/'channel'.""" + tc = _tc("send_message", target="slack:#general", text="hello") + out = extract_input(tc) + assert "slack:#general" in out and "hello" in out + + +def test_extract_input_fallback(): + tc = _tc("unknown_tool", foo="bar") + out = extract_input(tc) + assert "unknown_tool" in out and "foo" in out + + +# -- _clean -- + + +def test_clean_valid_bullets(): + assert _clean("• deletes file\n• unrecoverable") == "• deletes file\n• unrecoverable" + + +def test_clean_strips_fences(): + assert _clean("```\n• deletes file\n```") == "• deletes file" + + +def test_clean_strips_leading_intent_label(): + assert _clean("Intent:\n• deletes file") == "• deletes file" + + +def test_clean_empty_returns_none(): + assert _clean("") is None + assert _clean("no bullets here") is None + + +# -- analyze (positional signature) -- + + +def test_analyze_positional_signature(): + """analyze(tc, prov, mdl) must accept positional args (engine calls it via + asyncio.to_thread(self.intent_analyzer, tool_call, provider, model)).""" + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• ok") + result = analyze(_tc("run_shell", command="ls"), prov, "test-model") + assert result == "• ok" + + +def test_analyze_success(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="• permanently deleted\n• unrecoverable") + result = analyze(_tc("run_shell", command="rm x"), prov, "m") + assert "permanently deleted" in result + + +def test_analyze_provider_error_returns_none(): + prov = MagicMock() + prov.complete.side_effect = RuntimeError("network down") + assert analyze(_tc("run_shell", command="rm x"), prov, "m") is None + + +def test_analyze_empty_output_returns_none(): + prov = MagicMock() + prov.complete.return_value = MagicMock(text="") + assert analyze(_tc("run_shell", command="rm x"), prov, "m") is None From b640ec5530a90e40f54664cad59129914d12d29d Mon Sep 17 00:00:00 2001 From: malin1997 Date: Fri, 7 Aug 2026 17:03:51 +0800 Subject: [PATCH 6/6] fix: make intent analysis opt-in (default off) Defaulting the pref to True broke upstream tests that drive approvals through a ScriptedProvider (test_durable_resume, test_ui_refresh_cross_cutting_e2e): the analyzer consumed a scripted provider.complete response before the model could use it, so the post-approval reply never landed. Making it opt-in (default False) keeps the feature a strict no-op for existing tests and users unless they explicitly turn it on in Settings. The PR description is updated to reflect opt-in as the default. --- coworker/server/manager.py | 10 +++++----- surfaces/gui/src/components/SettingsView.tsx | 2 +- tests/test_engine_intent.py | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 07183322..886273d4 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -434,10 +434,10 @@ def get_engine( if Path(str(r.get("path", ""))).is_dir() ] roots = [{"path": ws, "writable": True, "label": "scratch"}, *extra] - # AI intent analysis: inject when the pref is on (default on). + # AI intent analysis: inject when the pref is on (opt-in; default off). from ..intent_analysis.analyzer import analyze intent_analyzer = None - if self._prefs.get("intent_analysis", True): + if self._prefs.get("intent_analysis", False): intent_analyzer = analyze engine = build_engine( agent=ag, @@ -1857,7 +1857,7 @@ def _selectable(m: str) -> bool: "nav_layout": self._nav_layout(), "sessions_peek": self.sessions_peek(), "context_bar": self.context_bar(), - "intent_analysis": self._prefs.get("intent_analysis", True), + "intent_analysis": self._prefs.get("intent_analysis", False), "scratch_base": self._prefs.get("scratch_base") or self.DEFAULT_SCRATCH_BASE, # Real on-disk secrets location, so the UI shows the OS-native path instead of a @@ -2734,10 +2734,10 @@ def _seed_task_permissions(self, engine: TurnEngine, task) -> None: def _build_task_engine(self, task, *, session_id: str) -> TurnEngine: ag = get_agent(task.agent) Path(task.workspace).mkdir(parents=True, exist_ok=True) - # AI intent analysis: inject when the pref is on (default on). + # AI intent analysis: inject when the pref is on (opt-in; default off). from ..intent_analysis.analyzer import analyze intent_analyzer = None - if self._prefs.get("intent_analysis", True): + if self._prefs.get("intent_analysis", False): intent_analyzer = analyze engine = build_engine( agent=ag, diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index ae51c81a..8f22d154 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -850,7 +850,7 @@ function IntentAnalysisCard() { useEffect(() => { getSettings() - .then((s) => setEnabled(s.intent_analysis !== false)) + .then((s) => setEnabled(s.intent_analysis === true)) .catch(() => setEnabled(false)); }, []); diff --git a/tests/test_engine_intent.py b/tests/test_engine_intent.py index 64863e92..09978f58 100644 --- a/tests/test_engine_intent.py +++ b/tests/test_engine_intent.py @@ -212,8 +212,8 @@ def test_approval_prompt_data_no_intent_omits_field(): assert "intent" not in data -def test_get_settings_includes_intent_analysis_default_true(): - """get_settings returns intent_analysis, defaulting to True.""" +def test_get_settings_includes_intent_analysis_default_false(): + """get_settings returns intent_analysis, defaulting to False (opt-in).""" import tempfile from coworker.server.manager import SessionManager @@ -221,7 +221,7 @@ def test_get_settings_includes_intent_analysis_default_true(): with tempfile.TemporaryDirectory() as tmp: manager = SessionManager(data_dir=tmp) settings = manager.get_settings() - assert settings.get("intent_analysis") is True + assert settings.get("intent_analysis") is False def test_set_intent_analysis_via_rest(): @@ -235,13 +235,13 @@ def test_set_intent_analysis_via_rest(): manager = SessionManager(data_dir=tmp) app = create_app(manager) client = TestClient(app) - # default True - assert client.get("/v1/settings").json().get("intent_analysis") is True - # turn off - r = client.post("/v1/settings/intent-analysis", json={"enabled": False}) - assert r.json()["intent_analysis"] is False - # GET reflects + # default False (opt-in) assert client.get("/v1/settings").json().get("intent_analysis") is False + # turn on + r = client.post("/v1/settings/intent-analysis", json={"enabled": True}) + assert r.json()["intent_analysis"] is True + # GET reflects + assert client.get("/v1/settings").json().get("intent_analysis") is True # -- helpers --