diff --git a/coworker/engine.py b/coworker/engine.py index 9b36e43d..874efe04 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -984,6 +984,20 @@ class TurnEngine: self.tool_requester(dict(args), tool_call.id), interrupted={"installed": False, "error": "interrupted by user"}, ) or {"installed": False, "error": "no response"} + if not result.get("installed"): + # The card says "or install it yourself and continue" — honor it. A user + # who brewed the tool mid-prompt and clicked Continue has PROVIDED it, + # not declined it; find their copy before treating this as a refusal. + found = _toolchain.resolve(name) + if found: + result = { + "installed": True, + "path": found, + "note": ( + "the user provided their own copy instead of the managed " + "install — use it from this path" + ), + } if not result.get("installed"): result.setdefault( "guidance", diff --git a/surfaces/gui/e2e/toolreq.spec.ts b/surfaces/gui/e2e/toolreq.spec.ts index fb812a0e..7efbf64f 100644 --- a/surfaces/gui/e2e/toolreq.spec.ts +++ b/surfaces/gui/e2e/toolreq.spec.ts @@ -22,8 +22,11 @@ test("request_tool surfaces a card naming the tool, the reason and the pinned ve // the coworker's quoted reason (mixing them is what made the card confusing, 2026-08-14). const facts = card.locator(".toolreq-facts"); await expect(facts).toContainText("8.30.1"); - await expect(facts).toContainText(/checksum-verified/i); - await expect(facts).toContainText("from github.com/gitleaks"); + // Plain-language consent: who installs (OpenWorker), from where, and the self-install + // alternative — no supply-chain jargon on the card (owner feedback 2026-08-15). + await expect(facts).toContainText( + "OpenWorker installs its own verified copy from github.com/gitleaks — or install it yourself and continue.", + ); // Declining must read as a normal choice that continues the run, not a failure. await expect(card.getByTestId("toolreq-skip")).toHaveText("Continue without it"); }); diff --git a/surfaces/gui/src/components/ToolRequestCard.tsx b/surfaces/gui/src/components/ToolRequestCard.tsx index 25963154..b22d1685 100644 --- a/surfaces/gui/src/components/ToolRequestCard.tsx +++ b/surfaces/gui/src/components/ToolRequestCard.tsx @@ -30,20 +30,25 @@ export function ToolRequestCard({ coworker's quoted ask above — mixing the two voices is what made the card confusing. */} {item.installable ? (
- - {item.tool} - {item.version ? ` ${item.version}` : ""} - - {item.summary && {item.summary}} - pinned & checksum-verified - {item.source && from {item.source}} +
+ + {item.tool} + {item.version ? ` ${item.version}` : ""} + + {item.summary && {item.summary}} +
+
+ OpenWorker installs its own verified copy + {item.source ? ` from ${item.source}` : ""} — or install it yourself and + continue. +
) : (
- - No verified build is available for this machine — install it yourself if you want - this check, or continue and the coworker will note the gap. - +
+ OpenWorker has no verified build for this machine — install it yourself if you + want this check, or continue and the coworker will note the gap. +
)}
diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 64effa43..6a02fffb 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -800,14 +800,15 @@ button.btn.danger { color: var(--accent); } /* request_tool fact strip — the product's own metadata (version, publisher, checksum), deliberately NOT italic so it can't be misread as the coworker still talking */ .toolreq-facts { - display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12.5px; color: var(--muted); font-style: normal; background: var(--paper); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; margin: 2px 0 10px; } .toolreq-facts code { color: var(--ink); font-size: 12.5px; } -.toolreq-fact + .toolreq-fact::before, -.toolreq-facts code + .toolreq-fact::before { content: "· "; color: var(--muted); } +.toolreq-factrow { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } +.toolreq-factrow code + .toolreq-fact::before { content: "· "; color: var(--muted); } +.toolreq-explain { margin-top: 4px; } +.toolreq-factrow + .toolreq-explain { margin-top: 5px; } /* a disabled Install must LOOK disabled — the whole card exists to not oversell */ .dirreq-actions .btn:disabled { opacity: 0.45; cursor: not-allowed; } .dirreq-pathrow { display: flex; gap: 8px; align-items: center; } diff --git a/tests/test_tool_request.py b/tests/test_tool_request.py index de5624b1..6ca8e59b 100644 --- a/tests/test_tool_request.py +++ b/tests/test_tool_request.py @@ -68,9 +68,14 @@ async def test_emits_tool_requested_and_reports_install(tmp_path): @pytest.mark.asyncio -async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): +async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path, monkeypatch): """A refusal must not read as 'check done'. The tool result has to push the agent toward a disclosed fallback, which is the whole point of the contract.""" + from coworker import toolchain + + # Truly absent — otherwise the decline-time re-check (below) would find the dev + # machine's real gitleaks and turn this into the user-provided-copy path. + monkeypatch.setattr(toolchain, "resolve", lambda name: None) async def requester(args, tool_call_id=None): return {"installed": False, "reason": "the user declined to install it"} @@ -86,6 +91,29 @@ async def test_declining_tells_the_agent_to_fall_back_openly(tmp_path): assert "degraded" in body or "fallback" in body +@pytest.mark.asyncio +async def test_decline_recheck_finds_a_copy_the_user_installed_themselves(tmp_path, monkeypatch): + """The card says "or install it yourself and continue" — that has to be real. A user + who brews the tool while the prompt is up and clicks Continue has PROVIDED the tool; + the agent must be handed their copy's path, not a refusal.""" + from coworker import toolchain + + monkeypatch.setattr(toolchain, "resolve", lambda name: "/opt/homebrew/bin/gitleaks") + + async def requester(args, tool_call_id=None): + return {"installed": False, "reason": "the user declined to install it"} + + engine = _engine(tmp_path, requester) + events = await _run(engine) + finished = [e for e in events if e.type is EventType.TOOL_FINISHED] + assert finished[0].data["status"] == "ok" + + tool_msg = [m for m in engine.messages if m.get("role") == "tool"][-1] + body = str(tool_msg["content"]) + assert "/opt/homebrew/bin/gitleaks" in body + assert "own copy" in body # attributed to the user, not to a managed install + + @pytest.mark.asyncio async def test_event_tells_the_truth_about_installability(tmp_path, monkeypatch): """Owner-hit 2026-08-14: the card offered Install for a tool with no pinned build —