Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 2510c59269 test(tools): restore the drain guard and gate the TTY loop on the exit too
Two follow-ups from review.

The anti-redelivery poll my previous commit message described was not in the
tree. It was written, it passed, and then a `git checkout -- .` in the
falsification run that followed reverted it, because it was the one edit made
after the checkpoint commit. The message was amended afterwards and recorded work
that no longer existed. jatmn caught the mismatch. Restored here, and the claim
now matches the code: one more read after the served line, asserting it is not
repeated, which is the only guard for drain semantics on Windows since the
manager-level test that counts deliveries is skipped there.

The TTY loop stopped on the echo alone while asserting the exit as well. collect
can return the drained echo on a yield timer before markDone has run, so a poll
can carry got:hello with no exit_code meta yet and the assertion below would then
be about an exit nobody observed. It now waits for both. The echo term stays in
the condition deliberately: gating on exit_code alone would stop polling before a
slow shell start delivers the echo, which is the race this change exists to
remove.
2026-09-08 08:39:56 +05:30
Vasanthdev2004 b9fc003d09 test(tools): harden the poll fix against what a poll can hide
Follow-up to the poll fix, from adversarially reviewing it rather than trusting
it. Four things it did not yet handle, three of them proven with probes.

A dead session does not pace the loop. All the spacing comes from collect()
waiting out its yield inside a LIVE session; every error return happens in
microseconds, and Continue removes the process the moment it observes the exit.
Measured at 3028 polls in 5 seconds on a removed id, which on a 4-vCPU runner
would peg a core for the whole deadline and hand the failure hundreds of
kilobytes of one repeated sentence. The exit result is also delivered exactly
once, so a loop that folds it into the blob and keeps going loses the single poll
that says what went wrong. Both now end the loop immediately.

A read drains, and "the line showed up" is satisfied just as well by output
re-delivered forever. One more read after the served line, asserting it is not
repeated, pins that. The manager-level test asserting the same count is skipped
on Windows, so this is the only place guarding it there.

The address parse accepted a fragment. A line split across two drains arrives as
two bodies with banner text wedged between them, and the formatter terminates the
partial chunk so it looks whole. It is now rejected unless it is host:port with a
numeric port, which keeps polling instead of failing on an unreachable address
and pointing the next reader at the network. Not reproduced: 25 runs under
saturation gave no splits, and a single Println explains why. It is a property of
the pattern rather than of today's helper.

The deadline was the same order of magnitude as the latency it tolerates, which
is the mistake that produced 500ms. Sixty seconds, against 0.74s to 4.79s
measured with every core saturated on a box faster than a runner.

Coverage: the address parse was the only assertion in the repo that a
still-running session's first read carries the child's stdout, and polling gives
that up, since which path runs now depends on the machine. It is paid back
deterministically instead. The helper prints a line per request, which cannot
exist before the first read, so it can only arrive through a poll of a live
process. Verified by removing that line: the test fails.

Two siblings found by the same sweep, both bounds that a slow start could break
on a correct system: the background-child timeout bound goes from 1s to 3s, still
far under the child's own lifetime, and the unpolled-session reap deadline from
2s to 10s against a 1.44s cold start.
2026-09-07 22:11:15 +05:30
Vasanthdev2004 0c9125d5a6 docs(tools): record that exec session reads drain, so poll chunks are incremental 2026-09-07 21:35:22 +05:30
Vasanthdev2004 902f171e31 test(tools): poll for the PTY echo instead of demanding it in one window
Same shape as the server test, one window narrower. The shell starts with a 10ms
yield, so it has almost certainly not reached `read` yet, and the echo then had
exactly one fixed 1000ms window to travel back through the PTY. Writing early is
fine, the terminal buffers it; spending that whole second getting the shell up on
a loaded runner is not.

Found by sweeping the package for the same single-window-then-assert shape after
fixing the server test. This one is linux-only so it cannot be exercised on a
Windows box; the Linux job is the check.

The exit code is tracked across polls rather than read off whichever call happened
to be last, since the echo and the exit can arrive in different reads.
2026-09-07 21:34:18 +05:30
Vasanthdev2004 516653bf84 test(tools): poll for the exec server's address instead of racing its startup
TestExecCommandForegroundServerReturnsSessionAndServesHTTP gave the helper one
500ms yield to be spawned, reach net.Listen and have its line drained back, then
parsed that single read for the address. On a loaded Windows CI runner it has not
got there yet, so the read is the "Command is still running." banner with no
address and the test fails as though the server were broken.

It has been failing that way on unrelated contributor pull requests, which is
worse than a slow test: it reports somebody else's change as the fault. The same
failure reproduces here by shrinking the yield to 1ms, and a single poll then
returns the address.

So it polls, which is what the banner in that very output tells the caller to do,
up to a bound far above any plausible start. A genuine failure still ends it at
once rather than waiting the bound out: a session that has gone away answers with
an error status, and a process that exited before listening answers with an
exit_code line, and both are reported with the full poll transcript.

The HTTP request is bounded too. It had no timeout, so a server that accepted a
connection and never answered would have hung until the package timeout took the
whole run down with it.
2026-09-07 21:30:11 +05:30
Vasanth T aadb4a27e9 fix(specialist): keep the pinned model when a specialist is resumed (#1009)
* fix(specialist): keep the pinned model when a specialist is resumed

Metadata.Model exists so a bounded, delegated task can run on a cheaper model
than its parent, and BuildArgs appends it to the child argv through
appendModelArgs. BuildResumeArgs never did.

So a specialist pinned to a cheap model ran on that model exactly once. The
moment the orchestrator resumed it, the child fell back to whatever the
parent's configured model resolved to. Nothing surfaced it: the resumed child
starts normally and does the work, so the only symptom is the bill.
Cost-motivated delegation quietly stopped saving anything.

Resuming does not restore the recorded model on its own. sessions.PrepareExec
records the model a run used but never feeds it back into provider
construction, so the flag has to be passed again rather than relied upon.

BuildResumeArgsInput now carries ParentModel and ParentReasoningEffort, the
same fallbacks the fresh path takes, and runResume passes what TaskRunOptions
already held. The reasoning-effort rule travels with the model unchanged: the
parent's effort is inherited only when the manifest pins no model of its own,
because a manifest that chose a different model has not agreed to the parent's
effort for it.

Regressions drive both builders and compare them, so the two paths cannot
drift again: a pinned model survives resume, an unpinned one still inherits the
parent's, both halves of the effort rule hold, and the flag keeps its position
relative to --auto in both.

Refs #554

* test(specialist): cover the resume call site, not just its builder

The builder tests all call BuildResumeArgs directly, so dropping the
ParentModel field from the runResume call site still compiled and still passed
every one of them. The defect this fixes lived at the call site, so it needs a
test that goes through Run.

Driven through the real dispatch with the RunChild seam capturing argv.

* test(specialist): guard the fresh call site as well

runFresh and runResume each construct their builder input by hand and carry a
byte-identical ParentModel line. Deleting either compiles and, until now,
deleting the fresh one was silent.

The resume half is what this branch repairs. This covers the other half so the
pair cannot drift again in the direction nobody was looking.
2026-09-07 16:02:39 +05:30
KRATOS a6ec0435e3 feat(acp): surface safe browser tool metadata (#1000)
* feat(acp): surface safe browser tool metadata

* fix(acp): align browser permission titles

* fix(acp): namespace browser metadata

* fix(acp): reject unsafe browser title text
2026-09-07 15:50:49 +05:30
10 changed files with 940 additions and 27 deletions

No files matched your search

+3 -1
View File
@@ -142,13 +142,15 @@ func actionOffered(optionID string, offered []PermissionOption) bool {
// session/request_permission request from a ZERO permission request.
func permissionToolCall(req agent.PermissionRequest) ToolCallUpdate {
args := marshalArgs(req.Args)
return ToolCallUpdate{
upd := ToolCallUpdate{
ToolCallID: req.ToolCallID,
Title: toolTitle(req.ToolName, string(args)),
Kind: toolKindFor(req.ToolName),
Status: ToolStatusPending,
RawInput: rawInputBytes(args),
}
attachBrowserToolDetails(&upd, req.ToolName)
return upd
}
func marshalArgs(args map[string]any) []byte {
+14
View File
@@ -82,3 +82,17 @@ func TestPermissionToolCall(t *testing.T) {
t.Error("expected rawInput from args")
}
}
func TestPermissionToolCallKeepsTheBrowserDescriptor(t *testing.T) {
call := permissionToolCall(agent.PermissionRequest{
ToolCallID: "browser-1",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
})
if got := browserDescriptor(t, call); got != (BrowserToolDetails{Version: 1, Command: "connect"}) {
t.Fatalf("browser descriptor = %#v", got)
}
if call.Title != "browser connect" {
t.Fatalf("title = %q", call.Title)
}
}
+119 -1
View File
@@ -2,7 +2,9 @@ package acp
import (
"encoding/json"
"net/url"
"strings"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
@@ -44,12 +46,125 @@ func toolKindFor(name string) string {
// toolTitle builds a concise human title, e.g. "read_file src/main.go".
func toolTitle(name, rawArgs string) string {
if browser, ok := browserToolDetails(name); ok {
return browserToolTitle(browser.Command, rawArgs)
}
if hint := primaryArgHint(rawArgs); hint != "" {
return name + " " + hint
}
return name
}
// browserToolDetails identifies ZERO's local browser helpers without treating
// similarly named MCP tools as browser automation. The descriptor intentionally
// contains no request data: ACP tool input is already protocol-visible, but a
// durable UI must not need to retain text, local CDP targets, or full URLs just
// to recognise the browser operation.
func browserToolDetails(name string) (*BrowserToolDetails, bool) {
const prefix = "browser_"
command, ok := strings.CutPrefix(name, prefix)
if !ok {
return nil, false
}
switch command {
case "install", "launch", "connect", "open", "snapshot", "click", "type", "press", "action":
return &BrowserToolDetails{Version: 1, Command: command}, true
default:
return nil, false
}
}
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
// extension channel. Keeping this in one helper prevents start, result, and
// permission payloads from drifting onto different wire shapes.
func attachBrowserToolDetails(update *ToolCallUpdate, name string) {
browser, ok := browserToolDetails(name)
if !ok {
return
}
raw, err := json.Marshal(browser)
if err != nil {
return
}
update.Meta = map[string]json.RawMessage{zeroBrowserMetaKey: raw}
}
// browserToolTitle avoids putting browser_type text, an attached DevTools
// endpoint, or a URL query/fragment in a tool-card title. Those values can
// carry credentials or session data; the UI only needs the operation and, for
// navigation, a human-recognisable origin.
func browserToolTitle(command, rawArgs string) string {
switch command {
case "action":
action, ok := exactJSONStringArg(rawArgs, "command")
if !ok {
return "browser action"
}
if action, ok := tools.NormalizedBrowserActionCommand(action); ok {
return "browser action " + action
}
return "browser action"
case "open":
rawURL, ok := exactJSONStringArg(rawArgs, "url")
if !ok {
return "browser open"
}
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
return "browser open"
}
u, err := url.Parse(normalized)
if err != nil || u.Scheme == "" || u.Host == "" {
return "browser open"
}
origin := u.Scheme + "://" + u.Host
if !browserTitleTextSafe(origin) {
return "browser open"
}
return "browser open " + truncateHint(origin)
default:
return "browser " + command
}
}
// browserTitleTextSafe validates text after URL parsing has decoded escaped
// UTF-8 in the host. Valid UTF-8 alone is not presentation-safe: control,
// format/bidi, and line/paragraph separator runes can reorder or split the
// permission label shown to a user. The execution URL remains unchanged.
func browserTitleTextSafe(text string) bool {
if !utf8.ValidString(text) {
return false
}
for _, r := range text {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
return false
}
}
return true
}
// exactJSONStringArg mirrors ZERO's map-based tool argument decoding: only the
// exact JSON key is considered, and a non-string value is invalid. In
// particular, an incidental "URL" key must not change a permission title when
// browser_open will only read "url".
func exactJSONStringArg(rawArgs, key string) (string, bool) {
var args map[string]json.RawMessage
if json.Unmarshal([]byte(rawArgs), &args) != nil {
return "", false
}
raw, ok := args[key]
if !ok {
return "", false
}
var value string
if json.Unmarshal(raw, &value) != nil {
return "", false
}
return value, true
}
// primaryArgHint extracts the most relevant argument (path/pattern/command) from
// raw JSON arguments. Best-effort; returns "" when it can't parse.
func primaryArgHint(rawArgs string) string {
@@ -89,7 +204,7 @@ func rawInput(args string) json.RawMessage {
// toolCallStart maps an advertised ZERO tool call to the initial ACP "tool_call"
// update (status in_progress — ZERO executes immediately after advertising).
func toolCallStart(call agent.ToolCall) ToolCallUpdate {
return ToolCallUpdate{
upd := ToolCallUpdate{
SessionUpdate: UpdateToolCall,
ToolCallID: call.ID,
Title: toolTitle(call.Name, call.Arguments),
@@ -97,6 +212,8 @@ func toolCallStart(call agent.ToolCall) ToolCallUpdate {
Status: ToolStatusInProgress,
RawInput: rawInput(call.Arguments),
}
attachBrowserToolDetails(&upd, call.Name)
return upd
}
// toolCallResult maps a finished ZERO tool result to a "tool_call_update".
@@ -116,6 +233,7 @@ func toolCallResult(result agent.ToolResult) ToolCallUpdate {
if locs := toolResultLocations(result); len(locs) > 0 {
upd.Locations = locs
}
attachBrowserToolDetails(&upd, result.Name)
return upd
}
+226
View File
@@ -1,14 +1,29 @@
package acp
import (
"encoding/json"
"strings"
"testing"
"unicode"
"unicode/utf8"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
t.Helper()
raw, ok := update.Meta[zeroBrowserMetaKey]
if !ok {
t.Fatalf("browser metadata = %#v, want %q", update.Meta, zeroBrowserMetaKey)
}
var details BrowserToolDetails
if err := json.Unmarshal(raw, &details); err != nil {
t.Fatalf("decode browser metadata: %v", err)
}
return details
}
func TestAgentMessageAndThoughtChunks(t *testing.T) {
m := agentMessageChunk("hello")
if m.SessionUpdate != UpdateAgentMessageChunk || m.Content.Type != "text" || m.Content.Text != "hello" {
@@ -56,6 +71,217 @@ func TestToolTitleAndHint(t *testing.T) {
}
}
func TestBrowserToolUpdatesAreStructuredAndPresentationSafe(t *testing.T) {
start := toolCallStart(agent.ToolCall{
ID: "browser-1",
Name: "browser_open",
Arguments: `{"url":"https://example.com/settings?token=not-for-a-title#account"}`,
})
if got := browserDescriptor(t, start); got != (BrowserToolDetails{Version: 1, Command: "open"}) {
t.Fatalf("browser descriptor = %#v, want open", got)
}
if start.Title != "browser open https://example.com" {
t.Fatalf("browser title = %q", start.Title)
}
if strings.Contains(start.Title, "token=") || strings.Contains(start.Title, "#account") {
t.Fatalf("browser title leaked URL-sensitive data: %q", start.Title)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
var wire struct {
Meta map[string]json.RawMessage `json:"_meta"`
}
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatal(err)
}
if _, ok := wire.Meta[zeroBrowserMetaKey]; !ok {
t.Fatalf("browser wire metadata = %#v", wire.Meta)
}
typed := toolCallStart(agent.ToolCall{
ID: "browser-2",
Name: "browser_type",
Arguments: `{"ref":"email","text":"[email protected]"}`,
})
if got := browserDescriptor(t, typed); got.Command != "type" {
t.Fatalf("browser type descriptor = %#v", got)
}
if typed.Title != "browser type" || strings.Contains(typed.Title, "[email protected]") {
t.Fatalf("browser type title = %q", typed.Title)
}
action := toolCallStart(agent.ToolCall{
ID: "browser-3",
Name: "browser_action",
Arguments: `{"command":"keyboard_insert_text","args":["[email protected]"]}`,
})
if action.Title != "browser action keyboard_insert_text" {
t.Fatalf("browser action title = %q", action.Title)
}
result := toolCallResult(agent.ToolResult{
ToolCallID: "browser-2",
Name: "browser_type",
Status: tools.StatusOK,
})
if got := browserDescriptor(t, result); got.Command != "type" {
t.Fatalf("browser result descriptor = %#v", got)
}
}
func TestBrowserDescriptorSurvivesProtocolShapedRoundTrip(t *testing.T) {
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{
ID: "start",
Name: "browser_open",
Arguments: `{"url":"https://user:[email protected]/private?token=secret#fragment"}`,
}),
toolCallResult(agent.ToolResult{
ToolCallID: "result",
Name: "browser_type",
Status: tools.StatusOK,
}),
permissionToolCall(agent.PermissionRequest{
ToolCallID: "permission",
ToolName: "browser_connect",
Args: map[string]any{"target": "127.0.0.1:9222"},
}),
}
type protocolToolCallUpdate struct {
SessionUpdate string `json:"sessionUpdate,omitempty"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(encoded, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["browser"]; ok {
t.Fatalf("browser descriptor escaped ACP _meta: %s", encoded)
}
var protocol protocolToolCallUpdate
if err := json.Unmarshal(encoded, &protocol); err != nil {
t.Fatal(err)
}
forwarded, err := json.Marshal(protocol)
if err != nil {
t.Fatal(err)
}
var roundTripped ToolCallUpdate
if err := json.Unmarshal(forwarded, &roundTripped); err != nil {
t.Fatal(err)
}
details := browserDescriptor(t, roundTripped)
if details.Version != 1 || details.Command == "" {
t.Fatalf("round-tripped browser descriptor = %#v", details)
}
descriptorJSON := string(roundTripped.Meta[zeroBrowserMetaKey])
for _, secret := range []string{"password", "private", "token", "fragment", "127.0.0.1", "9222"} {
if strings.Contains(descriptorJSON, secret) {
t.Fatalf("browser descriptor leaked %q: %s", secret, descriptorJSON)
}
}
}
}
func TestBrowserPermissionTitlesMirrorSafeToolArguments(t *testing.T) {
if got := browserToolTitle("open", `{"url":"evil.example.test/pay?token=hidden#fragment"}`); got != "browser open https://evil.example.test" {
t.Fatalf("bare-host title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test"}`); got != "browser open" {
t.Fatalf("case-variant URL title = %q", got)
}
if got := browserToolTitle("open", `{"URL":"https://different.example.test","url":"https://actual.example.test/path"}`); got != "browser open https://actual.example.test" {
t.Fatalf("exact URL key title = %q", got)
}
if got := browserToolTitle("action", `{"command":"not an action"}`); got != "browser action" {
t.Fatalf("unknown browser action title = %q", got)
}
longHost := "https://" + strings.Repeat("a", 200) + ".example.test/path?token=hidden"
title := browserToolTitle("open", `{"url":"`+longHost+`"}`)
if !utf8.ValidString(title) || utf8.RuneCountInString(title) > len("browser open ")+61 || strings.Contains(title, "token=") {
t.Fatalf("bounded browser origin title = %q", title)
}
}
func TestBrowserOpenTitlesRejectDecodedUnicodePresentationControls(t *testing.T) {
for _, rawURL := range []string{
"https://safe.example%E2%80%AEevil.test/path",
"https://safe.example%E2%81%A6evil.test/path",
"https://safe.example%C2%85evil.test/path",
"https://safe.example%E2%80%A8evil.test/path",
"https://safe.example%E2%80%A9evil.test/path",
} {
t.Run(rawURL, func(t *testing.T) {
normalized, err := tools.NormalizeBrowserOpenURL(rawURL)
if err != nil {
t.Fatalf("execution URL rejected: %v", err)
}
if normalized != rawURL {
t.Fatalf("execution URL = %q, want unchanged %q", normalized, rawURL)
}
args, err := json.Marshal(map[string]any{"url": rawURL})
if err != nil {
t.Fatal(err)
}
updates := []ToolCallUpdate{
toolCallStart(agent.ToolCall{ID: "start", Name: "browser_open", Arguments: string(args)}),
permissionToolCall(agent.PermissionRequest{ToolCallID: "permission", ToolName: "browser_open", Args: map[string]any{"url": rawURL}}),
}
for _, update := range updates {
encoded, err := json.Marshal(update)
if err != nil {
t.Fatal(err)
}
var decoded ToolCallUpdate
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Title != "browser open" {
t.Fatalf("unsafe browser title survived wire round trip: %q", decoded.Title)
}
for _, r := range decoded.Title {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) {
t.Fatalf("browser title contains unsafe presentation rune %U: %q", r, decoded.Title)
}
}
}
})
}
}
func TestBrowserDescriptorDoesNotClaimSimilarlyNamedMCPTools(t *testing.T) {
start := toolCallStart(agent.ToolCall{ID: "mcp-1", Name: "browser_plugin_open", Arguments: `{}`})
if len(start.Meta) != 0 {
t.Fatalf("MCP-like tool received built-in browser metadata: %#v", start.Meta)
}
encoded, err := json.Marshal(start)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"browser"`) {
t.Fatalf("non-browser tool encoded browser field: %s", encoded)
}
}
func TestToolCallStart(t *testing.T) {
upd := toolCallStart(agent.ToolCall{ID: "tc1", Name: "read_file", Arguments: `{"path":"a.go"}`})
if upd.SessionUpdate != UpdateToolCall {
+15
View File
@@ -210,6 +210,21 @@ type ToolCallUpdate struct {
RawInput json.RawMessage `json:"rawInput,omitempty"`
Content []ToolCallContent `json:"content,omitempty"`
Locations []ToolCallLocation `json:"locations,omitempty"`
// Meta is ACP's extension channel. ZERO-owned values must remain beneath a
// namespaced key so protocol-shaped clients can preserve them while decoding
// and re-encoding a tool call.
Meta map[string]json.RawMessage `json:"_meta,omitempty"`
}
// BrowserToolDetails identifies the browser helper operation behind a tool
// call. Version is the schema version for this optional ZERO extension;
// Command is one of install, launch, connect, open, snapshot, click, type,
// press, or action. Future fields must remain display-safe and must not
// include browser profile data, cookies, typed text, URL paths/queries, or
// DevTools endpoints.
type BrowserToolDetails struct {
Version int `json:"version"`
Command string `json:"command"`
}
// ToolCallContent is a tool call's rendered output. ZERO emits "content" (a
+31 -12
View File
@@ -76,12 +76,17 @@ type BuildArgsInput struct {
}
type BuildResumeArgsInput struct {
SessionID string
Prompt string
CurrentDepth int
Manifest Manifest
Cwd string
PermissionMode string
SessionID string
Prompt string
CurrentDepth int
Manifest Manifest
Cwd string
// ParentModel and ParentReasoningEffort are the same fallbacks the fresh
// path takes, and they are here for the same reason: appendModelArgs uses
// them only when the manifest pins nothing of its own.
ParentModel string
ParentReasoningEffort string
PermissionMode string
}
type BuildArgsResult struct {
@@ -344,6 +349,18 @@ func (executor Executor) BuildResumeArgs(input BuildResumeArgsInput) (BuildArgsR
}
args := []string{"exec", "--resume", sessionID}
args = append(args, promptArgs...)
// A RESUMED SPECIALIST KEEPS THE MODEL ITS MANIFEST PINNED.
//
// The fresh path appends this and the resume path did not, so a specialist
// pinned to a cheap model ran on that model once and then silently reverted
// to the parent's configured model on every resume. Nothing reports it: the
// child starts normally and the only symptom is the bill.
//
// Resuming does not restore the recorded model on its own. sessions.PrepareExec
// records the model it ran under but never feeds it back into provider
// construction, so without this the child takes whatever the config default
// resolves to now.
args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort)
args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json")
// See BuildArgs: only plan/spec-draft propagate --permission-mode so --auto
// remains the authority for member/auto/ask/unsafe child resolution.
@@ -424,12 +441,14 @@ func (executor Executor) runResume(ctx context.Context, params TaskParameters, o
return ExecResult{}, err
}
built, err := executor.BuildResumeArgs(BuildResumeArgsInput{
SessionID: params.Resume,
Prompt: params.Prompt,
CurrentDepth: options.CurrentDepth,
Manifest: manifest,
Cwd: options.Cwd,
PermissionMode: options.PermissionMode,
SessionID: params.Resume,
Prompt: params.Prompt,
CurrentDepth: options.CurrentDepth,
Manifest: manifest,
Cwd: options.Cwd,
ParentModel: options.ParentModel,
ParentReasoningEffort: options.ParentReasoningEffort,
PermissionMode: options.PermissionMode,
})
if err != nil {
return ExecResult{}, err
+303
View File
@@ -0,0 +1,303 @@
package specialist
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/streamjson"
)
// argValue returns the value following flag in argv, and whether it was present.
func argValue(args []string, flag string) (string, bool) {
for index, arg := range args {
if arg == flag && index+1 < len(args) {
return args[index+1], true
}
}
return "", false
}
func pinnedManifest(model string, effort string) Manifest {
return Manifest{
Metadata: Metadata{
Name: "skim",
Description: "Bounded read-only lookups.",
Model: model,
ReasoningEffort: effort,
Tools: []string{"read_file"},
},
SystemPrompt: "Find the thing and stop.",
ResolvedTools: []string{"read_file"},
}
}
// A RESUMED SPECIALIST KEEPS THE MODEL ITS MANIFEST PINNED.
//
// Metadata.Model exists so a bounded, delegated task can run on a cheaper model
// than its parent. BuildArgs appended it; BuildResumeArgs did not. So the
// specialist ran on the cheap model once, and the moment the orchestrator
// resumed it the child fell back to whatever the parent's configured model
// resolved to. Nothing surfaced it: the resumed child starts normally, does the
// work, and the only symptom is the bill.
//
// Resuming does not restore the recorded model on its own, which is why the flag
// has to be passed again rather than relied upon.
func TestAResumedSpecialistKeepsItsPinnedModel(t *testing.T) {
manifest := pinnedManifest("claude-haiku-4.5", "")
fresh, err := (Executor{}).BuildArgs(BuildArgsInput{
Manifest: manifest,
Prompt: "find the thing",
CurrentDepth: 0,
ParentModel: "claude-opus-4.1",
})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
// SETUP: the fresh path really does pin it, or the comparison below is
// asserting against a path that never worked either.
freshModel, ok := argValue(fresh.Args, "--model")
if !ok || freshModel != "claude-haiku-4.5" {
t.Fatalf("SETUP INVALID: the fresh path passed --model %q (present=%t), want the manifest's model", freshModel, ok)
}
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
Prompt: "keep going",
CurrentDepth: 0,
Manifest: manifest,
ParentModel: "claude-opus-4.1",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
resumedModel, ok := argValue(resumed.Args, "--model")
if !ok {
t.Fatalf("a resumed specialist carries no --model at all, so it reverts to the parent's configured model: %v", resumed.Args)
}
if resumedModel != "claude-haiku-4.5" {
t.Fatalf("a resumed specialist runs on %q, want the manifest's pinned %q", resumedModel, "claude-haiku-4.5")
}
}
// And a manifest that pins nothing still inherits the parent's model, which is
// what makes the fallback in appendModelArgs meaningful rather than the pinned
// case being special-cased.
func TestAResumedSpecialistWithNoPinInheritsTheParentModel(t *testing.T) {
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
Prompt: "keep going",
CurrentDepth: 0,
Manifest: pinnedManifest("", ""),
ParentModel: "claude-opus-4.1",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
model, ok := argValue(resumed.Args, "--model")
if !ok || model != "claude-opus-4.1" {
t.Fatalf("a resumed specialist with no pinned model passed --model %q (present=%t), want the parent's", model, ok)
}
}
// The reasoning-effort rule travels with the model, or the two paths disagree
// about what a pinned model implies. appendModelArgs inherits the parent's
// effort ONLY when the manifest pins no model of its own: a manifest that chose
// a different model has not agreed to the parent's effort for it.
func TestAResumedSpecialistFollowsTheSameReasoningEffortRule(t *testing.T) {
t.Run("pinned model does not inherit parent effort", func(t *testing.T) {
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
Prompt: "keep going",
CurrentDepth: 0,
Manifest: pinnedManifest("claude-haiku-4.5", ""),
ParentModel: "claude-opus-4.1",
ParentReasoningEffort: "high",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
if effort, ok := argValue(resumed.Args, "--reasoning-effort"); ok {
t.Fatalf("a manifest that pinned its own model inherited the parent's effort %q", effort)
}
})
t.Run("no pinned model inherits parent effort", func(t *testing.T) {
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ",
Prompt: "keep going",
CurrentDepth: 0,
Manifest: pinnedManifest("", ""),
ParentModel: "claude-opus-4.1",
ParentReasoningEffort: "high",
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
effort, ok := argValue(resumed.Args, "--reasoning-effort")
if !ok || effort != "high" {
t.Fatalf("a manifest pinning nothing passed --reasoning-effort %q (present=%t), want the parent's", effort, ok)
}
})
}
// The two builders agree on where the flag sits relative to the rest of the
// argv, so a future change to one does not silently reorder only the other.
func TestBothBuildersPlaceTheModelBeforeTheAutonomyFlag(t *testing.T) {
manifest := pinnedManifest("claude-haiku-4.5", "")
fresh, err := (Executor{}).BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "go", CurrentDepth: 0})
if err != nil {
t.Fatalf("BuildArgs: %v", err)
}
resumed, err := (Executor{}).BuildResumeArgs(BuildResumeArgsInput{
SessionID: "01HZZZZZZZZZZZZZZZZZZZZZZZ", Prompt: "go", CurrentDepth: 0, Manifest: manifest,
})
if err != nil {
t.Fatalf("BuildResumeArgs: %v", err)
}
for _, argv := range [][]string{fresh.Args, resumed.Args} {
model := indexOf(argv, "--model")
auto := indexOf(argv, "--auto")
if model < 0 || auto < 0 || model > auto {
t.Fatalf("--model at %d, --auto at %d in %s", model, auto, strings.Join(argv, " "))
}
}
}
func indexOf(args []string, want string) int {
for index, arg := range args {
if arg == want {
return index
}
}
return -1
}
// AND THE CALL SITE PASSES IT, WHICH THE BUILDER TESTS ABOVE CANNOT SEE.
//
// Every test above calls BuildResumeArgs directly. Dropping the ParentModel
// field from the runResume call site still compiles and still passes all of
// them, because the builder is doing its job with whatever it is handed. The
// defect this fix repairs lived at the call site, not in the builder, so it
// needs a test that goes through Run.
//
// Driven through the real Run dispatch with the RunChild seam capturing argv.
func TestRunResumePassesTheParentModelThroughToTheChild(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("create parent: %v", err)
}
if _, err := store.Create(sessions.CreateInput{
SessionID: "child_task",
SessionKind: sessions.SessionKindChild,
Tag: SessionTagSpecialist,
Depth: 1,
ParentSessionID: parent.SessionID,
AgentName: "skim",
TaskID: "child_task",
}); err != nil {
t.Fatalf("create child: %v", err)
}
zero := 0
var captured []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
NewSessionID: func() (string, error) { return "child_task", nil },
Load: func(LoadOptions) (LoadResult, error) {
// No pinned model, so the parent's is the only thing that can supply one.
return LoadResult{Specialists: []Manifest{pinnedManifest("", "")}}, nil
},
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) {
captured = append([]string(nil), args...)
return ChildRunResult{
Events: []streamjson.Event{
{Type: streamjson.EventRunStart, RunID: "run_1", SessionID: "child_task"},
{Type: streamjson.EventFinal, RunID: "run_1", Text: "done"},
{Type: streamjson.EventRunEnd, RunID: "run_1", Status: "success", ExitCode: &zero},
},
}, nil
},
}
if _, err := executor.Run(context.Background(), TaskParameters{
Name: "skim",
Prompt: "keep going",
Resume: "child_task",
}, TaskRunOptions{
ParentSessionID: parent.SessionID,
ParentModel: "claude-opus-4.1",
}); err != nil {
t.Fatalf("Run(resume): %v", err)
}
// SETUP: this really was the resume path, not a fresh spawn.
if index := indexOf(captured, "--resume"); index < 0 {
t.Fatalf("SETUP INVALID: the child was not resumed: %v", captured)
}
model, ok := argValue(captured, "--model")
if !ok {
t.Fatalf("the resumed child was launched with no --model, so it runs on whatever the config default resolves to: %v", captured)
}
if model != "claude-opus-4.1" {
t.Fatalf("the resumed child was launched with --model %q, want the parent's %q", model, "claude-opus-4.1")
}
}
// THE FRESH CALL SITE NEEDS THE SAME GUARD, FOR THE SAME REASON.
//
// runFresh and runResume both construct their builder input by hand, and both
// have a byte-identical ParentModel line. Deleting either one compiles. The
// resume half is what this change repairs; this covers the other half so the
// pair cannot drift again in the direction nobody was looking.
func TestRunFreshPassesTheParentModelThroughToTheChild(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
parent, err := store.Create(sessions.CreateInput{SessionID: "parent_session"})
if err != nil {
t.Fatalf("create parent: %v", err)
}
zero := 0
var captured []string
executor := Executor{
BinaryPath: "/usr/local/bin/zero",
SessionStore: store,
NewSessionID: func() (string, error) { return "child_task", nil },
Load: func(LoadOptions) (LoadResult, error) {
return LoadResult{Specialists: []Manifest{pinnedManifest("", "")}}, nil
},
RunChild: func(_ context.Context, _ string, args []string, _ func(streamjson.Event)) (ChildRunResult, error) {
captured = append([]string(nil), args...)
return ChildRunResult{
Events: []streamjson.Event{
{Type: streamjson.EventRunStart, RunID: "run_1", SessionID: "child_task"},
{Type: streamjson.EventFinal, RunID: "run_1", Text: "done"},
{Type: streamjson.EventRunEnd, RunID: "run_1", Status: "success", ExitCode: &zero},
},
}, nil
},
}
if _, err := executor.Run(context.Background(), TaskParameters{
Name: "skim",
Prompt: "find the thing",
}, TaskRunOptions{
ParentSessionID: parent.SessionID,
ParentModel: "claude-opus-4.1",
}); err != nil {
t.Fatalf("Run(fresh): %v", err)
}
// SETUP: a fresh spawn, not a resume, or this covers the wrong site.
if indexOf(captured, "--resume") >= 0 {
t.Fatalf("SETUP INVALID: the child was resumed rather than freshly spawned: %v", captured)
}
model, ok := argValue(captured, "--model")
if !ok || model != "claude-opus-4.1" {
t.Fatalf("the fresh child was launched with --model %q (present=%t), want the parent's", model, ok)
}
}
+11 -1
View File
@@ -70,6 +70,9 @@ func runBashToolHelper(command string) {
fmt.Println("listening", listener.Addr().String())
server := &http.Server{
Handler: http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
// Printed per request, so a test can prove the session delivers output
// mid-flight: this line cannot exist before the first read has happened.
fmt.Println("served", request.URL.Path)
_, _ = response.Write([]byte("zero-server-ok"))
}),
}
@@ -522,7 +525,14 @@ func TestBashToolTimeoutKillsBackgroundChildren(t *testing.T) {
if result.Status != StatusError {
t.Fatalf("expected timeout error status, got %s: %q", result.Status, result.Output)
}
if elapsed > time.Second {
// The regression this guards is Run blocking until the background child's own
// sleep ends, so the bound only has to sit under that. One second sat almost
// exactly on it: the call legitimately costs a sandbox plan, a fork and exec of
// a shell plus a subshell, the 300ms timeout itself and the post-kill drain,
// and spawn alone was measured here at 720ms to 1.44s with every core busy. A
// merely slow runner therefore failed a correct system. Three seconds is still
// far below the child's lifetime, so nothing is given up.
if elapsed > 3*time.Second {
t.Fatalf("Run blocked %s past the 300ms timeout; background child held the pipes", elapsed)
}
+199 -10
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"io"
"net"
"net/http"
"os"
"path/filepath"
@@ -373,10 +374,6 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
if err != nil {
t.Fatalf("foreground server should return session_id, meta=%#v output=%q", start.Meta, start.Output)
}
addr := parseListeningAddress(start.Output)
if addr == "" {
t.Fatalf("server output did not include listening address: %q", start.Output)
}
t.Cleanup(func() {
writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
@@ -384,7 +381,12 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
})
})
response, err := http.Get("http://" + addr)
addr := waitForListeningAddress(t, writeTool, sessionID, start.Output)
// Bounded, so a server that accepts a connection and never answers fails here
// with its address instead of hanging until the package timeout kills the run.
client := &http.Client{Timeout: 30 * time.Second}
response, err := client.Get("http://" + addr)
if err != nil {
t.Fatalf("foreground exec server was not reachable at %s: %v; output=%q", addr, err, start.Output)
}
@@ -396,6 +398,153 @@ func TestExecCommandForegroundServerReturnsSessionAndServesHTTP(t *testing.T) {
if string(bytes) != "zero-server-ok" {
t.Fatalf("server response = %q", string(bytes))
}
// AND THE SESSION DELIVERS OUTPUT MID-FLIGHT, PINNED DETERMINISTICALLY.
//
// The helper prints this line only when it serves a request, so it cannot have
// been in the first read: the request had not happened yet. It can only arrive
// through a poll of a process that is still running, which is the delivery the
// address parse used to cover by accident and only on a machine slow enough to
// miss the first read. A regression where a live session buffers everything
// until it exits leaves the rest of this test green and fails here.
deadline := time.Now().Add(60 * time.Second)
var served strings.Builder
for !strings.Contains(served.String(), "served") && time.Now().Before(deadline) {
poll := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "",
"yield_time_ms": 250,
})
served.WriteString(poll.Output)
if poll.Status != StatusOK {
t.Fatalf("the exec session was gone before it reported serving the request:\n%s", served.String())
}
}
if !strings.Contains(served.String(), "served") {
t.Fatalf("a still-running session never delivered the line the server printed while serving:\n%s", served.String())
}
// AND A READ DRAINS WHAT IT RETURNS.
//
// One more poll, because "the line showed up" is satisfied just as well by
// output that is re-delivered forever. drain copies and then nils the buffer,
// so this read carries nothing; if it stopped clearing, every session in
// production would repeat its whole output on every poll while every other
// assertion here stayed green. The manager-level test that asserts the same
// count is skipped on Windows, so this is the only place guarding it there.
again := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "",
"yield_time_ms": 250,
})
if strings.Contains(again.Output, "served") {
t.Errorf("a second read returned output the first had already delivered, so reads are no longer draining:\n%s", again.Output)
}
}
// waitForListeningAddress polls the exec session until the helper says where it
// is listening.
//
// ONE YIELD IS AN ALLOWANCE, NOT A GUARANTEE, AND THIS TEST TREATED IT AS ONE.
//
// The helper is this test binary re-executed. The first read used to be its only
// chance: 500ms to be spawned through the process manager, reach net.Listen and
// have its line drained back. A Windows CI runner under load does not get there
// in time, so the read returned "Command is still running." with no address and
// the test failed as though the server were broken. It failed that way on
// unrelated contributor PRs, which is worse than a slow test: it reports somebody
// else's change as the fault.
//
// Polling is what the banner in that very output tells the caller to do, so it is
// what this does, up to a bound far above any plausible start.
//
// AND IT STILL FAILS FAST WHEN THE FAILURE IS REAL. A session that has gone away
// answers with StatusError, and a process that exited before listening answers
// with an exit_code line. Either is a genuine defect, so neither is worth waiting
// out: they end the loop immediately with what the session actually said.
//
// Each read DRAINS: managedProcess.collect returns only the bytes produced since
// the previous call, so these chunks are incremental and none of them is the whole
// output. Each is parsed on its own, which is sound because the helper writes its
// address with a single Println and a write that small does not tear. The whole
// transcript is kept anyway, so a failure shows every chunk rather than the last.
func waitForListeningAddress(t *testing.T, writeTool Tool, sessionID int, first string) string {
t.Helper()
if addr := validListeningAddress(first); addr != "" {
return addr
}
// AND WHEN IT DOES NOT, THIS TEST STOPS COVERING THE FIRST READ.
//
// That parse used to be the only assertion in the repo that a still-running
// session's first read carries the child's stdout at all: every other test
// either uses a tiny yield against a helper that prints later, or uses a large
// one against a helper that exits, which is the collect-on-exit branch. The
// manager-level equivalent is skipped on Windows because it shells out to
// /bin/sh. The coverage cannot be pinned deterministically without
// reintroducing the race, since collect() blocks the whole window either way,
// so it is given up deliberately here and paid back after the request below,
// where a line that cannot exist before the first read proves the poll path.
transcript := []string{first}
// Sixty seconds, not thirty. Starting this helper was measured here at 0.74s
// to 4.79s with every core saturated, on a box faster than a CI runner, and
// windows-latest additionally scans a freshly linked test binary on its first
// execution. A deadline of the same order as the latency it tolerates is the
// mistake that produced the 500ms budget; the point is a bound, and every
// genuine failure below leaves before reaching it anyway.
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
poll := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "",
"yield_time_ms": 250,
})
transcript = append(transcript, poll.Output)
if addr := validListeningAddress(poll.Output); addr != "" {
return addr
}
// FAST-FAIL, BECAUSE THE LOOP HAS NO PACING OF ITS OWN. All the spacing
// comes from collect() waiting out its yield inside a LIVE session; every
// error return happens in microseconds. Continue removes the process the
// moment it observes the exit, so without these two the loop free-runs on
// a dead id: measured at 3028 polls in 5 seconds, which on a 4-vCPU runner
// would peg a core for the whole deadline and hand t.Fatalf hundreds of
// kilobytes of the same sentence. The exit result is also delivered
// exactly once, so folding it into the blob and continuing loses the one
// poll that says what went wrong.
if poll.Status != StatusOK {
t.Fatalf("the exec session was gone before the server reported an address:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
}
if strings.Contains(poll.Output, "exit_code:") {
t.Fatalf("the server process exited before it reported an address:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
}
}
t.Fatalf("the server never reported a listening address within the deadline:\n%s", strings.Join(transcript, "\n--- poll ---\n"))
return ""
}
// validListeningAddress is parseListeningAddress plus the check that what it
// found is actually an address.
//
// A read DRAINS, and the formatter wraps each drain in its own body, so a line
// split across two reads arrives as two fragments with banner text wedged between
// them and no concatenation can rejoin it. The formatter also terminates the
// partial chunk, so the fragment looks like a whole line and the bare parse
// returns a truncated address: the test would then fail on an unreachable port
// and point the next reader at the network instead of at the parse. Rejecting
// anything that is not host:port keeps polling instead.
func validListeningAddress(output string) string {
addr := parseListeningAddress(output)
if addr == "" {
return ""
}
host, port, err := net.SplitHostPort(addr)
if err != nil || host == "" {
return ""
}
if number, err := strconv.Atoi(port); err != nil || number <= 0 {
return ""
}
return addr
}
func parseListeningAddress(output string) string {
@@ -427,7 +576,12 @@ func TestExecCommandReapsFinishedUnpolledSession(t *testing.T) {
t.Fatalf("session_id is not numeric: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
// Ten seconds, not two. Inside this the helper must cold-start, run its 250ms
// sleep, exit and pass the reap, and a cold start alone was measured at up to
// 1.44s under load: two seconds was a budget with almost nothing left in it. The
// loop returns as soon as the session is gone, so the bound only costs anything
// when the reap genuinely never happens.
deadline := time.Now().Add(10 * time.Second)
for {
if _, ok := manager.Snapshot(sessionID); !ok {
return
@@ -739,6 +893,13 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
t.Fatalf("session_id is not numeric: %v", err)
}
// SAME SHAPE AS THE SERVER TEST ABOVE, one window narrower.
//
// The shell is started with a 10ms yield, so it has almost certainly not
// reached `read` yet, and the echo then had exactly one fixed 1000ms window to
// travel back through the PTY. Writing early is fine, the terminal buffers it,
// but a loaded runner can spend that whole second still getting the shell up.
// So the echo is polled for rather than demanded within one window.
result := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "hello\n",
@@ -747,11 +908,39 @@ func TestExecCommandTTYSessionAcceptsInputOnLinux(t *testing.T) {
if result.Status != StatusOK {
t.Fatalf("write_stdin status = %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "got:hello") {
t.Fatalf("expected PTY input output, got %q", result.Output)
var transcript strings.Builder
transcript.WriteString(result.Output)
exitCode := result.Meta["exit_code"]
// BOTH THE ECHO AND THE EXIT, because the test asserts both.
//
// collect can return the drained echo on a yield timer before markDone has
// run, so a poll can carry got:hello with no exit_code meta yet. Stopping on
// the echo alone would then assert an exit that had not been observed. The
// condition deliberately keeps the echo term as well: gating on exit_code
// alone would stop polling before a slow shell start delivers the echo, which
// is the race this whole change exists to remove.
deadline := time.Now().Add(30 * time.Second)
for (!strings.Contains(transcript.String(), "got:hello") || exitCode == "") && time.Now().Before(deadline) {
poll := writeTool.Run(context.Background(), map[string]any{
"session_id": sessionID,
"chars": "",
"yield_time_ms": 250,
})
transcript.WriteString(poll.Output)
if code := poll.Meta["exit_code"]; code != "" {
exitCode = code
}
if poll.Status != StatusOK {
// The session is gone. Either the echo already arrived, which the loop
// condition will see, or it never will and the assertions below say so.
break
}
}
if result.Meta["exit_code"] != "0" {
t.Fatalf("expected exited session, got meta=%#v output=%q", result.Meta, result.Output)
if !strings.Contains(transcript.String(), "got:hello") {
t.Fatalf("expected PTY input output, got %q", transcript.String())
}
if exitCode != "0" {
t.Fatalf("expected exited session, got exit_code=%q output=%q", exitCode, transcript.String())
}
}
+19 -2
View File
@@ -548,11 +548,11 @@ func browserActionArgs(args map[string]any) (string, []string, error) {
if err != nil {
return "", nil, err
}
command = strings.ToLower(strings.TrimSpace(command))
spec, ok := browserActionSpecs[command]
command, ok := NormalizedBrowserActionCommand(command)
if !ok {
return "", nil, fmt.Errorf("command must be one of: %s", strings.Join(browserActionCommandNames(), ", "))
}
spec := browserActionSpecs[command]
values, err := stringArrayArg(args, "args")
if err != nil {
return "", nil, err
@@ -570,6 +570,16 @@ func browserActionArgs(args map[string]any) (string, []string, error) {
return command, commandArgs, nil
}
// NormalizedBrowserActionCommand returns the exact action that browser_action
// will execute after normalizing its command argument. ACP uses it only for a
// permission title, so an unrecognised command is never reflected as text that
// the tool would reject.
func NormalizedBrowserActionCommand(command string) (string, bool) {
command = strings.ToLower(strings.TrimSpace(command))
_, ok := browserActionSpecs[command]
return command, ok
}
func browserActionCommandArgs(command string, spec browserActionSpec, values []string) ([]string, error) {
switch command {
case "connect":
@@ -657,6 +667,13 @@ func browserOpenURLArg(args map[string]any) (string, error) {
if err != nil {
return "", err
}
return NormalizeBrowserOpenURL(rawURL)
}
// NormalizeBrowserOpenURL applies the browser_open URL rules before execution.
// Keeping this exported within the internal package lets permission displays
// describe the same destination the browser helper will open.
func NormalizeBrowserOpenURL(rawURL string) (string, error) {
normalized := strings.TrimSpace(rawURL)
if !strings.Contains(normalized, "://") {
normalized = "https://" + normalized