Compare commits

..
Author SHA1 Message Date
anandh8x c2caa9ab93 Preserve patch context bytes
Track source offsets for structured-patch context lines and reuse the exact matched source text during replacement. This keeps tolerant matching from changing indentation or mixed line endings outside the requested edit.
2026-08-19 20:15:05 +05:30
anandh8x b9894f0eca Align structured patch failure semantics
Use prevalidated sequential application with explicit partial-success errors instead of unsafe multi-file rollback. Retain atomic per-file publication, no-overwrite creates, unsupported-hardlink fallback, and conservative tracker invalidation.
2026-08-19 17:56:05 +05:30
anandh8x a6892f2b38 Bind patch rollback to committed versions
Capture file identity and content before publication, quarantine rollback targets atomically, and refuse destructive undo when a path was replaced or modified in place. Add deterministic coverage for publication and rollback races.
2026-08-19 16:04:02 +05:30
anandh8x de2f071dd2 Harden patch transactions and review edges
Publish new patch targets without overwriting raced destinations, journal only completed mutations, and create replacement temps beside their targets. Reject pre-plan diagnostics, cover tool-search transcript suppression, and leave sufficient cleanup time in the Windows process fixture.
2026-08-19 15:28:55 +05:30
anandh8x 1439b25a34 Address final review correctness gaps
Bound narrow waiting labels, reject malformed plan records, preserve missing final newlines, reject out-of-order EOF hunks, clear stale tracking after failed rollback, and keep deferred tool loading aligned with model visibility.
2026-08-19 14:03:18 +05:30
anandh8x 00baeac3ca Resolve plan test seam after rebase
Drop the obsolete sticky-plan height helper while retaining the PR status test seams brought forward by the UI changes.
2026-08-19 13:28:42 +05:30
anandh8x b6cd6c1bab Fix structured patch safety boundaries
Preserve read-before-write credit only when the model already knew the full source, reject ambiguous hunk matches, and retain CRLF line endings. Add regression coverage for unsafe overwrites, positive tracking cases, duplicate matches, and Windows-style files.
2026-08-19 13:24:09 +05:30
anandh8x 99a8b9f588 Fix Windows CI process checks 2026-08-19 13:24:09 +05:30
anandh8x 889f109630 Reuse rooted patch temp allocator 2026-08-19 13:24:09 +05:30
anandh8x 8b4605b121 Retry structured patch temp allocation 2026-08-19 13:24:09 +05:30
anandh8x 09c78b527f Use unique structured patch temp files 2026-08-19 13:24:09 +05:30
anandh8x d3d7efdcf8 Bind structured patches to workspace root 2026-08-19 13:24:09 +05:30
anandh8x 94458fd0d1 Harden patch and transcript edge cases 2026-08-19 13:24:09 +05:30
anandh8x b94e956893 Fix smoke test regressions 2026-08-19 13:24:09 +05:30
anandh8x 4a289c286a Address UI polish review findings 2026-08-19 13:24:09 +05:30
anandh8x 6c7a5d8f2a Resolve rebased diff renderer conflict 2026-08-19 13:24:09 +05:30
anandh8x c4de188713 Polish transcript and tool feedback 2026-08-19 13:24:09 +05:30
anandh8x 0d6cc7c5a5 Render staged image attachments in terminal 2026-08-19 13:21:17 +05:30
anandh8x 76fbe0421e Normalize ChatGPT model aliases 2026-08-19 13:21:17 +05:30
anandh8x 93034797ad Preserve semantic colors behind overlays 2026-08-19 13:21:17 +05:30
anandh8x 17ed6fd9bb Smooth active run status feedback 2026-08-19 13:21:17 +05:30
anandh8x f7a4158875 Cover resumed edit previews 2026-08-19 13:21:17 +05:30
anandh8x 7ce81090a2 Remove inert transcript interactions 2026-08-19 13:21:17 +05:30
anandh8x 634aa7990e Replace persistent sidebar with run details 2026-08-19 13:21:16 +05:30
anandh8x 5a3a7dd0ca Improve theme-aware code rendering 2026-08-19 13:21:16 +05:30
anandh8x 7b1180ea0a Restore tool previews when resuming sessions 2026-08-19 13:21:16 +05:30
anandh8x eee66ed0e8 Fix structured apply patch handling 2026-08-19 13:21:16 +05:30
anandh8x e41d2e579c Checkpoint UI polish 2026-08-19 13:21:16 +05:30
d66ad71532 chore: remove dead wrapper functions and unused test seams (#905)
* chore: remove dead wrapper functions and unused test seams

These five functions are unreachable from any cmd/* main even when tests are included (deadcode -test ./...), so they are genuinely dead on every platform:

- TraceEventKeys: one-line alias of ParseTraceEventKeys, no callers
- ValidateToolCapabilities: one-line alias of ValidateCapabilities, no callers
- GetLocalDiffStats and WatchPRState: test seams in internal/tui/export_test.go that no test calls
- ScanSSEData: non-context SSE scanner in internal/providers/providerio, fully superseded by ScanSSEDataWithContext

The five remaining -test findings are //go:build !linux stubs (landlock/seccomp/linux_helper) that are live on Linux, so they are left in place.

Co-Authored-By: cairn-code <[email protected]>

* Bump Go to 1.26.6 so govulncheck and Windows smoke pass.

The 1.26.5 standard library still reports GO-2026-6218 and the Punycode
label check that fail required CI; 1.26.6 matches main and clears them.

---------

Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-18 21:44:13 +08:00
d065467cd3 feat(zerogit): auto-create a conventional branch before push/pr on default branch (#671)
* feat(zerogit): auto-create a conventional branch before push/pr on default branch

zero changes push/pr refused to push straight to the default branch but
never offered an alternative, unlike other agent tooling that names and
checks out a feature branch automatically. Add CreateBranch, IsDefaultBranch,
CurrentGitUser, and branch-name slugify/build helpers to zerogit, and wire
push/pr to auto-create a "<user>/<slug>" branch (slug from an LLM when a
provider is configured, otherwise a deterministic fallback from the diff)
whenever the current branch is the default one and --yes/--dry-run weren't
passed.

* fix(zerogit): normalize LLM branch slugs, bound remote lookup, handle existing branches

Address CodeRabbit's review on the auto-branch-naming PR:

- generateAutoBranchSlug now takes the first non-empty, quote-trimmed
  line of the model's response before slugifying, instead of folding
  any preamble or wrapping quotes into the branch name.
- isDefaultBranch's ls-remote lookup is now bounded by a 5s timeout so
  a slow or unreachable remote can't stall push/pr; falls back to the
  local main/master heuristic as before.
- CreateBranch checks whether the target branch already exists locally
  and checks it out instead of failing checkout -b on the collision.

Also add regression coverage: CurrentGitUser's OS-username fallback
tier, CreateBranch's existing-branch and generic checkout-failure
paths, a CLI-level test for changes pr (previously untested) mirroring
the existing changes push coverage, and a messy-LLM-response case for
the slug normalization fix.

* fix(zerogit,cli): make auto-branching collision-safe, remote-aware, fail-closed, and opt-in for LLM naming

- CreateBranch never checks out an existing branch on a name collision: it
  picks a unique suffixed name off the current HEAD (name-2 .. name-9) and
  fails visibly when the namespace is exhausted, so a stale branch with
  unrelated history can no longer be published while the new commit stays
  behind on the default branch.
- ensureFeatureBranch resolves the remote once (explicit --remote, then the
  original branch's configured upstream, then origin) via IsDefaultBranch,
  which now reports the resolved remote, and push/pr thread that remote into
  Push, so a freshly created branch without tracking configuration no longer
  falls back to origin in fork setups.
- The default-branch check fails closed: main and master count as default
  with no network, an unreachable remote falls back to the local
  refs/remotes/<remote>/HEAD record, and when neither answers the push is
  refused with guidance instead of silently dropping the confirmation guard
  for repositories whose default is trunk or develop.
- LLM branch naming is now opt-in via --auto on push/pr, matching commit
  --auto: a configured provider alone no longer causes the change diff to be
  uploaded during ordinary git-only pushes.
- After the ordinary commit-then-push sequence the tree is clean, so the
  fallback name comes from the HEAD commit subject instead of the constant
  user/changes.

* style(zerogit): align fake-runner result comments to gofmt output

* test(zerogit): cover Push's fail-closed path when the default branch is unknown

* fix(zerogit): keep a stale cached remote HEAD from clearing the push guard

When the live ls-remote default-branch lookup fails, isDefaultBranch fell
back to the local refs/remotes/<remote>/HEAD cache and returned
branch == cachedName. That cache is only a hint: if the server renamed its
default (main -> trunk) the record still names main, so checking whether
pushing trunk is safe returned false and the newly protected branch was
pushed without --yes.

Trust the cache only to block a push (a positive match proves the branch is
the recorded default); on a mismatch fall through to the existing
fail-closed unknown-default error instead of treating the branch as
unprotected.

* fix(cli): honor --diff-bytes when inspecting the diff for auto-branch naming

changes push/pr accept and document --diff-bytes, but ensureFeatureBranch
inspected the working tree with only Cwd set. With --auto the resulting
unbounded diff was embedded in the branch-name request to the provider, so a
user who capped the diff to limit how much proprietary source is uploaded
for naming still sent the complete diff.

Thread options.maxDiffBytes through ensureFeatureBranch into InspectOptions,
matching how the commit path already bounds the diff.

* fix(cli): refuse an auto-branch push when HEAD has nothing to publish

The auto-branch path decided to branch solely from working-tree status and
never checked that HEAD was ahead of the default branch. On a clean,
up-to-date default branch it would create and push user/<subject> at the
exact default tip; with only uncommitted edits it named a branch from those
edits but pushed the unchanged HEAD, leaving the edits local, and changes pr
then abandoned the empty branch before the host rejected the comparison.

Add zerogit.CommitsAhead and consult it before branching: when HEAD is
provably not ahead of <remote>/<default>, report "no changes to publish"
instead of creating the branch. An indeterminate count (for example a
remote-tracking ref that was never fetched) is treated as "cannot tell" so a
legitimate first push is not blocked.

* fix(cli): recover the branch slug from preamble or fenced LLM replies

The slug helper returned the first non-empty line verbatim, so a reply like
"Here is a suggested branch name:\nadd-login-page" produced
user/here-is-a-suggested-branch-name, and a reply wrapped in a code fence
slugified the ``` line to nothing and silently fell back to the local name.

Drop Markdown code-fence lines and prefer a line that already reads as a
kebab-case slug, falling back to the first quoted/plain line only when no
slug-shaped line exists. Cover preamble and fenced responses with tests.

* fix(zerogit): harden auto-branch remote handling

Three review findings:

- isDefaultBranch passed the remote to ls-remote before the HEAD
  positional with no --, so a remote value shaped like an option
  (--upload-pack=/bin/echo) was parsed as one. Terminate options with --
  and cover a dash-prefixed remote.

- An unborn remote (freshly created, zero refs) answered ls-remote with
  empty output, fell through to the fail-closed unknown-default error,
  and made the very first feature-branch push a --yes dead end that
  git remote set-head --auto cannot repair. ls-remote succeeding with no
  refs now counts as proof there is no protected default; main/master
  stay guarded by the name heuristic and every failure path stays
  fail-closed.

- CreateBranch probed only local refs/heads for collisions, so a branch
  existing only on the target remote (an old merged-PR branch) was
  silently fast-forwarded by the later push -u. The target remote's
  heads are now probed once (bounded, with --) and count as taken;
  an unreachable remote fails visibly before anything is created.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(zerogit): close branch-naming and push safety gaps from review

Require a confirmed unborn HEAD, not just an empty ls-remote --symref,
before granting the first-push exception, since a dangling remote
HEAD looked the same. Resolve branch naming and ahead-count checks
against the remote branch instead of a working-tree snapshot, which
could describe uncommitted edits a commit-only push would never
publish. Guard concurrent branch creation with a force-with-lease
push. Scope the ls-remote timeout to the default-branch preflight
only, so it no longer caps Push's own unrelated check.

* fix(cli): require a clean tree before auto-branch push/pr

Refuse ensureFeatureBranch when the working tree is dirty or the
ahead count against the remote default cannot be determined, so a
default-branch push cannot leave uncommitted edits behind or publish
an empty comparison under a guessed name.

* fix(zerogit): drop fixed five-second remote lookup timeout

IsDefaultBranch and CreateBranch applied a hard 5s bound on ls-remote,
so ordinary feature-branch pushes and the collision probe failed on slow
but reachable remotes (SSH/VPN) before Push could run. Honor the
caller's context instead; callers that need a bound pass a deadline.

* fix(cli): bypass the ahead-count and diff-base checks on a confirmed-unborn remote

jatmn flagged that the confirmed-unborn-remote exception this PR added to
IsDefaultBranch never actually helps the main/master case it was written
for: when the local branch is named main, isDefaultBranch returns true from
the name heuristic before it ever consults the remote, so
ensureFeatureBranch still unconditionally calls commitsAhead against
<remote>/<branch>. On a genuinely empty remote that ref cannot exist, so
`zero changes push`/`pr` from a local default branch against a brand-new
empty remote still dead-ended with "cannot determine whether HEAD is
ahead of origin/main" - the exact bug this PR set out to fix.

Tracing the flow further, the same problem also hit the diff-base Inspect
call right after: it uses the same nonexistent <remote>/<branch> ref, so
bypassing only commitsAhead would have left the dead end one line later.

ensureFeatureBranch now probes the new zerogit.IsUnbornRemote (ls-remote
--heads on the remote) whenever commitsAhead fails, and only bypasses both
checks when that probe positively confirms the remote has no refs at all.
An unreachable remote, or one that answers with refs, still fails closed
exactly as before - the fail-closed "unknown default branch" behavior is
unchanged and covered by TestEnsureFeatureBranchFailsWhenAheadCountUnknown
and the new TestEnsureFeatureBranchFailsWhenUnbornCheckErrors.

TestEnsureFeatureBranchCreatesBranchOnConfirmedUnbornRemote is the
regression test: confirmed failing (with the exact dead-end error) against
the pre-fix logic, passing after.

* fix(zerogit): resolve main/master against the live remote default first

isDefaultBranch short-circuited on the literal branch names main/master
before ever consulting the remote. A repository whose remote default is
genuinely something else (e.g. trunk) could still have a local main branch
tracking that remote, and the shortcut wrongly treated it as protected
instead of trusting the live symref result. The conventional-name check
now only applies as a last-resort fallback, after both the live ls-remote
symref lookup and the local remotes/<remote>/HEAD cache fail to answer.

* fix(cli): preserve push lease on retry and check the live default ref

Two related gaps in ensureFeatureBranch's auto-branch preflight:

- When an auto-branch push loses a force-with-lease race against a
  concurrent creator, the generated branch is left checked out locally
  with no successful push behind it. A retry took the "already off the
  default branch" early return and dropped RequireNewRemoteBranch,
  letting the next push silently fast-forward whatever the concurrent
  creator published. The lease now stays required unless the branch
  already has a configured upstream (proof a push already landed).

- The ahead-of-default check compared HEAD against the local
  remote-tracking ref, which is only refreshed at clone or the last
  fetch. A stale ref can under-report how far ahead HEAD is, or say
  zero when the remote has actually moved past it, so ensureFeatureBranch
  now refreshes that tracking ref before trusting it, and fails closed
  if the refresh itself cannot be confirmed.

Adds regression coverage for both: a retry after a lease collision keeps
requiring the lease (and an already-published branch does not), and a
stale tracking ref is refreshed before the ahead check runs (with a
fail-closed case when that refresh cannot be confirmed).

* fix(cli): track generated branches, reject PR on unborn remote, classify main on unborn

* fix(zerogit,cli): preserve explicit nonexistence lease on force, propagate marker write errors, check unborn remote before branch switch

* fix(zerogit): preserve non-existence lease on target remote and roll back failed branch marking

Compare target remote against configured upstream remote to retain non-existence lease on target, roll back created branch if marking fails, and allow --yes to bypass PR default-branch preflight.

Refs #671

* fix(cli): address review findings for auto feature branch creation

* fix(cli): refine LLM preamble candidate slug extraction and enforce unborn-remote guard

* fix(cli): restore default branch after auto-branch and resolve --yes upstream

Do not leave auto-branch commits on the protected local default branch,
and resolve the real upstream for the unborn-remote guard under --yes
instead of always probing origin.

* test(cli): assert auto-branch passes capped diff into the provider

Thread --auto and a mock provider through DiffBytes coverage so the
cap is checked on the real LLM request path, not only Inspect.

* fix(cli): block auto-branch on unborn remotes and fix lease/restore

Refuse auto-creating a feature branch when the target remote has no refs yet.
Publishing only user/slug leaves remote HEAD dangling, so the next changes pr
fails closed and a later main push has no PR diff. Require push --yes first.

Keep the nonexistence lease unless branch@{upstream} is exactly the target
remote/generated-branch ref from a successful push -u. Inherited origin/main
from branch.autoSetupMerge=inherit must not drop the lease.

Restore the original default branch against its own upstream tip, not the
push destination, so --remote upstream does not rewrite a fork main that
tracks origin/main.

* fix(cli): recover push -u upstream and filter LLM slug preambles

Push -u can publish a remote branch then fail to write local upstream
config. Treat that as incomplete, recover with set-upstream-to, and probe
the remote before reasserting the nonexistence lease on retry. Also skip
one-word LLM acknowledgements before accepting a slug-shaped line.

Refs #671

* fix(cli): decide the branch-collision lease with one live remote check

Replace the local-state inference chain (upstream config, a
zeroAutoBranch marker, per-remote provenance tracking) that kept
producing new collision-safety edge cases across many review rounds
with a single live remoteHasBranch check against the destination
remote: missing there gets the nonexistence lease, already there gets
a plain push, and git's own fast-forward check is the safety net if
that existing branch isn't actually ours.

Removes MarkGeneratedBranch/IsGeneratedBranch, now dead, along with
the tests that pinned the specific local-state heuristics rather than
the collision-safety property itself.

* fix(cli,zerogit): address CodeRabbit findings on auto-branch naming

Recover trailing-punctuation LLM slugs, notice deterministic fallback,
report dual restore/rollback failures, and skip Push upstream repair on
dry-run. Align comments with fail-closed and DryRun behavior.

Refs #671

* fix(tools): set explicit stdin reader and use resolved binary for format-on-write

Use the binary path resolved by exec.LookPath and set formatter.Stdin
to an empty reader so format-on-write does not hang on inherited stdin
handles on Windows.

Refs #671

* fix(cli,zerogit,tools): address CodeRabbit review findings on PR 671

Remove redundant command validation, eliminate duplicate remote lookup in PR
workflow, harden ref options and branch switching, isolate test Git
environment, and add formatter lookup test.

Refs #671

* fix(cli,zerogit): address new CodeRabbit review findings on PR 671

Harden CreateBranch name validation, remove unreachable workflow error check,
complete doc comment, isolate test Git environment, and clean up unused
appDeps field.

Refs #671

* fix(cli,zerogit,tools): address third round of CodeRabbit review findings on PR 671

Use featureBranchOptions struct for ensureFeatureBranch, bound preflight
contexts, add upstream repair mismatch subtest, and centralize test Git
config isolation.

Refs #671

* fix(cli,zerogit): finish ensureFeatureBranch doc contract and close review gaps

The ensureFeatureBranch doc comment ended mid-sentence and sat on the featureBranchOptions type, so go doc attached function prose to the struct. Move it above the function and state which deps are required non-nil (inspectChanges, commitsAhead, headCommitSubject, currentGitUser, createBranch) versus optional and nil-guarded (refreshTrackingRef, isUnbornRemote, branchUpstreamRef, resetBranchRef, deleteBranch). Also extract the branch slug filename with path.Base because git paths are always slash-separated, gate two tests on the git version their fixtures need (autoSetupMerge=inherit requires 2.35, init --bare -b requires 2.28), and cover the literal "user" CurrentGitUser fallback via a test seam.

Refs #671

Co-Authored-By: cairn-code <[email protected]>

* Protect auto-branch publication and default ref restoration

Co-Authored-By: cairn-code <[email protected]>

* fix(cli): wire expected-old restore through the dependency seam

The P1 fix added expectedOld to resetBranchRef but left the appDeps field and the workflows.go import behind, so the CLI package did not compile. Update the field type to the 5-argument signature and restore the filepath import used by worktree release resolution.

Co-Authored-By: cairn-code <[email protected]>

* fix(cli,zerogit): wire CAS tip and close review test gaps

Addresses the CodeRabbit round on 93e3448e: fillAppDeps now defaults currentBranchTip so the compare-and-swap old value actually reaches ResetBranchRef in production; TestEnsureFeatureBranchRestoresDefaultBranchAfterCreate asserts the CAS value; the real-git auto-branch test asserts local main is restored to its upstream tip; docs explain the deliberate context.Background push, CurrentBranchTip semantics, the RemoteHasBranch tip-equality ownership check, and the Git 2.24 requirement for CommitsAhead; CreateBranch gains name-validation subtests.

Co-Authored-By: cairn-code <[email protected]>

* fix(zerogit): make RemoteHasBranch report existence only, not tip equality

RemoteHasBranch's tip-equality check made ensureFeatureBranch reassert the
nonexistence lease (--force-with-lease=<branch>:) on every push after the
first: a second local commit puts HEAD ahead of the just-published remote
tip, so the check reported false for a branch that plainly exists, and Git
rejected the follow-up push with a stale-info error instead of a normal
fast-forward.

The concurrent-creator race the tip check was meant to guard was already
handled one layer up: ensureFeatureBranch drops to a plain push when the
branch exists and relies on Git's own fast-forward check as the safety net
(see TestRunChangesPushDropsLeaseWhenBranchExistsOnRemote), so a race
survivor with unrelated history is rejected there regardless of tip
equality here.

Add TestRemoteHasBranchReportsTrueForDivergedTip, which fails under the old
tip-equality check and passes now.

Refs #671

* fix(cli,zerogit): preserve the generated branch on a compare-and-swap conflict

After auto-branching, ensureFeatureBranch restores the default branch to its
own upstream tip using the compare-and-swap form of update-ref. When that
refused because the default branch had moved concurrently, the failure was
indistinguishable from an ordinary update-ref write error, so the rollback
path ran and deleted the freshly created feature branch. That branch
exclusively owns the user's commits at that point, so a lost race destroyed
work in order to "recover" from it.

Add ErrCompareAndSwapConflict, returned by ResetBranchRef only when an
expected-old value was supplied (the sole way git refuses is a tip mismatch).
ensureFeatureBranch now recognizes it and returns without rolling back,
reporting that the branch was preserved so the user can reconcile the default
branch by hand.

Extend TestResetBranchRefRefusesConcurrentDefaultAdvance, which already drove
a real concurrent advance, to require the sentinel. Add
TestEnsureFeatureBranchPreservesBranchOnCompareAndSwapConflict, which fails
with "must not delete the generated branch" when the new branch is removed.

Refs #671

* Address review feedback for auto-branch workflow.

Refresh the exact configured upstream ref before branching and restoring, guard direct URL and path remote destinations from tracking-ref assertion, and remove undeclared --end-of-options from CommitsAhead.

Refs #671

* fix(zerogit,cli): resolve reviewer feedback on branch restoration and remote handling

Update CommitsAhead and ResetBranchRef to safely terminate option parsing with --, inspect all linked worktrees before ref updates, resolve advertised branches on direct path and URL remotes, and query git configuration directly for tracking remote and merge branch without splitting on slashes.

Refs #671

* fix(zerogit): invoke rev-list without positional delimiter to treat revs correctly

Remove premature option terminator from rev-list command invocation while validating base ref against option-like prefixes.

Refs #671

* fix(cli,zerogit): resolve restore tip commit and enforce push remote unborn policy

Resolve restore tip commits via ResolveRemoteBranchTip for named and direct URL/path upstreams to eliminate bare branch fallback, enforce unborn destination policy on push remotes before branch creation, and extract resolveBranchPushPlan to isolate decision logic from repository mutation.

Refs #671

* fix(cli,zerogit): address review feedback on destination remote and upstream matching

Forward resolved pushRemote to branchPushPlan, update HasUpstream to support multi-segment remote upstream references, clarify IsNamedRemote comment, and extend CLI argument parsing tests.

Refs #671

* docs(cli): complete ensureFeatureBranch documentation and wire branchUpstreamRemoteAndMerge

Refs #671

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-16 16:01:40 +08:00
Vasanth T 0eab63c908 docs(agents): record the normalize-before-match blocker (#900)
Three separate changes hit this in a fortnight, each written by someone
being careful about exactly the thing that got them.

A transform that removes bytes without leaving a gap is also a
reassembler. Redaction that matches by shape, run before a sanitizer that
strips control bytes, lets a credential split by a NUL or an ESC pass the
patterns as two fragments and be rejoined on the way out: the MCP failure
reason in #835, and the imported-transcript chokepoint in #878. The path
form of the same mistake is comparing where a handle landed against a
value produced by the same resolver the kernel just used, so a redirect
agrees with itself: the ACL guard in #808, where junctions were caught
only by an accident of Go's mode bits and directory symlinks were not
caught at all.

The unsplit value passing is what makes it survive review, so the note
says the test needs a split case.
2026-08-15 20:32:33 +08:00
64a783b08c fix(dictation): redact API keys from streaming transcriber errors (#852)
* fix(dictation): redact API keys from streaming transcriber errors

Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().

Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.

Co-authored-by: euxaristia <[email protected]>

* fixup(dictation): address review: redact session/write errors, move redaction tests

- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
  TestDeepgramStreamTranscribeErrorRedaction next to their source files.

Refs #710

* fixup(dictation): sync Deepgram redaction test on CloseStream before close

Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.

Refs #710

* fix(dictation): preserve context.Canceled through streaming error redaction

Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(dictation): close remaining cancellation and redaction gaps

Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.

* test(dictation): cover dial and startup cancel identity

Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.

* fix(dictation): prefer cancel over a racing OpenAI realtime error

Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.

Refs Gitlawb/zero#710

* fix(dictation): prevent auto-submit on cancelled realtime race

handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.

Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.

* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber

* fix(dictation): add custom key header redaction and single-pass redaction tests

* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test

* fix(dictation): initialize non-zero session ID and filter stale session 0 messages

Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.

Refs #710

* fix(dictation): redact API keys from streaming transcriber errors

Streaming dictation (Deepgram, OpenAI Realtime) injected API keys into
websocket URLs/headers and echoed unredacted transport errors and
server-side error payloads. Wrap those error endpoints with
providerio.Redact and switch the connect-error format specifier from %w
to %s so the raw error cannot leak via errors.Unwrap().

Adds regression tests asserting the API key is not present in the
returned stream error for both transcribers.

Co-authored-by: euxaristia <[email protected]>

* fixup(dictation): address review: redact session/write errors, move redaction tests

- Redact API key in OpenAI Realtime session configuration error.
- Return redacted OpenAI Realtime write error from writeErrCh instead of discarding.
- Move TestOpenAIRealtimeStreamTranscribeErrorRedaction and
  TestDeepgramStreamTranscribeErrorRedaction next to their source files.

Refs #710

* fixup(dictation): sync Deepgram redaction test on CloseStream before close

Address CodeRabbit nitpick: the server now drains the client's audio frames and
waits for CloseStream before rejecting the connection with an API-key-bearing
policy-violation close reason. This ensures StreamTranscribe observes the close
reason (and redacts it) instead of failing earlier on a write error.

Refs #710

* fix(dictation): preserve context.Canceled through streaming error redaction

Formatting the read error with %s dropped the unwrap chain, so an Esc
abort of a Deepgram or OpenAI Realtime dictation no longer matched
errors.Is(msg.err, context.Canceled) in the TUI and produced a spurious
error toast on top of the cancellation notice. Return the cancellation
sentinel itself before redacting — it carries no key — and keep the
redacted flat string for genuine failures. Adds regression tests that
cancel a live stream and assert the sentinel survives.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(dictation): close remaining cancellation and redaction gaps

Key cancellation detection on ctx.Err() instead of
errors.Is(err, context.Canceled), which was racing against
writer-goroutine transport errors that could overwrite the error
value right before the check. Applied consistently across both
transcriber implementations. Fix goroutine leaks and a possible hang
in the cancellation regression tests.

* test(dictation): cover dial and startup cancel identity

Add regression tests for Esc-abort during WebSocket dial and right after
accept (session update / first write) so those redaction sites keep
returning context.Canceled for the TUI errors.Is check.

* fix(dictation): prefer cancel over a racing OpenAI realtime error

Esc can cancel after conn.Read already has an error frame. Check ctx.Err()
after partial callbacks and on the realtimeError path so the stream still
returns context.Canceled instead of a redacted failure notice.

Refs Gitlawb/zero#710

* fix(dictation): prevent auto-submit on cancelled realtime race

handleDictationTranscribed suppressed context.Canceled but then fell
through to the msg.submit branch. A delta -> Esc -> already-buffered
realtime error race returns a nonempty transcript alongside
context.Canceled, so with stt.autoSubmit on, Esc could submit the
composer's restored pre-existing text instead of doing nothing.

Treat a canceled result as terminal before the auto-submit path is
reached, and add a TUI-level test covering the race.

* test(dictation): exercise writeErrCh cancellation branch in realtime transcriber

* fix(dictation): add custom key header redaction and single-pass redaction tests

* fix(dictation): track dictation sessionID to drop stale completions after Esc and synchronize write cancellation test

* fix(dictation): initialize non-zero session ID and filter stale session 0 messages

Ensure the first dictation session in a process gets stale-message protection by initializing sessionID to 1 in newDictationController and removing msg.sessionID != 0 exemptions in handleDictationTranscribed and handleDictationPartial. Clean up unreachable redaction guards in providerio.

Refs #710

* fix(dictation): drop providerio widen; pin session and redaction tests

Revert the Token/X-Api-Key providerio.Redact expansion (belongs in its own PR with coverage). Make Deepgram single-pass, cancel-race, and live-session partial tests pin the branches they name, and restore the ST1005 nolint on the user-facing Deepgram error string.

* fix(dictation): ignore stale batch startup by session ID

Gate dictationStartedMsg on session ID so a late Start result after cancel cannot reset a newer recording. Rename and comment cleanups from review; race suite recorded on the PR body.

* fix(dictation): exit writers on cancel and pin dial/write redaction paths

Select on ctx.Done while waiting for audio chunks so cancelled streaming
sessions do not leave writer goroutines blocked on an open idle channel.
Add non-cancellation redaction tests for Deepgram dial failure and OpenAI
Realtime dial, session-configuration, and async writer failures.

Refs #852

* fix(dictation): close the realtime socket on writer failure so a silent server cannot strand the read loop

CodeRabbit review 4885680006 flagged that the OpenAI Realtime writer goroutine pushed write errors to writeErrCh and returned without closing the connection, so conn.Read stays parked forever when the peer goes silent after session setup and the error is never drained. Close the socket on both the append and commit write failure paths (CloseNow, since the close handshake itself would block on a silent peer), and add a silent-server regression test.

Refs #852

Co-Authored-By: cairn-code <[email protected]>

* test(dictation): cancel the silent-server writer failure test during teardown

CodeRabbit review 4909044011 identified that the silent-server regression test used context.Background(), allowing its goroutine to remain blocked in conn.Read after a timeout. Give the test a cancellable context and defer cancellation so teardown always releases the blocked read.

Refs #852

Co-Authored-By: cairn-code <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: euxaristia <[email protected]>
Co-authored-by: cairn-code <[email protected]>
2026-08-15 20:31:52 +08:00
9c292092a2 fix(daemon): clean up child after startup timeout (#774)
* fix(daemon): clean up child after startup timeout

* fix(daemon): terminate startup process tree

* test(background): accept reaped-later zombie children

* fix(background): retain configured process group target

* fix(background): address termination review feedback

* fix(daemon): use launch-time group identity, fix zombie/reap classification

Addresses jatmn's three #774 review findings:

- TerminateCommand rediscovered the process group via
  execution.TerminateProcessTree's Getpgid(pid) lookup. On Darwin,
  that lookup can return ESRCH once an unreaped group leader has
  already exited, even though live descendants remain in the group
  it configured at launch — silently falling back to signalling only
  the (already dead) leader PID and leaving descendants running (the
  required macOS smoke job's failure). Add
  execution.TerminateProcessGroup, which signals the negative PID
  directly using the launch-time Setpgid invariant instead of
  rediscovering it, and route TerminateCommand through it.

- signalTargetRunning's zombie check matched the individual-PID
  fallback target (a process that is not its own group leader)
  against the process table by PGID. That target's actual PGID
  differs from its own PID by definition, so no row ever matched,
  unknown resulted every time, and a zombie individual-PID target
  was always conservatively reported as still running. Branch on the
  sign of the target: group members by PGID, individual PIDs by PID.

- TerminateCommand returned the termination error even when the
  subsequent reap succeeded — a successful cmd.Wait is authoritative
  proof the leader is gone and collected, regardless of how the kill
  attempt itself went (e.g. taskkill /T racing an already-exited PID
  on Windows). Classify a successful reap as success.

All three regression tests are verified to fail against the pre-fix
code (confirmed by temporarily reverting each fix and rerunning).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(daemon): harden command termination inputs

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-abb8-73de-9489-51e9e6514c6b

Co-authored-by: Pierre Bruno <[email protected]>

* fix(background): preserve process tree failures

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* docs(daemon): clarify boundary-probe and Setsid-guard comments per PR #774 review

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-08-15 20:31:10 +08:00
KRATOSandClaude Opus 4.8 060d38ce71 feat(security): path-jail primitive, credential-store locking, worktree git hardening (#891)
Split 1/3 of #829, carved out as the independently-reversible hardening layer
with no dependency on the orchestration work that stacks on top of it.

- internal/pathjail: os.Root handle-relative path confinement — ancestor
  traversal is confined, the final component is checked for reparse points
  (including a name written with a trailing separator), unprivileged Windows
  junctions are covered, temp-name fragments are rejected before they can
  escape the directory, whitespace-bearing paths are confined as spelled, and
  temp files stay unpredictable and exclusive.
- internal/credstore: file locking around the credential read-modify-write —
  exclusive for writers, shared for readers, so a reader cannot race a publish.
- internal/worktrees: git invocation hardening.

pathjail has no importers in this PR by design; internal/memory and
internal/specialist adopt it in the stacked PRs (2/3 and 3/3).


Origin-Session: local-de382f | Claude Code | 4 prompts
Origin-Snapshot: 52db9b74dad5

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-15 20:24:08 +08:00
Anandan 04fd3c01a7 Improve diff viewer rendering (#902)
* Improve diff viewer rendering

* Address diff viewer review findings

* Fix diff viewer interaction regressions

* Preserve header-like diff content

* Fix diff hunk parsing and image vulnerability

* Strengthen diff blank-context coverage

* Fix diff rendering edge cases
2026-08-15 17:54:38 +08:00
Vasanth T dc15e82298 fix: bump Go to 1.26.6 for stdlib vulnerability fixes (#903) 2026-08-14 18:58:16 +05:30
150 changed files with 14495 additions and 2487 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/Gitlawb/zero
go 1.26.5
go 1.26.6
require (
charm.land/bubbles/v2 v2.1.1
@@ -16,7 +16,7 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.44.0
golang.org/x/image v0.45.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
+2
View File
@@ -66,6 +66,8 @@ golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZC
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+7 -3
View File
@@ -3087,13 +3087,15 @@ func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMod
// Deferral may activate only when tool_search is actually runnable; otherwise
// the loop would hide deferred tools behind a loader the dispatch gate rejects
// — an inescapable dead-end. "Runnable" mirrors executeToolCall's gate:
// registered, not in DisabledTools, and advertised in the current permission
// mode (e.g. not spec-draft, where tool_search is not advertised). The
// registered, model-visible, not in DisabledTools, and advertised in the
// current permission mode (e.g. not spec-draft, where tool_search is not
// advertised). The
// EnabledTools allowlist is intentionally NOT checked here — tool_search is
// exempt from the allowlist at dispatch, so an allowlist that omits it must
// not disable deferral.
loader, loaderFound := registry.Get(tools.ToolSearchToolName)
loaderUsable := loaderFound &&
tools.ModelVisible(loader) &&
!containsToolName(options.DisabledTools, tools.ToolSearchToolName) &&
ToolAdvertised(loader, permissionMode)
@@ -3208,7 +3210,9 @@ func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
return tools.ModelVisible(tool) &&
ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) &&
ToolAdvertised(tool, permissionMode)
}
func ToolAllowedByFilters(name string, enabledTools []string, disabledTools []string) bool {
+18
View File
@@ -67,3 +67,21 @@ func TestEagerToolSchemaTokenBudget(t *testing.T) {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — trim a schema, drop a core tool, or raise the ceiling deliberately (deferring will NOT help: this test disables deferral)", got, maxEagerToolSchemaTokens)
}
}
func TestAgentAdvertisesPatchInsteadOfAmbiguousStringReplacement(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
exposed, _ := partitionTools(registry, PermissionModeAsk, Options{}, map[string]bool{})
names := make(map[string]bool, len(exposed))
for _, definition := range exposed {
names[definition.Name] = true
}
if !names["apply_patch"] {
t.Fatal("agent must retain apply_patch for existing-file changes")
}
if names["edit_file"] {
t.Fatal("agent must not receive the ambiguous string-replacement tool")
}
}
+7 -6
View File
@@ -52,7 +52,7 @@ work.
- Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, read_minified_file, list_directory, glob, grep,
write_file, edit_file, apply_patch - over shelling out to
write_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
@@ -61,10 +61,11 @@ work.
- Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- For edits to existing files, prefer apply_patch with minimal, targeted hunks
and enough unchanged context to identify the intended location. Match the
existing indentation, imports, and idioms. Match the file's comment density:
do not add explanatory comments unless the user asks or the code is already
comment-dense.
- Solve the problem as posed, not a more general version of it. Add no
speculative abstraction, configurability, or handling for cases that cannot
occur, and nothing the user did not ask for. A small diff can still be
@@ -104,7 +105,7 @@ work.
you need to clean up a running foreground command yourself, use write_stdin.
- write_stdin's session_id is only ever an id returned by a still-running
exec_command; never guess or probe ids. If you have no such session, start one
with exec_command, or use write_file/edit_file/apply_patch for file changes.
with exec_command, or use write_file/apply_patch for file changes.
- write_stdin with empty input polls an existing exec_command session, and
`\u0003` interrupts it. Sending other stdin bytes may require approval because
it can drive the running process beyond the original command. Non-tty sessions
+2 -2
View File
@@ -60,14 +60,14 @@ const openAIPromptAddendum = `<model_guidance>
longer answers, fenced code blocks for code, and ` + "`inline code`" + ` for paths,
commands, and symbols.
- Strongly prefer the native file tools (read_file, list_directory, grep, glob,
write_file, edit_file, apply_patch) over shelling out to cat/sed/awk/python for
write_file, apply_patch) over shelling out to cat/sed/awk/python for
file work. Make one tool call per file; do not batch file writes into a script.
- Persist until the task is fully handled this turn: gather context, implement,
run the validators, and report — do not stop at a partial result.
</model_guidance>`
const geminiPromptAddendum = `<model_guidance>
- Prefer the dedicated tools (read_file, grep, glob, edit_file, apply_patch) over
- Prefer the dedicated tools (read_file, grep, glob, apply_patch) over
equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed.
+1 -1
View File
@@ -36,7 +36,7 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"inspect the target file",
"plan then act",
"choose the narrowest tool",
"prefer edit_file or apply_patch",
"for edits to existing files, prefer apply_patch",
"verify after edits",
"honor the active permission mode",
"avoid broad refactors",
-4
View File
@@ -29,10 +29,6 @@ func ParseTraceEventKeys(stdout string) []string {
return keys
}
func TraceEventKeys(stdout string) []string {
return ParseTraceEventKeys(stdout)
}
func MissingTraceEvents(required []string, stdout string) []string {
seen := map[string]bool{}
for _, key := range ParseTraceEventKeys(stdout) {
+32 -1
View File
@@ -453,7 +453,12 @@ func (manager *Manager) normalizeLoadedTask(fileTaskID string, task Task) (Task,
}
outputFile, err := manager.outputFile(task.ID, task.OutputFile)
if err != nil {
return Task{}, false, err
var migrated bool
outputFile, migrated = manager.migrateLegacyOutputFile(task.ID, task.OutputFile)
if !migrated {
return Task{}, false, err
}
changed = true
}
if outputFile != task.OutputFile {
changed = true
@@ -562,6 +567,32 @@ func (manager *Manager) outputFile(taskID string, requested string) (string, err
return path, nil
}
// migrateLegacyOutputFile recovers task metadata written under a previous home
// or XDG data root. It accepts only the canonical per-task output
// name in the historic zero/background layout, and only when the corresponding
// regular file already exists inside the active manager root.
func (manager *Manager) migrateLegacyOutputFile(taskID string, requested string) (string, bool) {
requested = strings.TrimSpace(requested)
if !filepath.IsAbs(requested) {
return "", false
}
requested = filepath.Clean(requested)
expectedName := taskID + ".ndjson"
if filepath.Base(requested) != expectedName {
return "", false
}
legacyRoot := filepath.Dir(requested)
if filepath.Base(legacyRoot) != "background" || filepath.Base(filepath.Dir(legacyRoot)) != "zero" {
return "", false
}
canonical := filepath.Join(manager.rootDir, expectedName)
info, err := os.Lstat(canonical)
if err != nil || !info.Mode().IsRegular() {
return "", false
}
return canonical, true
}
func validTaskID(taskID string) bool {
return taskIDPattern.MatchString(strings.TrimSpace(taskID))
}
+83
View File
@@ -1,6 +1,7 @@
package background
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -191,6 +192,88 @@ func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
}
}
func TestManagerMigratesLegacyOutputPathWhenCanonicalOutputExists(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
canonicalOutput := filepath.Join(root, taskID+".ndjson")
if err := os.WriteFile(canonicalOutput, []byte("legacy output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
legacyOutput := filepath.Join(t.TempDir(), "zero", "background", taskID+".ndjson")
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: legacyOutput,
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal legacy metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write legacy metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
task, ok := manager.Get(taskID)
if !ok {
t.Fatal("manager did not load migrated task")
}
if task.OutputFile != canonicalOutput {
t.Fatalf("migrated output file = %q, want %q", task.OutputFile, canonicalOutput)
}
if warnings := manager.LoadWarnings(); len(warnings) != 0 {
t.Fatalf("migration produced load warnings: %#v", warnings)
}
persisted, err := os.ReadFile(filepath.Join(root, taskID+".json"))
if err != nil {
t.Fatalf("read migrated metadata: %v", err)
}
var saved Task
if err := json.Unmarshal(persisted, &saved); err != nil {
t.Fatalf("decode migrated metadata: %v", err)
}
if saved.OutputFile != canonicalOutput {
t.Fatalf("persisted output file = %q, want %q", saved.OutputFile, canonicalOutput)
}
}
func TestManagerRejectsUnrelatedAbsoluteOutputPathDuringMigration(t *testing.T) {
root := t.TempDir()
const taskID = "legacy_task"
if err := os.WriteFile(filepath.Join(root, taskID+".ndjson"), []byte("output\n"), 0o600); err != nil {
t.Fatalf("write canonical output: %v", err)
}
metadata, err := json.Marshal(Task{
ID: taskID,
Type: "specialist",
Status: StatusCompleted,
OutputFile: filepath.Join(t.TempDir(), taskID+".ndjson"),
StartedAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(filepath.Join(root, taskID+".json"), append(metadata, '\n'), 0o600); err != nil {
t.Fatalf("write metadata: %v", err)
}
manager, err := NewManager(root)
if err != nil {
t.Fatalf("NewManager returned error: %v", err)
}
if _, ok := manager.Get(taskID); ok {
t.Fatal("manager loaded task with an unrelated absolute output path")
}
if warnings := strings.Join(manager.LoadWarnings(), "\n"); !strings.Contains(warnings, "output file must be inside") {
t.Fatalf("load warnings = %q, want containment rejection", warnings)
}
}
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
root := t.TempDir()
manager, err := NewManager(root)
+18
View File
@@ -39,3 +39,21 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
}
// terminateOwnedProcess terminates cmd's process group directly when its launch
// configuration proves it was made a group leader. This avoids fragile Getpgid
// rediscovery after an owned leader exits. Ordinary commands fall back to the
// safe PID/tree path rather than assuming their PID is also a process-group ID.
//
// The Setpgid && Pgid == 0 guard only recognizes ConfigureChildProcessGroup's
// own convention. A command made its own session leader via Setsid (as opposed
// to Setpgid) is also its own process-group leader in practice, but takes the
// slower rediscovery path here since Setsid isn't checked. Harmless today
// because TerminateCommand has exactly one caller in this codebase; worth
// covering explicitly if Setsid-configured commands start using this path too.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
if cmd.SysProcAttr != nil && cmd.SysProcAttr.Setpgid && cmd.SysProcAttr.Pgid == 0 {
return false, execution.TerminateProcessGroup(cmd.Process.Pid, terminationGracePeriod, terminationPollInterval)
}
return false, terminateProcess(cmd.Process.Pid)
}
+75 -4
View File
@@ -147,11 +147,12 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
t.Fatalf("terminateProcess: %v", err)
}
// The forked child must be gone too (poll until reaped by init).
// The forked child must no longer be running. An orphaned zombie is already
// dead but may remain visible briefly until the platform's init reaps it.
deadline := time.Now().Add(2 * time.Second)
// Only ESRCH proves the child is gone; any other error (e.g. EPERM) would
// wrongly pass the test, so treat it as still-present and keep polling.
for !errors.Is(syscall.Kill(childPID, syscall.Signal(0)), syscall.ESRCH) {
// processStopped treats only ESRCH or an observed zombie state as stopped;
// other errors (for example EPERM) remain live and keep polling.
for !processStopped(childPID) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived terminateProcess — group kill failed", childPID)
@@ -159,3 +160,73 @@ func TestTerminateProcessKillsForkedChildren(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
func TestTerminateCommandStopsUnconfiguredCommand(t *testing.T) {
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
if cmd.ProcessState == nil {
_ = cmd.Process.Kill()
}
})
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
if cmd.ProcessState == nil {
t.Fatal("unconfigured command was not reaped")
}
}
func TestTerminateCommandKillsChildAfterLeaderExits(t *testing.T) {
grace, poll := terminationGracePeriod, terminationPollInterval
terminationGracePeriod, terminationPollInterval = 2*time.Second, 20*time.Millisecond
t.Cleanup(func() { terminationGracePeriod, terminationPollInterval = grace, poll })
// The leader exits immediately after launching the child. TerminateCommand
// must capture and signal the process group before Wait reaps the leader and
// makes its group identity unavailable.
cmd := exec.Command("sh", "-c", "sleep 300 & echo $!; exit 0")
ConfigureChildProcessGroup(cmd)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read forked child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse forked child pid %q: %v", line, err)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for !processStopped(childPID) {
if time.Now().After(deadline) {
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("forked child %d survived TerminateCommand", childPID)
}
time.Sleep(20 * time.Millisecond)
}
}
func processStopped(pid int) bool {
if errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH) {
return true
}
state, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return errors.Is(syscall.Kill(pid, syscall.Signal(0)), syscall.ESRCH)
}
return strings.HasPrefix(strings.TrimSpace(string(state)), "Z")
}
+34
View File
@@ -3,11 +3,20 @@
package background
import (
"fmt"
"os/exec"
"github.com/Gitlawb/zero/internal/execution"
"golang.org/x/sys/windows"
)
// stillActiveExitCode is the documented GetExitCodeProcess value for a process
// that has not terminated. x/sys/windows does not expose Windows' STILL_ACTIVE
// constant.
const stillActiveExitCode uint32 = 259
var terminateProcessForTest = terminateProcess
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
// delegated to execution.TerminateProcessTree, so no launch-time process-group
// setup is required (the POSIX build sets Setpgid here instead).
@@ -16,3 +25,28 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup
func terminateProcess(pid int) error {
return execution.TerminateProcessTree(pid, 0, 0)
}
// terminateOwnedProcess asks taskkill /T to terminate the tree rooted at cmd,
// with KillProcessTree's direct Process.Kill fallback if taskkill fails. taskkill
// can discover descendants only while the root PID still exists; unlike a POSIX
// process group, a Windows tree has no independently addressable identity after
// its root exits.
func terminateOwnedProcess(cmd *exec.Cmd) (bool, error) {
var (
alreadyExited bool
terminateErr error
)
err := cmd.Process.WithHandle(func(handle uintptr) {
var exitCode uint32
if windows.GetExitCodeProcess(windows.Handle(handle), &exitCode) == nil {
alreadyExited = exitCode != stillActiveExitCode
}
// Keep the exact process identity pinned while the PID-based taskkill
// operation runs, preventing the PID from being recycled underneath it.
terminateErr = terminateProcessForTest(cmd.Process.Pid)
})
if err != nil {
return false, fmt.Errorf("pin process %d for termination: %w", cmd.Process.Pid, err)
}
return alreadyExited, terminateErr
}
+106
View File
@@ -0,0 +1,106 @@
//go:build windows
package background
import (
"bufio"
"errors"
"os/exec"
"strconv"
"strings"
"testing"
"time"
)
func TestTerminateCommandReturnsTreeFailureAfterRootFallbackReaps(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 300")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
treeErr := errors.New("taskkill /T failed")
originalTerminateProcess := terminateProcessForTest
terminateProcessForTest = func(pid int) error {
if err := cmd.Process.Kill(); err != nil {
return errors.Join(treeErr, err)
}
return treeErr
}
t.Cleanup(func() { terminateProcessForTest = originalTerminateProcess })
err := TerminateCommand(cmd)
if !errors.Is(err, treeErr) {
t.Fatalf("TerminateCommand error = %v, want tree failure", err)
}
if cmd.ProcessState == nil {
t.Fatal("root process was not reaped")
}
}
func TestTerminateCommandReapsExitedLeader(t *testing.T) {
cmd := exec.Command("cmd.exe", "/c", "exit", "0")
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Let the child exit without calling Wait so cleanup exercises the Windows
// taskkill/TerminateProcess race while it still owns the process handle.
time.Sleep(500 * time.Millisecond)
// jatmn's #774 finding: taskkill /T racing an already-exited PID (and the
// Process.Kill fallback) both fail here, but the reap below still succeeds —
// the process-handle exit-code check proves the leader was already gone, so
// TerminateCommand must not surface the expected kill-attempt error.
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v, want nil (leader was already gone and got reaped)", err)
}
if cmd.ProcessState == nil {
t.Fatal("exited process was not reaped")
}
}
func TestTerminateCommandDeadLeaderCannotIdentifyDescendantTree(t *testing.T) {
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
`$p = Start-Process powershell.exe -ArgumentList '-NoProfile','-Command','Start-Sleep -Seconds 300' -PassThru; $p.Id`)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("read descendant pid: %v", err)
}
descendantPID, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil {
t.Fatalf("parse descendant pid %q: %v", line, err)
}
t.Cleanup(func() {
_ = exec.Command("taskkill.exe", "/F", "/PID", strconv.Itoa(descendantPID)).Run()
})
// Wait until the leader is observably exited but deliberately leave it
// unreaped, matching the POSIX regression's ordering.
deadline := time.Now().Add(5 * time.Second)
for processExistsOnWindows(cmd.Process.Pid) {
if time.Now().After(deadline) {
t.Fatal("leader did not exit")
}
time.Sleep(20 * time.Millisecond)
}
if err := TerminateCommand(cmd); err != nil {
t.Fatalf("TerminateCommand: %v", err)
}
// Windows has no persistent group ID analogous to POSIX: once the leader is
// gone, taskkill /T cannot rediscover this descendant. Assert that platform
// distinction rather than claiming TerminateCommand can provide it here.
if !processExistsOnWindows(descendantPID) {
t.Fatal("descendant unexpectedly stopped after its root exited")
}
}
func processExistsOnWindows(pid int) bool {
return exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
"Get-Process -Id "+strconv.Itoa(pid)+" -ErrorAction Stop | Out-Null").Run() == nil
}
+75
View File
@@ -1,5 +1,14 @@
package background
import (
"errors"
"fmt"
"os/exec"
"time"
)
const commandReapTimeout = 3 * time.Second
// TerminateProcess stops a background process by PID — on Windows its process
// tree; on POSIX its whole process group when the PID leads its own group (the
// invariant ConfigureChildProcessGroup establishes for processes started through
@@ -12,3 +21,69 @@ package background
func TerminateProcess(pid int) error {
return terminateProcess(pid)
}
// TerminateCommand stops a started command and reaps its leader. The caller must
// have exclusive ownership of cmd: it must not have previously called Wait or
// Process.Release, and no goroutine may call either concurrently. On POSIX it
// stops the whole group when the command was configured as its leader; ordinary
// commands safely fall back to PID/tree discovery. On Windows, success for a
// leader that was already dead confirms only that the leader was reaped;
// descendants may survive because Windows cannot rediscover a tree from a dead
// root. `zero daemon start` needs this operation when readiness times out: it
// launched the child, so it must both stop the tree and collect the leader.
//
// The order matters: the tree is signalled first, because Wait releases the
// leader's PID and a later group lookup could then resolve to nothing (or, worse,
// to a recycled PID). Termination stays in the shared execution lifecycle
// primitives rather than introducing a second platform-specific killer.
//
// If the bounded reap times out, the Wait call already started by this function
// continues in its goroutine. From that point onward the caller must not access
// cmd at all, including ProcessState, because that would race with Wait.
func TerminateCommand(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil {
return errors.New("terminate command: process was never started")
}
leaderAlreadyExited, terminateErr := terminateOwnedProcess(cmd)
reapErr := waitForTerminatedCommandWithin(cmd, commandReapTimeout)
if reapErr != nil {
if terminateErr != nil {
return fmt.Errorf("%v (reap failed: %w)", terminateErr, reapErr)
}
return reapErr
}
if terminateErr != nil && !leaderAlreadyExited {
return terminateErr
}
// Only discard a termination error when the platform demonstrated before
// attempting termination that the leader had already exited. A successful
// reap alone says nothing about whether a live descendant tree was stopped.
return nil
}
// classifyWaitError treats a non-zero exit status as success: a process being
// terminated is expected to report one (or a signal), so the only interesting
// failure is Wait itself not working.
func classifyWaitError(waitErr error) error {
var exitErr *exec.ExitError
if waitErr != nil && !errors.As(waitErr, &exitErr) {
return fmt.Errorf("reap process: %w", waitErr)
}
return nil
}
// waitForTerminatedCommandWithin bounds the reap so a child that somehow survives
// termination cannot hang the caller — the daemon-start cleanup path runs while a
// user waits at the CLI. On timeout its Wait goroutine is intentionally left to
// finish: exec.Cmd permits only one Wait call, and abandoning it without a reaper
// would leak the child once it eventually exits.
func waitForTerminatedCommandWithin(cmd *exec.Cmd, timeout time.Duration) error {
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
select {
case waitErr := <-waitDone:
return classifyWaitError(waitErr)
case <-time.After(timeout):
return fmt.Errorf("process %d did not reap after termination", cmd.Process.Pid)
}
}
+132 -37
View File
@@ -64,38 +64,53 @@ type appDeps struct {
// getenv reads a process environment variable (production: os.Getenv, set in
// defaultAppDeps — deliberately NOT filled by fillAppDeps, so tests are hermetic
// against ambient vars like ZERO_PROVIDER unless they inject it). nil ⇒ empty.
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
getenv func(string) string
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
detectLocalRuntimes func(context.Context, provideronboarding.LocalDetectOptions) []provideronboarding.DetectedLocalRuntime
openRouterLogin func(context.Context, provideroauth.OpenRouterOptions) (string, error)
newSessionStore func() *sessions.Store
loadPlugins func(plugins.LoadOptions) (plugins.LoadResult, error)
loadHooks func(hooks.LoadOptions) (hooks.LoadResult, error)
skillsDir func() string
pluginsDir func() string
toolsDir func() string
newMCPStore func() (*mcp.PermissionStore, error)
newMCPTokenStore func() (*mcp.TokenStore, error)
newSandboxStore func() (*sandbox.GrantStore, error)
selectSandboxBackend func(sandbox.BackendOptions) sandbox.Backend
runSandboxSetupHelper func(path string, args []string, stdout io.Writer, stderr io.Writer) error
registerMCPTools func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error)
prepareWorktree func(context.Context, worktrees.Options) (worktrees.Result, error)
releaseWorktree func(context.Context, worktrees.Options, string) error
detectVerifyPlan func(string) (verify.Plan, error)
runVerify func(context.Context, verify.Plan, verify.RunOptions) verify.Report
runSelfVerify func(context.Context, verify.Plan, selfverify.Options) selfverify.Report
runAgentEval func(context.Context, agentEvalOptions) (agentEvalReport, error)
inspectChanges func(context.Context, zerogit.InspectOptions) (zerogit.ChangeSummary, error)
commitChanges func(context.Context, zerogit.CommitOptions) (zerogit.CommitResult, error)
pushChanges func(context.Context, zerogit.PushOptions) (zerogit.PushResult, error)
createPR func(context.Context, zerogit.PROptions) (zerogit.PRResult, error)
createBranch func(context.Context, zerogit.BranchOptions) (zerogit.BranchResult, error)
isDefaultBranch func(context.Context, zerogit.DefaultBranchOptions) (bool, string, string, error)
currentGitUser func(context.Context, string) string
headCommitSubject func(context.Context, string) string
commitsAhead func(context.Context, string, string, string) (int, error)
isUnbornRemote func(context.Context, string, string) (bool, error)
refreshTrackingRef func(context.Context, string, string, string) error
branchUpstreamRemote func(context.Context, string, string) string
branchUpstreamRemoteAndMerge func(context.Context, string, string) (string, string)
resolveRemoteBranchTip func(context.Context, string, string, string) (string, error)
remoteHasBranch func(context.Context, string, string, string) (bool, error)
currentGitBranch func(context.Context, string) string
currentBranchTip func(context.Context, string) string
deleteBranch func(context.Context, string, string, string) error
resetBranchRef func(context.Context, string, string, string, string) error
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
type mcpToolRuntime interface {
@@ -198,11 +213,52 @@ func defaultAppDeps() appDeps {
commitChanges: zerogit.Commit,
pushChanges: zerogit.Push,
createPR: zerogit.CreatePR,
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
createBranch: zerogit.CreateBranch,
isDefaultBranch: zerogit.IsDefaultBranch,
currentGitUser: func(ctx context.Context, cwd string) string {
return zerogit.CurrentGitUser(ctx, cwd, nil)
},
headCommitSubject: func(ctx context.Context, cwd string) string {
return zerogit.HeadCommitSubject(ctx, cwd, nil)
},
commitsAhead: func(ctx context.Context, cwd, remote, branch string) (int, error) {
return zerogit.CommitsAhead(ctx, cwd, remote, branch, nil)
},
isUnbornRemote: func(ctx context.Context, cwd, remote string) (bool, error) {
return zerogit.IsUnbornRemote(ctx, cwd, remote, nil)
},
refreshTrackingRef: func(ctx context.Context, cwd, remote, branch string) error {
return zerogit.RefreshTrackingRef(ctx, cwd, remote, branch, nil)
},
branchUpstreamRemote: func(ctx context.Context, cwd, branch string) string {
return zerogit.UpstreamRemote(ctx, cwd, branch, nil)
},
branchUpstreamRemoteAndMerge: func(ctx context.Context, cwd, branch string) (string, string) {
return zerogit.UpstreamRemoteAndMergeBranch(ctx, cwd, branch, nil)
},
resolveRemoteBranchTip: func(ctx context.Context, cwd, remote, branch string) (string, error) {
return zerogit.ResolveRemoteBranchTip(ctx, cwd, remote, branch, nil)
},
remoteHasBranch: func(ctx context.Context, cwd, remote, branch string) (bool, error) {
return zerogit.RemoteHasBranch(ctx, cwd, remote, branch, nil)
},
currentGitBranch: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranch(ctx, cwd, nil)
},
currentBranchTip: func(ctx context.Context, cwd string) string {
return zerogit.CurrentBranchTip(ctx, cwd, nil)
},
deleteBranch: func(ctx context.Context, cwd, fallbackBranch, branchToDelete string) error {
return zerogit.DeleteBranch(ctx, cwd, fallbackBranch, branchToDelete, nil)
},
resetBranchRef: func(ctx context.Context, cwd, branch, newTip, expectedOld string) error {
return zerogit.ResetBranchRef(ctx, cwd, branch, newTip, nil, expectedOld)
},
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -562,6 +618,45 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.createPR == nil {
deps.createPR = defaults.createPR
}
if deps.createBranch == nil {
deps.createBranch = defaults.createBranch
}
if deps.isDefaultBranch == nil {
deps.isDefaultBranch = defaults.isDefaultBranch
}
if deps.currentGitUser == nil {
deps.currentGitUser = defaults.currentGitUser
}
if deps.headCommitSubject == nil {
deps.headCommitSubject = defaults.headCommitSubject
}
if deps.commitsAhead == nil {
deps.commitsAhead = defaults.commitsAhead
}
if deps.isUnbornRemote == nil {
deps.isUnbornRemote = defaults.isUnbornRemote
}
if deps.refreshTrackingRef == nil {
deps.refreshTrackingRef = defaults.refreshTrackingRef
}
if deps.branchUpstreamRemote == nil {
deps.branchUpstreamRemote = defaults.branchUpstreamRemote
}
if deps.branchUpstreamRemoteAndMerge == nil {
deps.branchUpstreamRemoteAndMerge = defaults.branchUpstreamRemoteAndMerge
}
if deps.remoteHasBranch == nil {
deps.remoteHasBranch = defaults.remoteHasBranch
}
if deps.currentGitBranch == nil {
deps.currentGitBranch = defaults.currentGitBranch
}
if deps.currentBranchTip == nil {
deps.currentBranchTip = defaults.currentBranchTip
}
// resolveRemoteBranchTip, deleteBranch, and resetBranchRef stay nil when
// unset so unit tests that mock createBranch without a real git tree do not
// hit real git restore/delete commands.
if deps.runTUI == nil {
deps.runTUI = defaults.runTUI
}
+52 -10
View File
@@ -1,6 +1,7 @@
package cli
import (
"errors"
"fmt"
"io"
"os"
@@ -160,20 +161,61 @@ func runDaemonStartDetached(paths daemon.Paths, stdout io.Writer, stderr io.Writ
cmd.Stdout = logFile
cmd.Stderr = logFile
background.ConfigureChildProcessGroup(cmd) // own process group: outlives this shell
if err := cmd.Start(); err != nil {
if err := startAndAwaitDaemonProcess(cmd, func() bool { return daemonReachable(paths) }, 5*time.Second, 25*time.Millisecond); err != nil {
if errors.Is(err, errDaemonStartTimeout) {
return writeAppError(stderr, err.Error()+"; see "+logPath, exitCrash)
}
return writeAppError(stderr, err.Error(), exitCrash)
}
_ = cmd.Process.Release()
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if daemonReachable(paths) {
fmt.Fprintf(stdout, "zero daemon started (socket %s)\n", paths.Socket)
return exitSuccess
}
time.Sleep(25 * time.Millisecond)
var errDaemonStartTimeout = errors.New("daemon did not come up within timeout")
func startAndAwaitDaemonProcess(cmd *exec.Cmd, reachable func() bool, timeout time.Duration, pollInterval time.Duration) error {
if err := cmd.Start(); err != nil {
return err
}
return writeAppError(stderr, "daemon did not come up within timeout; see "+logPath, exitCrash)
if waitForDaemonReadiness(reachable, timeout, pollInterval) {
if err := cmd.Process.Release(); err != nil {
cleanupErr := terminateAndReapDaemonProcess(cmd)
if cleanupErr != nil {
return fmt.Errorf("release daemon process: %w (cleanup failed: %v)", err, cleanupErr)
}
return fmt.Errorf("release daemon process: %w", err)
}
return nil
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
return fmt.Errorf("%w; cleanup failed: %v", errDaemonStartTimeout, err)
}
return errDaemonStartTimeout
}
func waitForDaemonReadiness(reachable func() bool, timeout time.Duration, pollInterval time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if reachable() {
return true
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
time.Sleep(min(pollInterval, remaining))
}
// The loop's last reachable() call already ran immediately before this one
// (right after the final Sleep, with the same deadline check separating
// them), so this adds no extra waiting time and is not a grace window for a
// slow-starting daemon — it only re-checks the same instant in case
// reachable() itself is non-deterministic. To tolerate a genuinely slow
// start, raise the timeout passed to startAndAwaitDaemonProcess instead.
return reachable()
}
func terminateAndReapDaemonProcess(cmd *exec.Cmd) error {
return background.TerminateCommand(cmd)
}
func runDaemonStop(args []string, stdout io.Writer, stderr io.Writer) int {
+88
View File
@@ -2,8 +2,15 @@ package cli
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Gitlawb/zero/internal/background"
)
// isolateDaemonPaths points DefaultPaths at a temp dir so the test never touches
@@ -121,3 +128,84 @@ func TestDaemonSubcommandsRejectExtraArgs(t *testing.T) {
}
}
}
func TestWaitForDaemonReadinessChecksTimeoutBoundary(t *testing.T) {
checks := 0
ready := waitForDaemonReadiness(func() bool {
checks++
return checks == 2
}, 0, time.Millisecond)
if !ready {
t.Fatal("daemon that became reachable at the timeout boundary was not detected")
}
if checks != 2 {
t.Fatalf("reachability checks = %d, want initial and final checks", checks)
}
}
func TestTerminateAndReapDaemonProcess(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := cmd.Start(); err != nil {
t.Fatalf("start helper process: %v", err)
}
if err := terminateAndReapDaemonProcess(cmd); err != nil {
t.Fatalf("terminate and reap helper process: %v", err)
}
if cmd.ProcessState == nil {
t.Fatalf("helper process was not reaped: state=%v", cmd.ProcessState)
}
if cmd.ProcessState.Success() {
t.Fatalf("helper process unexpectedly exited successfully: %v", cmd.ProcessState)
}
}
func TestStartAndAwaitDaemonProcessTimeoutTerminatesAndReaps(t *testing.T) {
cmd := daemonDetachedChildCommand("hang")
if err := startAndAwaitDaemonProcess(cmd, func() bool { return false }, 0, time.Millisecond); !errors.Is(err, errDaemonStartTimeout) {
t.Fatalf("start and await error = %v, want timeout", err)
}
if cmd.ProcessState == nil {
t.Fatal("timed-out helper process was not reaped")
}
}
func TestStartAndAwaitDaemonProcessReleasesReadyChild(t *testing.T) {
marker := filepath.Join(t.TempDir(), "child-finished")
cmd := daemonDetachedChildCommand("mark", "ZERO_TEST_DAEMON_CHILD_MARKER="+marker)
if err := startAndAwaitDaemonProcess(cmd, func() bool { return true }, time.Second, time.Millisecond); err != nil {
t.Fatalf("start and await ready helper: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(marker); err == nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("released helper process did not remain alive to write its marker")
}
func daemonDetachedChildCommand(mode string, extraEnv ...string) *exec.Cmd {
cmd := exec.Command(os.Args[0], "-test.run=TestDaemonDetachedChildProcess")
cmd.Env = append(os.Environ(), "ZERO_TEST_DAEMON_DETACHED_CHILD="+mode)
cmd.Env = append(cmd.Env, extraEnv...)
background.ConfigureChildProcessGroup(cmd)
return cmd
}
func TestDaemonDetachedChildProcess(t *testing.T) {
switch os.Getenv("ZERO_TEST_DAEMON_DETACHED_CHILD") {
case "":
return
case "mark":
if err := os.WriteFile(os.Getenv("ZERO_TEST_DAEMON_CHILD_MARKER"), []byte("ready"), 0o600); err != nil {
os.Exit(2)
}
os.Exit(0)
}
for {
time.Sleep(time.Hour)
}
}
File diff suppressed because it is too large. Load diff
+538 -8
View File
@@ -2,9 +2,12 @@ package cli
import (
"context"
"errors"
"fmt"
"io"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -602,18 +605,20 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && options.message != "" {
if command != "commit" && options.hasMessage {
return options, false, execUsageError{"--message is only valid with `zero changes commit`"}
}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command != "commit" && command != "push" && options.dryRun {
return options, false, execUsageError{"--dry-run is only valid with commit or push"}
}
// --auto on push/pr is the explicit opt-in for LLM branch naming (see
// ensureFeatureBranch); on commit it opts into the LLM commit message.
if command != "commit" && command != "push" && command != "pr" && options.auto {
return options, false, execUsageError{"--auto is only valid with commit, push, or pr"}
}
if command != "inspect" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
}
@@ -934,8 +939,7 @@ Flags:
--fill Automatically populate PR title and body from commits
--draft Create PR as a draft
--yes Confirm pushing to a default/protected branch
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
-a, --auto Use the LLM: commit generates the message, push/pr name the auto-created branch (sends the diff to the provider)
--json Print JSON output
-h, --help Show this help
`)
@@ -958,11 +962,31 @@ func runChangesPush(args []string, stdout io.Writer, stderr io.Writer, deps appD
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: options.dryRun,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// context.Background() is deliberate: the 2-minute ctx above is the
// feature-branch preflight (inspection, remote probes, branch creation).
// The push itself must not be cancelled mid-transfer just because the
// preflight budget elapsed; a large push can legitimately take longer.
result, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Force: options.force,
DryRun: options.dryRun,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -1010,15 +1034,62 @@ func runChangesPR(args []string, stdout io.Writer, stderr io.Writer, deps appDep
return writeExecUsageError(stderr, err.Error())
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
branch, remote, created, err := ensureFeatureBranch(ctx, stdout, workspaceRoot, options.remote, featureBranchOptions{
JSONMode: options.json,
AllowDefaultBranch: options.yes,
DryRun: false,
AutoNaming: options.auto,
MaxDiffBytes: options.maxDiffBytes,
}, deps)
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
// Resolve the target remote for the unborn-remote check. Explicit --remote wins,
// followed by the remote resolved by ensureFeatureBranch. When --yes is set,
// fall back to the branch upstream or "origin".
targetRemote := strings.TrimSpace(options.remote)
if targetRemote == "" {
targetRemote = remote
}
if targetRemote == "" {
currentBranch := ""
if deps.currentGitBranch != nil {
currentBranch = deps.currentGitBranch(context.Background(), workspaceRoot)
}
if deps.branchUpstreamRemote != nil && currentBranch != "" {
targetRemote = deps.branchUpstreamRemote(context.Background(), workspaceRoot, currentBranch)
}
if targetRemote == "" {
targetRemote = "origin"
}
}
// Probe errors are intentionally ignored so the flow can continue; if the
// probe fails, the subsequent push or PR operation provides fail-closed behavior.
if deps.isUnbornRemote != nil {
if unborn, unbornErr := deps.isUnbornRemote(context.Background(), workspaceRoot, targetRemote); unbornErr == nil && unborn {
return writeExecUsageError(stderr, fmt.Sprintf("cannot create pull request on unborn remote %s: push the initial default branch first", targetRemote))
}
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Pushing current branch to set upstream..."); err != nil {
return exitCrash
}
}
// context.Background() is deliberate here too: runChangesPR reuses the
// 2-minute ctx for preflight (branch creation and remote probes), but the
// push must not be cancelled mid-transfer if that budget elapsed.
pushResult, err := deps.pushChanges(context.Background(), zerogit.PushOptions{
Cwd: workspaceRoot,
Remote: options.remote,
Remote: firstNonEmptyString(options.remote, remote),
Branch: branch,
Force: options.force,
RequireNewRemoteBranch: created,
AllowPushDefaultBranch: options.yes,
})
if err != nil {
@@ -1100,3 +1171,462 @@ func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provide
}
return msg, nil
}
type featureBranchOptions struct {
JSONMode bool
AllowDefaultBranch bool
DryRun bool
AutoNaming bool
MaxDiffBytes int
}
// branchPushPlan holds the resolved parameters for a push or PR workflow
// before any branches are created or refs mutated. Separating plan resolution
// from mutation allows tests to assert the resolution matrix directly without
// touching a git repository.
type branchPushPlan struct {
ShouldBranch bool
CurrentBranch string
PushRemote string
RequireNewRemoteBranch bool
TrackingRemote string
TrackingBranch string
RestoreTip string
PublishabilityBase string
Summary zerogit.ChangeSummary
Slug string
}
// resolveBranchPushPlan resolves the branch, remotes, restore tip, and naming
// slug for changes push / pr workflows. It performs all checks (clean working
// tree, unborn remote detection, ahead count, restore tip resolution) but
// executes no mutation (no branch creation, ref resets, or deletions).
func resolveBranchPushPlan(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (branchPushPlan, error) {
if opts.AllowDefaultBranch || opts.DryRun {
return branchPushPlan{
ShouldBranch: false,
PushRemote: strings.TrimSpace(requestedRemote),
RequireNewRemoteBranch: false,
}, nil
}
if deps.isDefaultBranch == nil {
return branchPushPlan{}, fmt.Errorf("isDefaultBranch dependency missing")
}
isDefault, currentBranch, remote, err := deps.isDefaultBranch(ctx, zerogit.DefaultBranchOptions{Cwd: workspaceRoot, Remote: requestedRemote})
if err != nil {
return branchPushPlan{}, err
}
if !isDefault {
// Whether this push needs the nonexistence lease is decided by one
// live check against the destination remote, not by inference from
// local git state (upstream config, a generated-branch marker):
// every local signal that stood in for that check turned out to be
// wrong in some ordering (inherited config from checkout -b, a
// config write that failed after a successful push, a push aimed at
// a different remote than the one local config describes). A branch
// missing on the remote gets the nonexistence lease, protecting a
// concurrent creator of the same name; a branch already there gets a
// plain push, and git's own fast-forward check is the safety net —
// if the remote history isn't ours, the push fails with a clear
// non-fast-forward error instead of an attempted silent recovery.
if deps.remoteHasBranch == nil {
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: true,
}, nil
}
targetRemote := firstNonEmptyString(requestedRemote, remote)
exists, existsErr := deps.remoteHasBranch(ctx, workspaceRoot, targetRemote, currentBranch)
if existsErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot check whether %s already exists on remote %s: %w", currentBranch, targetRemote, existsErr)
}
return branchPushPlan{
ShouldBranch: false,
CurrentBranch: currentBranch,
PushRemote: remote,
RequireNewRemoteBranch: !exists,
}, nil
}
// CreateBranch and Push publish commits only. A dirty working tree would
// leave uncommitted edits behind under a branch/PR that does not include
// them, so refuse until the tree is clean (commit or stash first).
workingTree, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect working tree: %w", err)
}
if !workingTree.Clean {
return branchPushPlan{}, fmt.Errorf("working tree has uncommitted changes; commit or stash them before pushing from the default branch")
}
// Capture the source default branch's own upstream before refreshing or
// leaving it. --remote selects the push destination; restore must not
// rewrite local main to upstream/main when main still tracks origin/main
// (fork setups), and refreshing must update the exact tracking ref that
// will supply both the ahead count and the restore target.
trackingRemote := remote
trackingBranch := currentBranch
if deps.branchUpstreamRemoteAndMerge != nil {
if r, m := deps.branchUpstreamRemoteAndMerge(ctx, workspaceRoot, currentBranch); r != "" && m != "" {
trackingRemote = r
trackingBranch = m
}
}
// Refresh the local remote-tracking ref before trusting it: IsDefaultBranch
// already contacted the remote for its symref check, but the tracking ref
// commitsAhead reads from is only a local cache (written at clone or the
// last fetch) and can sit behind the remote's live tip. Left stale, a real
// publishable range could look like zero commits ahead, and the diff
// derived below (which reuses this same ref as its base) would be stale
// too. A failure here (offline, or a genuinely unborn remote with no such
// ref to fetch yet) is folded into the same fail-closed path as an
// unresolvable ahead count below.
var fetchErr error
if deps.refreshTrackingRef != nil {
fetchErr = deps.refreshTrackingRef(ctx, workspaceRoot, trackingRemote, trackingBranch)
}
// The ahead check gates on the source upstream (trackingRemote), but the
// feature branch is pushed to `pushRemote`, which can be a different
// destination (--remote on a fork). Refuse to auto-branch onto an unborn
// destination: without an initial default branch the new feature branch
// becomes the destination's first ref and its HEAD is left pointing at it.
// This is checked once, here, against the push destination, before resolving
// commit tips or creating any branches.
pushRemote := firstNonEmptyString(requestedRemote, remote)
if deps.isUnbornRemote != nil {
if unbornDest, unbornDestErr := deps.isUnbornRemote(ctx, workspaceRoot, pushRemote); unbornDestErr == nil && unbornDest {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", pushRemote)
}
}
// Branching off the default branch only makes sense when HEAD carries a
// commit that is not already on the remote default branch. A clean,
// up-to-date default branch would otherwise publish a feature branch at the
// exact default tip. If the ahead count cannot be determined (for example
// the remote-tracking ref was never fetched), fail rather than guess. When
// the remote is confirmed unborn, refuse auto-branching entirely: the
// initial default branch must be established first (changes push --yes)
// so the remote has a real HEAD before any feature branch is published.
ahead, aheadErr := deps.commitsAhead(ctx, workspaceRoot, trackingRemote, trackingBranch)
if fetchErr != nil || aheadErr != nil {
var unbornRemote bool
var unbornErr error
if deps.isUnbornRemote != nil {
unbornRemote, unbornErr = deps.isUnbornRemote(ctx, workspaceRoot, trackingRemote)
}
if unbornErr == nil && unbornRemote {
return branchPushPlan{}, fmt.Errorf("remote %s has no branches yet; push the initial default branch first with --yes (`zero changes push --yes`), then use auto-branching for subsequent work", trackingRemote)
}
cause := aheadErr
if cause == nil {
cause = fetchErr
}
return branchPushPlan{}, fmt.Errorf("cannot determine whether HEAD is ahead of %s/%s: %w; fetch the remote tracking branch first", trackingRemote, trackingBranch, cause)
}
if ahead == 0 {
return branchPushPlan{}, fmt.Errorf("no changes to publish: HEAD is not ahead of %s/%s; commit your work before pushing", trackingRemote, trackingBranch)
}
// Resolve the restore tip to a concrete commit before creating any branch.
// It must never be a bare local branch name: resolving "main" yields the
// very commit the feature branch is about to own, so restore becomes a
// silent no-op and the publishable commits stay on the default branch. A
// named remote resolves through its just-refreshed remote-tracking ref; a
// direct URL or path asks the remote itself. Fail closed rather than
// substitute a local branch name.
restoreTip := ""
if deps.resolveRemoteBranchTip != nil {
tip, tipErr := deps.resolveRemoteBranchTip(ctx, workspaceRoot, trackingRemote, trackingBranch)
if tipErr != nil {
return branchPushPlan{}, fmt.Errorf("cannot resolve restore tip for %s/%s: %w", trackingRemote, trackingBranch, tipErr)
}
restoreTip = tip
}
if restoreTip == "" {
restoreTip = trackingRemote + "/" + trackingBranch
}
// Name the branch (and, with --auto, send the provider) from what HEAD is
// actually ahead of the resolved remote branch by, using the same ref
// commitsAhead just checked. A working-tree snapshot can describe edits a
// commit-only push will never include.
baseRef := restoreTip
summary, err := deps.inspectChanges(ctx, zerogit.InspectOptions{Cwd: workspaceRoot, BaseRef: baseRef, MaxDiffBytes: opts.MaxDiffBytes})
if err != nil {
return branchPushPlan{}, fmt.Errorf("failed to inspect changes: %w", err)
}
slug := fallbackBranchSlug(summary)
if len(summary.Files) == 0 {
// An empty commit (or one whose only content the diff omits) leaves
// nothing to derive a slug from; name the branch from the commit
// subject instead.
if subject := deps.headCommitSubject(ctx, workspaceRoot); subject != "" {
slug = zerogit.SlugifyBranchComponent(subject)
}
}
if opts.AutoNaming && strings.TrimSpace(summary.Diff) != "" {
llmSlug := ""
if resolved, cfgErr := deps.resolveConfig(workspaceRoot, config.Overrides{}); cfgErr == nil && config.HasProviderProfile(resolved.Provider) {
if provider, provErr := deps.newProvider(resolved.Provider); provErr == nil {
if !opts.JSONMode {
fmt.Fprintln(stdout, "Generating branch name using LLM...")
}
genCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
generated, genErr := generateAutoBranchSlug(genCtx, provider, resolved.Provider.Model, redactChangeSummary(summary))
cancel()
if genErr == nil && generated != "" {
llmSlug = generated
}
}
}
if llmSlug != "" {
slug = llmSlug
} else if !opts.JSONMode {
// Cover resolveConfig failures, missing provider profiles,
// newProvider errors, generation errors, and empty results so the
// user is not left guessing after "Generating..." or silence.
fmt.Fprintln(stdout, "LLM branch naming unavailable; using deterministic name.")
}
}
return branchPushPlan{
ShouldBranch: true,
CurrentBranch: currentBranch,
PushRemote: pushRemote,
RequireNewRemoteBranch: true,
TrackingRemote: trackingRemote,
TrackingBranch: trackingBranch,
RestoreTip: restoreTip,
PublishabilityBase: baseRef,
Summary: summary,
Slug: slug,
}, nil
}
// ensureFeatureBranch is the branch-naming step `zero changes push`/`pr` run
// before pushing: pushing straight to the default branch is refused deeper in
// zerogit.Push, so rather than surface that as a dead end, create and switch
// to a conventionally named "<user>/<slug>" branch first. It returns the
// branch push/pr should target, or "" to mean "current HEAD branch, unchanged"
// (zerogit.Push already treats an empty Branch that way), plus the remote the
// preflight resolved (requestedRemote, then the original branch's configured
// upstream, then "origin"), plus whether Push should require that branch not
// already exist on that remote. Callers must pass the remote to Push: a
// freshly created branch has no tracking configuration, so Push's own
// fallback would silently retarget "origin" even when the work came from a
// branch tracking a different remote. Callers must also pass that third value
// through as Push's RequireNewRemoteBranch: CreateBranch's own
// remote-collision probe runs before this returns, and closing that race
// requires Push's push itself to assert the destination is still new. That
// requirement also carries across a retry: if the current branch is already
// non-default (this call didn't create it) but has not been successfully
// published to the exact target remote under the same branch name, keep the
// nonexistence lease. branch.<name>.remote alone is not enough: with
// branch.autoSetupMerge=inherit, checkout -b copies remote=origin from the
// source branch before any push. After a successful create, the original
// default branch ref is moved back to its own upstream tip (not necessarily
// the push destination) so the feature branch exclusively owns the
// publishable commits; a failed restore rolls the feature branch back.
// allowDefaultBranch (the --yes flag) and dryRun both opt out via the ""
// branch / false return, leaving Push's own guard/preview behavior on the
// default branch unaffected.
//
// autoNaming gates the LLM naming path (--auto): these commands were
// git-only, and sending the change diff to a configured provider on every
// default-branch push would silently export source code nobody asked to
// share. Without the opt-in the name comes from deterministic local
// information only. maxDiffBytes caps the committed-range diff Inspect
// returns, so a user who passed --diff-bytes to bound the proprietary source
// sent for LLM naming has that cap honored here just as the commit path does.
// The working tree must be clean and HEAD must be ahead of the resolved
// remote branch; both are verified before any branch is created. The
// inspectChanges, commitsAhead, headCommitSubject, currentGitUser, and
// createBranch dependencies are required on the default-branch path and are
// called without nil guards; fillAppDeps populates all of them. The
// refreshTrackingRef, resolveRemoteBranchTip, isUnbornRemote, resetBranchRef,
// and deleteBranch dependencies are optional and are skipped when nil.
func ensureFeatureBranch(ctx context.Context, stdout io.Writer, workspaceRoot string, requestedRemote string, opts featureBranchOptions, deps appDeps) (string, string, bool, error) {
plan, err := resolveBranchPushPlan(ctx, stdout, workspaceRoot, requestedRemote, opts, deps)
if err != nil {
return "", "", false, err
}
if !plan.ShouldBranch {
return plan.CurrentBranch, plan.PushRemote, plan.RequireNewRemoteBranch, nil
}
expectedRestoreTip := ""
if deps.currentBranchTip != nil {
expectedRestoreTip = deps.currentBranchTip(ctx, workspaceRoot)
}
name := zerogit.BuildBranchName(deps.currentGitUser(ctx, workspaceRoot), plan.Slug)
result, err := deps.createBranch(ctx, zerogit.BranchOptions{Cwd: workspaceRoot, Name: name, Remote: plan.PushRemote})
if err != nil {
return "", "", false, fmt.Errorf("failed to create branch: %w", err)
}
// The normal flow commits on the default branch first, then creates the
// feature branch at the same HEAD. Without moving the original default
// ref back, local main keeps the pre-squash commits and diverges from
// its upstream after a squash-merge (and a later pull/push can re-publish
// them). Once the feature branch owns those commits, restore the default
// branch to the source branch's own upstream tip captured above.
// Failure-safe: if the restore fails, delete the feature branch and
// check the default branch back out so the tree is not left half-moved.
if deps.resetBranchRef != nil {
if err := deps.resetBranchRef(ctx, workspaceRoot, plan.CurrentBranch, plan.RestoreTip, expectedRestoreTip); err != nil {
// A compare-and-swap conflict means the default branch moved
// while we were branching, so restoreTip is no longer the tip we
// meant to restore. The rollback below deletes the branch that
// now exclusively owns the user's commits, which would destroy
// work to recover from a race we can simply report. Keep the
// branch and let the user reconcile the default branch.
if errors.Is(err, zerogit.ErrCompareAndSwapConflict) {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; generated branch %s was preserved because the default branch changed concurrently", plan.CurrentBranch, plan.RestoreTip, err, result.Branch)
}
var rollbackErr error
if deps.deleteBranch != nil {
rollbackErr = deps.deleteBranch(ctx, workspaceRoot, plan.CurrentBranch, result.Branch)
}
if rollbackErr != nil {
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w; rollback of %s also failed: %v", plan.CurrentBranch, plan.RestoreTip, err, result.Branch, rollbackErr)
}
return "", "", false, fmt.Errorf("failed to restore default branch %s to %s after auto-branching: %w", plan.CurrentBranch, plan.RestoreTip, err)
}
}
if !opts.JSONMode {
fmt.Fprintf(stdout, "Created branch %s (was on %s)\n", result.Branch, plan.CurrentBranch)
}
return result.Branch, plan.PushRemote, true, nil
}
// fallbackBranchSlug derives a deterministic branch-name slug from a change
// summary without calling an LLM, so ensureFeatureBranch still works when no
// provider is configured.
func fallbackBranchSlug(summary zerogit.ChangeSummary) string {
switch len(summary.Files) {
case 0:
return "changes"
case 1:
// Git paths are always slash-separated regardless of platform, so
// path.Base (not filepath.Base) extracts the filename portably.
return zerogit.SlugifyBranchComponent(path.Base(summary.Files[0].Path))
default:
return fmt.Sprintf("update-%d-files", len(summary.Files))
}
}
// generateAutoBranchSlug asks the model for a short kebab-case slug
// describing the diff, mirroring generateAutoCommitMessage's prompt shape.
func generateAutoBranchSlug(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a short git branch name slug for it.\n")
promptBuilder.WriteString("The slug must be 2 to 5 lowercase words separated by hyphens (kebab-case), using only letters, digits, and hyphens, with no prefix like \"feature/\" or \"fix/\" and no surrounding quotes.\n")
promptBuilder.WriteString("Output ONLY the raw slug text, nothing else.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
slug := zerogit.SlugifyBranchComponent(extractBranchSlug(collected.Text))
if slug == "" {
return "", fmt.Errorf("provider returned empty branch slug")
}
return slug, nil
}
// slugLineRe matches a line that already reads as a kebab-case slug (letters,
// digits, and internal single hyphens only). extractBranchSlug prefers such a
// line so a preamble sentence is never mistaken for the slug.
var slugLineRe = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
func isPreambleText(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
if strings.HasSuffix(lower, ":") || strings.HasSuffix(lower, ".") || strings.HasSuffix(lower, "!") || strings.HasSuffix(lower, "?") {
return true
}
preambles := []string{
"here is", "here's", "below is", "below are", "sure", "certainly",
"i suggest", "suggested branch", "branch name", "recommended branch",
"how about", "you could use",
}
for _, p := range preambles {
if strings.HasPrefix(lower, p) {
return true
}
}
return false
}
// extractBranchSlug pulls the intended slug out of a model response that didn't
// follow the "output only the raw slug" instruction exactly. It drops Markdown
// code-fence lines, then prefers a line that already looks like a kebab-case
// slug: that skips a leading preamble such as "Here is a suggested branch
// name:" in favor of the "add-login-page" line that follows it. One-word
// acknowledgements that happen to match the slug regex ("Sure", "Certainly")
// are filtered as preamble before the early return, so "Sure\nadd login page"
// yields the real suggestion. Inline labeled forms ("Branch name: add-login-page")
// still work: the preamble check runs on the extracted value after the label
// is stripped. If no line is strictly kebab-cased, it prefers a plausible
// non-preamble line so plain multi-word replies still slugify correctly.
func extractBranchSlug(text string) string {
var firstLine string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "```") {
continue
}
line = strings.TrimSpace(strings.Trim(line, `"'`))
if line == "" {
continue
}
candidate := line
if idx := strings.Index(line, ":"); idx != -1 {
after := strings.TrimSpace(strings.Trim(line[idx+1:], `"'`))
if after != "" {
candidate = after
}
}
// Models often terminate a suggestion with sentence punctuation;
// strip it so "add login page." is recovered as the slug rather than
// rejected as preamble (isPreambleText treats a trailing '.' as a
// full sentence). True preamble lines still match the prefix list.
candidate = strings.TrimSpace(strings.TrimRight(candidate, ".!?"))
if candidate == "" {
continue
}
// Preamble classification runs before accepting a slug-shaped line so
// "Sure" does not win over a later "add-login-page". The check is on
// candidate (post label-strip) so "Branch name: add-login-page" still
// returns the extracted slug.
if slugLineRe.MatchString(candidate) && !isPreambleText(candidate) {
return candidate
}
if firstLine == "" && !isPreambleText(candidate) {
firstLine = candidate
}
}
return firstLine
}
+15 -5
View File
@@ -105,7 +105,17 @@ func TestLoadProviderCommandTimeout(t *testing.T) {
//
// This is a hang-breaker, not a tuned margin. Nothing in a healthy run waits on
// it, since both tests return about one WaitDelay after the fixture exits.
const providerCommandTestBudget = 60 * time.Second
const (
providerCommandTestBudget = 90 * time.Second
providerCommandReadyWaitAttempts = 45
)
func TestProviderCommandBudgetLeavesCleanupMargin(t *testing.T) {
readyWaitCeiling := time.Duration(providerCommandReadyWaitAttempts) * time.Second
if providerCommandTestBudget < 2*readyWaitCeiling {
t.Fatalf("provider command test budget %s leaves less than one readiness window for cleanup after %s", providerCommandTestBudget, readyWaitCeiling)
}
}
// backgroundChildLifetime must exceed providerCommandTestBudget so that a child
// dying of old age is unobservable: any run where runProviderCommand has not
@@ -341,15 +351,15 @@ func writeCommand(t *testing.T, script commandScript) string {
// assertion fail is far more diagnosable than blocking until the
// timeout fires and reporting an unrelated error. Each iteration is
// about a second, since `ping -n 2` sleeps between its two echoes,
// so the ceiling is roughly 15s: far longer than any cold PowerShell
// start seen on a runner, and far shorter than
// providerCommandTestBudget, so giving up surfaces a legible
// so the ceiling is roughly 45s: enough for a heavily loaded Windows
// runner while leaving the other half of providerCommandTestBudget for
// process exit, PID-file verification, and cleanup. Giving up surfaces a legible
// PID-file failure rather than a timeout that hides the real cause.
lines = append(lines,
":zeroWaitReady",
"if exist \""+readyFile+"\" goto zeroReady",
"set /a ZERO_READY_TRIES+=1",
"if %ZERO_READY_TRIES% GEQ 15 goto zeroReady",
"if %ZERO_READY_TRIES% GEQ "+itoa(providerCommandReadyWaitAttempts)+" goto zeroReady",
"ping -n 2 127.0.0.1 >nul",
"goto zeroWaitReady",
":zeroReady",
+2 -1
View File
@@ -151,7 +151,8 @@ type KeyBindingsConfig struct {
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
// TogglePlan is retained for configuration compatibility. Plan updates render
// in the transcript and no longer have a persistent panel to toggle.
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
+246
View File
@@ -0,0 +1,246 @@
package credstore
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
func fileStore(t *testing.T, dir string) *Store {
t.Helper()
store, err := New(Options{Dir: dir, Storage: "file"})
if err != nil {
t.Fatalf("New: %v", err)
}
return store
}
// THE REPRODUCTION, kept as the regression. Before the lock this reliably kept
// 1 of 100: every writer read the same map, added its own provider, and the
// last rename published a file missing all the others.
func TestConcurrentSetKeepsEveryKey(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
const n = 100
var wg sync.WaitGroup
errs := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if err := store.Set(fmt.Sprintf("p%03d", i), fmt.Sprintf("k%03d", i)); err != nil {
errs <- err
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatalf("Set: %v", err)
}
got, err := store.Providers()
if err != nil {
t.Fatalf("Providers: %v", err)
}
if len(got) != n {
t.Fatalf("kept %d of %d concurrent writes", len(got), n)
}
// ...and every value is the one its own writer wrote, not a neighbour's.
for i := 0; i < n; i++ {
key, ok, err := store.Get(fmt.Sprintf("p%03d", i))
if err != nil {
t.Fatalf("Get: %v", err)
}
if !ok || key != fmt.Sprintf("k%03d", i) {
t.Fatalf("p%03d = %q (present=%v); a writer's own value must survive", i, key, ok)
}
}
}
// DELETE RACES SET, and an unlocked delete loses the other writer's key.
//
// A first version of this only checked that keys nobody deleted survived — and
// every reader sees those, so it passed with the lock removed from Delete. The
// case that actually breaks is a Delete whose stale read predates a concurrent
// Set: it writes back a map that never contained the new key, and the Set is
// gone. So the assertion is on the SETS surviving, not on the bystanders.
func TestADeleteCannotClobberAConcurrentSet(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
// Throwaway keys for the deleters to remove, written first so a delete
// always has something to do.
const churn = 40
for i := 0; i < churn; i++ {
if err := store.Set(fmt.Sprintf("churn%02d", i), "v"); err != nil {
t.Fatal(err)
}
}
const adds = 60
var wg sync.WaitGroup
for i := 0; i < adds; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_ = store.Set(fmt.Sprintf("added%02d", i), "v")
}(i)
}
for i := 0; i < churn; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, _ = store.Delete(fmt.Sprintf("churn%02d", i))
}(i)
}
wg.Wait()
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
survivors := map[string]bool{}
for _, name := range got {
survivors[name] = true
}
missing := []string{}
for i := 0; i < adds; i++ {
name := fmt.Sprintf("added%02d", i)
if !survivors[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
t.Fatalf("%d of %d concurrent Set calls were clobbered by a racing Delete: %v",
len(missing), adds, missing)
}
// ...and every churn key really was removed, so the deletes were not
// no-ops that would make the check above vacuous.
for i := 0; i < churn; i++ {
if survivors[fmt.Sprintf("churn%02d", i)] {
t.Fatalf("churn%02d survived; the deletes did nothing and this test proves nothing", i)
}
}
}
// ACROSS PROCESSES, which is the case that matters: a plan running with
// max_workers > 1 spawns child PROCESSES, and an in-process mutex would not
// have helped any of them. The race detector cannot see this one either — it is
// two OS processes — so it is driven for real.
func TestConcurrentSetAcrossProcesses(t *testing.T) {
if testing.Short() {
t.Skip("spawns processes")
}
helper := os.Getenv("ZERO_CREDSTORE_HELPER_DIR")
if helper != "" {
// Child mode: write our slice of the keys and exit.
store, err := New(Options{Dir: helper, Storage: "file"})
if err != nil {
os.Exit(3)
}
prefix := os.Getenv("ZERO_CREDSTORE_HELPER_PREFIX")
for i := 0; i < 25; i++ {
if err := store.Set(fmt.Sprintf("%s%02d", prefix, i), "v"); err != nil {
os.Exit(4)
}
}
os.Exit(0)
}
dir := t.TempDir()
const children = 4
var wg sync.WaitGroup
failures := make(chan string, children)
for c := 0; c < children; c++ {
wg.Add(1)
go func(c int) {
defer wg.Done()
cmd := exec.Command(os.Args[0], "-test.run=TestConcurrentSetAcrossProcesses", "-test.v")
cmd.Env = append(os.Environ(),
"ZERO_CREDSTORE_HELPER_DIR="+dir,
fmt.Sprintf("ZERO_CREDSTORE_HELPER_PREFIX=c%d_", c),
)
if out, err := cmd.CombinedOutput(); err != nil {
failures <- fmt.Sprintf("child %d: %v\n%s", c, err, out)
}
}(c)
}
wg.Wait()
close(failures)
for failure := range failures {
t.Fatal(failure)
}
store := fileStore(t, dir)
got, err := store.Providers()
if err != nil {
t.Fatal(err)
}
if len(got) != children*25 {
t.Fatalf("kept %d of %d keys written by %d concurrent processes", len(got), children*25, children)
}
}
// The lock lives BESIDE the data file, never on it. write publishes by rename,
// so a lock taken on the data file would be attached to an inode the next
// writer has already replaced — every writer would appear to hold it.
func TestTheLockIsNotTheDataFile(t *testing.T) {
dir := t.TempDir()
store := fileStore(t, dir)
if store.lockPath() == store.file {
t.Fatal("the lock is the data file; rename would carry it away")
}
if !strings.HasPrefix(filepath.Base(store.lockPath()), filepath.Base(store.file)) {
t.Fatalf("lock %q is not beside the data file %q", store.lockPath(), store.file)
}
if err := store.Set("p", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file was not created: %v", err)
}
// ...and it survives a write, since the rename replaces only the data file.
if err := store.Set("q", "k"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(store.lockPath()); err != nil {
t.Fatalf("the lock file did not survive a write: %v", err)
}
}
// The lock is a security boundary, so the path where it CANNOT be taken needs
// coverage too: an operation that could not lock must report that, never proceed
// unserialized and never report success. Replacing the credential directory with
// a regular file makes the lock's MkdirAll fail on every platform.
func TestOperationsFailWhenTheLockCannotBeAcquired(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "creds")
store := fileStore(t, dir)
// Where the store expects its directory, put a regular file.
if err := os.WriteFile(dir, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
if err := store.Set("openai", "sk-test"); err == nil {
t.Error("Set reported success although the lock could not be acquired")
}
if _, err := store.Delete("openai"); err == nil {
t.Error("Delete reported success although the lock could not be acquired")
}
if _, _, err := store.Get("openai"); err == nil {
t.Error("Get reported success although the lock could not be acquired")
}
if _, err := store.Providers(); err == nil {
t.Error("Providers reported success although the lock could not be acquired")
}
// And nothing may have been written through the failed path.
if raw, err := os.ReadFile(dir); err != nil || string(raw) != "not a directory" {
t.Errorf("the blocking file was modified: %q (err %v)", raw, err)
}
}
+49 -5
View File
@@ -9,6 +9,7 @@ package credstore
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -125,14 +126,27 @@ func (s *Store) Backend() string { return s.backend }
func (s *Store) Encrypted() bool { return s.backend == "keyring" || s.backend == "encrypted-file" }
// Set stores (or overwrites) the API key for provider.
func (s *Store) Set(provider, key string) error {
func (s *Store) Set(provider, key string) (err error) {
provider = normalizeProvider(provider)
if provider == "" {
return fmt.Errorf("credstore: provider is required")
}
if s.backend == "keyring" {
// The OS keyring serializes its own writes; the file lock guards the
// file backends only.
return s.kr.Set(keyringService, keyringPrefix+provider, key)
}
// READ AND WRITE UNDER ONE LOCK. Separately locking each half would still
// lose keys: two writers would each read the same map, each add their own
// provider, and the second rename would publish a file missing the first.
// Measured before this existed: 1 of 100 concurrent Set calls survived.
release, err := s.acquireFileLock(true)
if err != nil {
return err
}
// Joined, not chosen between: a release failure annotates the result instead
// of masking the write error that actually explains what went wrong.
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return err
@@ -142,7 +156,7 @@ func (s *Store) Set(provider, key string) error {
}
// Get returns the API key for provider and whether one is stored.
func (s *Store) Get(provider string) (string, bool, error) {
func (s *Store) Get(provider string) (value string, found bool, err error) {
provider = normalizeProvider(provider)
if provider == "" {
return "", false, nil
@@ -150,6 +164,15 @@ func (s *Store) Get(provider string) (string, bool, error) {
if s.backend == "keyring" {
return s.kr.Get(keyringService, keyringPrefix+provider)
}
// A shared lock, so a read serializes against a writer's publish. On unix
// the rename is atomic and this only prevents reading an empty file mid-swap;
// on Windows an unsynchronized reader holding the file open would block the
// writer's rename outright, so readers must coordinate through the same lock.
release, err := s.acquireFileLock(false)
if err != nil {
return "", false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return "", false, err
@@ -159,7 +182,7 @@ func (s *Store) Get(provider string) (string, bool, error) {
}
// Delete removes the API key for provider, reporting whether one existed.
func (s *Store) Delete(provider string) (bool, error) {
func (s *Store) Delete(provider string) (existed bool, err error) {
provider = normalizeProvider(provider)
if provider == "" {
return false, nil
@@ -167,6 +190,13 @@ func (s *Store) Delete(provider string) (bool, error) {
if s.backend == "keyring" {
return s.kr.Delete(keyringService, keyringPrefix+provider)
}
// The same lock as Set: a delete is a read-modify-write too, and one racing
// a set would otherwise resurrect or erase an unrelated provider.
release, err := s.acquireFileLock(true)
if err != nil {
return false, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return false, err
@@ -179,17 +209,22 @@ func (s *Store) Delete(provider string) (bool, error) {
}
// Providers lists providers with a stored key (sorted), for display/audit.
func (s *Store) Providers() ([]string, error) {
func (s *Store) Providers() (names []string, err error) {
if s.backend == "keyring" {
// The OS keyring has no enumerate-by-prefix in our minimal surface; callers
// that need a list pass known provider names to Get. Return empty here.
return nil, nil
}
release, err := s.acquireFileLock(false)
if err != nil {
return nil, err
}
defer func() { err = errors.Join(err, release()) }()
data, err := s.read()
if err != nil {
return nil, err
}
names := make([]string, 0, len(data))
names = make([]string, 0, len(data))
for name := range data {
names = append(names, name)
}
@@ -264,6 +299,15 @@ func (s *Store) write(data map[string]string) error {
return nil
}
// lockPath is the advisory lock guarding a read-modify-write of the credential
// file. Beside the data file, never the data file itself — write publishes by
// rename and would carry the lock away with the old inode.
func (s *Store) lockPath() string { return s.file + ".lock" }
// filepathDir is filepath.Dir, named locally so the two platform lock files can
// share it without either importing path/filepath for one call.
func filepathDir(path string) string { return filepath.Dir(path) }
func normalizeProvider(provider string) string {
return strings.ToLower(strings.TrimSpace(provider))
}
+55
View File
@@ -0,0 +1,55 @@
//go:build !windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/unix"
)
// acquireFileLock takes an exclusive advisory lock (flock) so a read-modify-write
// of the credential file is serialized against every other one — across
// processes AND across goroutines, since flock is held per open file
// description and two opens in one process contend exactly as two processes do.
//
// THE LOCK FILE IS SEPARATE FROM THE DATA FILE, and that is not tidiness. write
// publishes by os.Rename, which replaces the inode; a lock taken on the data
// file would be attached to an inode that the next writer has already replaced,
// so every writer would appear to hold it. The lock lives on a file nothing
// renames.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
// Writers take LOCK_EX; readers take LOCK_SH so they run concurrently with
// each other but still serialize against a writer's publish (see Get).
how := unix.LOCK_SH
if exclusive {
how = unix.LOCK_EX
}
if err := unix.Flock(int(file.Fd()), how); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Close alone drops the flock, so the explicit unlock is belt-and-braces —
// but its failure is reported rather than swallowed, because a cleanup that
// did not complete must not be indistinguishable from one that did. The
// callers join this into their result, so it annotates a successful write
// instead of masking it.
unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
+55
View File
@@ -0,0 +1,55 @@
//go:build windows
package credstore
import (
"errors"
"fmt"
"os"
"golang.org/x/sys/windows"
)
// acquireFileLock takes an exclusive OS lock (LockFileEx) so a read-modify-write
// of the credential file is serialized, matching the flock behaviour on unix.
//
// The lock file is SEPARATE from the data file for the same reason: write
// publishes by rename, and a lock on the renamed file would be attached to
// something the next writer has already replaced.
func (s *Store) acquireFileLock(exclusive bool) (func() error, error) {
path := s.lockPath()
if err := os.MkdirAll(filepathDir(path), 0o700); err != nil {
return nil, fmt.Errorf("credstore: lock dir: %w", err)
}
file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
return nil, fmt.Errorf("credstore: open lock: %w", err)
}
handle := windows.Handle(file.Fd())
overlapped := new(windows.Overlapped)
// A fixed 1-byte region, blocking (no LOCKFILE_FAIL_IMMEDIATELY) so a
// waiter queues rather than failing the operation. Writers pass
// LOCKFILE_EXCLUSIVE_LOCK; readers omit it (a shared lock) so they still
// serialize against a writer's publish — which matters more on Windows,
// where an unsynchronized reader holding the file open blocks the rename.
flags := uint32(0)
if exclusive {
flags = windows.LOCKFILE_EXCLUSIVE_LOCK
}
if err := windows.LockFileEx(handle, flags, 0, 1, 0, overlapped); err != nil {
_ = file.Close()
return nil, fmt.Errorf("credstore: lock: %w", err)
}
return func() error {
// Reported rather than swallowed, for the same reason as the unix side: a
// cleanup that did not complete must not look identical to one that did.
// It matters more here — on Windows the handle staying open is what blocks
// the next writer's rename, so a failed release has a visible consequence.
unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped)
closeErr := file.Close()
if err := errors.Join(unlockErr, closeErr); err != nil {
return fmt.Errorf("credstore: release lock: %w", err)
}
return nil
}, nil
}
+39 -8
View File
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -60,7 +61,15 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
HTTPHeader: http.Header{"Authorization": {"Token " + d.cfg.APIKey}},
})
if err != nil {
return "", fmt.Errorf("connecting to Deepgram: %w", err)
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to Deepgram: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
}
defer conn.CloseNow()
@@ -68,15 +77,26 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
// so no conversion (unlike sherpa-onnx, which wants float32).
writeErrCh := make(chan error, 1)
go func() {
for chunk := range chunks {
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
return
case chunk, ok := <-chunks:
if !ok {
// CloseStream flushes any buffered audio and returns final
// results before Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
return
}
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
writeErrCh <- err
return
}
}
}
// CloseStream flushes any buffered audio and returns final results before
// Deepgram closes the socket.
writeErrCh <- conn.Write(ctx, websocket.MessageText, []byte(`{"type":"CloseStream"}`))
}()
// Deepgram results are per-utterance segments, not cumulative: accumulate the
@@ -100,7 +120,18 @@ func (d *deepgramTranscriber) StreamTranscribe(ctx context.Context, chunks <-cha
}
default:
}
return compose(), fmt.Errorf("Deepgram stream error: %w", err) //nolint:staticcheck // Preserve established user-facing error text.
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
//nolint:staticcheck // Preserve established user-facing error text.
return compose(), fmt.Errorf("Deepgram stream error: %s", providerio.Redact(err.Error(), d.cfg.APIKey))
}
if typ != websocket.MessageText {
continue
@@ -0,0 +1,278 @@
package dictation
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestDeepgramDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to Deepgram") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// dialFailServerWithUpgrade returns a ws URL whose handshake responds 101 with
// Upgrade set to upgradeValue (not "websocket"), so websocket.Dial fails with
// that value in the error string. Hijacks and closes immediately to avoid the
// library's 3s body-drain timeout on failed dials.
func dialFailServerWithUpgrade(t *testing.T, upgradeValue string) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijack not supported", http.StatusInternalServerError)
return
}
conn, bufrw, err := hj.Hijack()
if err != nil {
return
}
defer conn.Close()
_, _ = bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
_, _ = bufrw.WriteString("Connection: Upgrade\r\n")
_, _ = bufrw.WriteString("Upgrade: " + upgradeValue + "\r\n\r\n")
_ = bufrw.Flush()
}))
t.Cleanup(srv.Close)
return "ws" + strings.TrimPrefix(srv.URL, "http")
}
func TestDeepgramStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Drain the client's audio frames, then wait for CloseStream so the
// client has flushed its writes before we reject the connection. Closing
// immediately (before CloseStream) risks the client failing on a write
// error and never observing this API-key-bearing close reason.
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "invalid key sk-test-key")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestDeepgramStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 320)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestDeepgramStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewDeepgramTranscriber(DeepgramConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can race the first write or the first Read; both
// setup/write redaction sites must still yield context.Canceled.
func TestDeepgramStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
// No frames yet: the writer blocks on chunks or the reader blocks on Read.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// TestDeepgramStreamErrorRedactsHeaderShapedKey exercises the same stream-error
// redaction path as TestDeepgramStreamTranscribeErrorRedaction, with a close
// reason that embeds the key next to an X-Api-Key-shaped header label.
func TestDeepgramStreamErrorRedactsHeaderShapedKey(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "X-Api-Key: sk-custom-secret-key-1234567890 failed")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-custom-secret-key-1234567890", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-custom-secret-key-1234567890") {
t.Errorf("Custom header API key leaked: %v", ferr)
}
}
func TestDeepgramSinglePassRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
for {
typ, data, err := c.Read(ctx)
if err != nil {
return
}
if typ == websocket.MessageText && strings.Contains(string(data), "CloseStream") {
break
}
}
c.Close(websocket.StatusPolicyViolation, "error with sk-key1234567890abcdef1234")
})
tr, err := NewDeepgramTranscriber(DeepgramConfig{APIKey: "sk-key1234567890abcdef1234", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 320)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
got := ferr.Error()
if strings.Contains(got, "sk-key1234567890abcdef1234") {
t.Fatalf("API key leaked into stream error: %q", got)
}
// One key occurrence must produce exactly one redaction marker (no double wrap).
if strings.Count(got, "[REDACTED]") != 1 {
t.Fatalf("redaction markers in %q: want exactly 1 [REDACTED]", got)
}
}
@@ -8,6 +8,7 @@ import (
"net/http"
"strings"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/coder/websocket"
)
@@ -22,6 +23,10 @@ type OpenAIRealtimeConfig struct {
Model string // default "gpt-4o-transcribe"
// BaseURL overrides the wss endpoint (tests point it at a fake server).
BaseURL string
// writeErrInjector, when set, is consulted before each JSON websocket write.
// Used by same-package tests to force controllable write failures that embed
// secrets; production callers leave it nil.
writeErrInjector func() error
}
type openAIRealtimeTranscriber struct {
@@ -59,7 +64,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
})
if err != nil {
return "", fmt.Errorf("connecting to OpenAI Realtime: %w", err)
// Preserve the context.Canceled sentinel (it carries no key) so an
// Esc-abort during dial still matches the UI's errors.Is check. Key
// on ctx.Err() itself rather than unwrapping err: a cancellation can
// surface as a plain transport error (e.g. "closed network
// connection") rather than context.Canceled directly.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("connecting to OpenAI Realtime: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
defer conn.CloseNow()
@@ -73,24 +86,53 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
},
},
}
if err := writeJSON(ctx, conn, sessionUpdate); err != nil {
return "", fmt.Errorf("configuring OpenAI Realtime session: %w", err)
if err := o.writeJSON(ctx, conn, sessionUpdate); err != nil {
// Same cancellation short-circuit as the dial above: an Esc-abort
// while the session update is in flight must stay context.Canceled.
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", fmt.Errorf("configuring OpenAI Realtime session: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
writeErrCh := make(chan error, 1)
go func() {
for chunk := range chunks {
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
// Select on ctx.Done() while waiting for the next chunk so a cancelled
// session does not leave this goroutine blocked on an open idle channel.
for {
select {
case <-ctx.Done():
writeErrCh <- ctx.Err()
return
case chunk, ok := <-chunks:
if !ok {
// Commit the buffered audio to force final transcription.
if err := o.writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"}); err != nil {
writeErrCh <- err
// Unblock the reader: without a close, conn.Read stays
// parked on a silent peer and the error is never drained
// from writeErrCh. CloseNow aborts instead of waiting on
// the close handshake, which a silent peer never answers.
conn.CloseNow()
return
}
writeErrCh <- nil
return
}
appendMsg := map[string]any{
"type": "input_audio_buffer.append",
"audio": base64.StdEncoding.EncodeToString(chunk),
}
if err := o.writeJSON(ctx, conn, appendMsg); err != nil {
writeErrCh <- err
// Same unblocking close as the commit path: a server that
// goes silent after session setup would otherwise leave the
// reader blocked in conn.Read with the error undrained.
conn.CloseNow()
return
}
}
}
// Commit the buffered audio to force final transcription of the utterance.
writeErrCh <- writeJSON(ctx, conn, map[string]any{"type": "input_audio_buffer.commit"})
}()
// OpenAI deltas are incremental (append), unlike Deepgram/sherpa's cumulative
@@ -113,7 +155,17 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
}
default:
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %w", err)
// A user abort cancels the streaming context; the UI matches it
// with errors.Is(err, context.Canceled), so return the sentinel
// itself (it carries no key) instead of a flat redacted string.
// Key on ctx.Err() rather than unwrapping err: the writeErrCh
// swap above can replace err with a plain transport error (e.g.
// "closed network connection") that doesn't itself unwrap to
// context.Canceled even though the cancellation is what caused it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(err.Error(), o.cfg.APIKey))
}
if typ != websocket.MessageText {
continue
@@ -125,6 +177,12 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), false)
}
// onPartial may deliver the partial that makes the TUI cancel
// (Esc). Observe that before the next Read, which can already
// have a racing OpenAI error frame buffered.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
case realtimeCompleted:
// A completed item replaces the in-progress delta buffer with the
// server's authoritative transcript for that segment.
@@ -138,6 +196,9 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
if onPartial != nil {
onPartial(compose(), true)
}
if ctx.Err() != nil {
return compose(), ctx.Err()
}
// Once we've committed the buffer (user stopped) and the server has
// returned a completed transcription, the utterance is done.
if committed {
@@ -147,7 +208,15 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case realtimeCommitted:
committed = true
case realtimeError:
return compose(), fmt.Errorf("OpenAI Realtime error: %s", evt.text)
// Esc can race an incoming error event: conn.Read may already have
// the OpenAI error in hand by the time cancellation lands. Key on
// ctx.Err() first, same as every other return point in this loop,
// so a cancel that wins the race still surfaces as context.Canceled
// instead of a spurious redacted failure alongside it.
if ctx.Err() != nil {
return compose(), ctx.Err()
}
return compose(), fmt.Errorf("OpenAI Realtime error: %s", providerio.Redact(evt.text, o.cfg.APIKey))
}
// The writer signals commit completion out of band; observe it so a
// stop with no further audio still flips `committed`.
@@ -155,6 +224,10 @@ func (o *openAIRealtimeTranscriber) StreamTranscribe(ctx context.Context, chunks
case werr := <-writeErrCh:
if werr == nil {
committed = true
} else if ctx.Err() != nil {
return compose(), ctx.Err()
} else {
return compose(), fmt.Errorf("OpenAI Realtime stream error: %s", providerio.Redact(werr.Error(), o.cfg.APIKey))
}
default:
}
@@ -201,6 +274,15 @@ func parseRealtimeEvent(data []byte) realtimeEvent {
return realtimeEvent{kind: realtimeOther}
}
func (o *openAIRealtimeTranscriber) writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
if o.cfg.writeErrInjector != nil {
if err := o.cfg.writeErrInjector(); err != nil {
return err
}
}
return writeJSON(ctx, conn, v)
}
func writeJSON(ctx context.Context, conn *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
@@ -0,0 +1,437 @@
package dictation
import (
"context"
"errors"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/coder/websocket"
)
// Non-cancellation dial failure: handshake returns 101 with an Upgrade header
// equal to the API key so the dial error embeds the key and redaction must strip it.
func TestOpenAIRealtimeDialFailureRedactsKey(t *testing.T) {
const key = "sk-test-dial-key-abcdef"
baseURL := dialFailServerWithUpgrade(t, key)
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: key, BaseURL: baseURL})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected dial failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("dial failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from dial failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "connecting to OpenAI Realtime") {
t.Errorf("expected dial-path error prefix, got: %v", ferr)
}
}
// Session-configuration write fails after dial: inject a write error that embeds
// the API key (peer close cannot reach this path reliably — the session write
// always lands in the kernel buffer first) and assert redaction.
func TestOpenAIRealtimeSessionConfigFailureRedactsKey(t *testing.T) {
const key = "sk-test-session-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold open; the client fails on the injected session write before reading.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, nil)
if ferr == nil {
t.Fatal("expected session-config failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("session-config failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from session-config failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "configuring OpenAI Realtime session") {
t.Errorf("expected session-config error prefix, got: %v", ferr)
}
}
// Asynchronous writer failure: session update succeeds, then the audio writer
// injects a key-bearing write error. The server keeps the connection open and
// emits a delta so the reader loop observes writeErrCh (not a peer close).
func TestOpenAIRealtimeWriterFailureRedactsKey(t *testing.T) {
const key = "sk-test-writer-key-abcdef"
sessionDone := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionDone)
// Keep reading and emit a delta so the client processes an event and
// drains writeErrCh while the connection is still open.
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail subsequent audio/commit writes.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave open after the first frame so the writer hits the injected error
// on that append rather than a clean commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
done <- ferr
}()
var ferr error
select {
case ferr = <-done:
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not complete after writer failure")
}
close(chunks)
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
select {
case <-sessionDone:
default:
// Session may race the injected audio write; prefix assertion above is enough.
}
}
// Writer failure with a silent server: the peer never sends a frame after
// session setup, so the reader is parked in conn.Read. The writer must close
// the connection on failure or the writeErrCh error is never observed and
// StreamTranscribe blocks forever.
func TestOpenAIRealtimeWriterFailureUnblocksSilentServer(t *testing.T) {
const key = "sk-test-silent-writer-key-abcdef"
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open without ever writing a frame.
<-ctx.Done()
})
var writes atomic.Int32
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: key,
BaseURL: url,
writeErrInjector: func() error {
// First write is session.update; fail the subsequent append write.
if writes.Add(1) == 1 {
return nil
}
return errors.New("invalid API key " + key)
},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
// Leave the channel open: the writer must fail on the append, not the commit.
done := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {})
done <- ferr
}()
select {
case ferr := <-done:
if ferr == nil {
t.Fatal("expected writer failure")
}
if errors.Is(ferr, context.Canceled) {
t.Fatalf("writer failure should not be context.Canceled: %v", ferr)
}
if strings.Contains(ferr.Error(), key) {
t.Errorf("API key leaked from writer failure: %v", ferr)
}
if !strings.Contains(ferr.Error(), "OpenAI Realtime stream error") {
t.Errorf("expected stream/writer error prefix, got: %v", ferr)
}
case <-time.After(5 * time.Second):
close(chunks)
t.Fatal("StreamTranscribe did not unblock after writer failure with a silent server")
}
}
func TestOpenAIRealtimeStreamTranscribeErrorRedaction(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
defer c.Close(websocket.StatusNormalClosure, "")
for {
typ, _, err := c.Read(ctx)
if err != nil {
return
}
if typ != websocket.MessageText {
continue
}
_ = c.Write(ctx, websocket.MessageText, []byte(`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`))
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
close(chunks)
_, ferr := tr.StreamTranscribe(context.Background(), chunks, func(string, bool) {})
if ferr == nil {
t.Fatal("expected error")
}
if strings.Contains(ferr.Error(), "sk-test-key") {
t.Errorf("API key leaked: %v", ferr)
}
}
func TestOpenAIRealtimeStreamTranscribeCancelKeepsSentinel(t *testing.T) {
firstFrame := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
// Hold the connection open, never answering, so the client blocks in
// Read until its context is cancelled (the Esc-abort path).
var once sync.Once
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
once.Do(func() { close(firstFrame) })
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
// The channel stays open: the session is live when the user aborts.
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-firstFrame:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("cancelled stream error lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can cancel while the WebSocket dial is still pending. The dial error is
// redacted with %s, so we must return ctx.Err() rather than a flat string.
func TestOpenAIRealtimeStreamTranscribeDialCancelKeepsSentinel(t *testing.T) {
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{
APIKey: "sk-test-key",
BaseURL: "ws://127.0.0.1:1", // never reached; ctx is already cancelled
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
chunks := make(chan []byte)
defer close(chunks)
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("dial-cancel lost the context.Canceled sentinel: %v", ferr)
}
}
// Esc right after accept can hit the session-update write or the first Read;
// both redaction sites must still yield context.Canceled.
func TestOpenAIRealtimeStreamTranscribeStartupCancelKeepsSentinel(t *testing.T) {
accepted := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
close(accepted)
// Do not read: leave the client in session-update write or first Read.
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-accepted:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("startup-cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed before accept: %v", ferr)
}
}
// Cancel while the writer is blocked on more chunks. Whichever path observes
// the cancellation first (the blocked conn.Read or the writeErrCh drain) must
// return context.Canceled rather than a redacted flat string.
func TestOpenAIRealtimeStreamTranscribeWriteCancelKeepsSentinel(t *testing.T) {
sessionReceived := make(chan struct{})
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
close(sessionReceived)
for {
if _, _, err := c.Read(ctx); err != nil {
return
}
}
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
chunks <- make([]byte, 480)
defer close(chunks)
errCh := make(chan error, 1)
go func() {
_, ferr := tr.StreamTranscribe(ctx, chunks, nil)
errCh <- ferr
}()
select {
case <-sessionReceived:
cancel()
ferr := <-errCh
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("write-path cancel lost the context.Canceled sentinel: %v", ferr)
}
case ferr := <-errCh:
t.Fatalf("StreamTranscribe failed early instead of blocking: %v", ferr)
}
}
// Esc can race an incoming OpenAI error event. conn.Read may already have
// the error frame in hand by the time the cancellation lands. The server
// fires a delta immediately followed by the error, back to back. Cancel from
// inside the delta callback (same path the TUI uses when a partial triggers
// Esc handling) so the cancel is observed before the next event is processed.
// The returned error must still be context.Canceled, not the redacted OpenAI
// error.
func TestOpenAIRealtimeStreamTranscribeErrorRaceKeepsSentinel(t *testing.T) {
url := wsTestServer(t, func(ctx context.Context, c *websocket.Conn) {
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"conversation.item.input_audio_transcription.delta","delta":"hi"}`,
))
_ = c.Write(ctx, websocket.MessageText, []byte(
`{"type":"error","error":{"message":"invalid API key sk-test-key"}}`,
))
<-ctx.Done()
})
tr, err := NewOpenAIRealtimeTranscriber(OpenAIRealtimeConfig{APIKey: "sk-test-key", BaseURL: url})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
chunks := make(chan []byte, 1)
defer close(chunks)
chunks <- make([]byte, 480)
var once sync.Once
_, ferr := tr.StreamTranscribe(ctx, chunks, func(string, bool) {
// Cancel synchronously from the partial callback, before the
// stream loop continues to the already-buffered error frame.
once.Do(cancel)
})
if !errors.Is(ferr, context.Canceled) {
t.Fatalf("StreamTranscribe error = %v, want context.Canceled (cancel must win over a racing OpenAI error event)", ferr)
}
}
+125 -6
View File
@@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"syscall"
"time"
)
@@ -38,12 +40,45 @@ func KillProcessTree(pid int) error {
// TerminateProcessTree requests graceful termination, then force-kills the
// process tree after grace. Callers retain their distinct persistence models;
// this function owns only the OS lifecycle primitive.
//
// The signal target is REDISCOVERED here via processSignalTarget(pid), which
// asks the OS for pid's current group. A caller that already knows pid was
// configured as its own group leader at launch (e.g. via ConfigureProcessGroup)
// should use TerminateProcessGroup instead — see its doc comment for why this
// rediscovery is fragile once the leader may have already exited.
func TerminateProcessTree(pid int, grace, poll time.Duration) error {
target, err := processSignalTarget(pid)
if err != nil {
return err
}
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
return terminateTarget(pid, target, grace, poll)
}
// TerminateProcessGroup is like TerminateProcessTree, but for a caller that
// already knows pid is (or was, at launch) its own process-group leader — e.g.
// a command started after ConfigureProcessGroup(cmd), which sets Setpgid so
// cmd.Process.Pid always leads its own group at the moment it is signalled.
//
// TerminateProcessTree instead rediscovers the group via syscall.Getpgid(pid)
// at call time. On Darwin, that lookup can return ESRCH once an unreaped group
// leader has already exited, even though live descendants remain in the group
// it configured — TerminateProcessTree would then silently fall back to
// signalling only the (already dead) leader PID and leave those descendants
// running. Skipping rediscovery in favor of the launch-time invariant avoids
// that: the negative PID is used directly, regardless of whether the leader is
// still alive to be looked up.
func TerminateProcessGroup(pid int, grace, poll time.Duration) error {
if pid <= 1 {
return fmt.Errorf("refusing to signal invalid pid %d", pid)
}
return terminateTarget(pid, -pid, grace, poll)
}
// terminateTarget signals target (an individual PID or, negative, a process
// group) with SIGTERM then, after grace, SIGKILL. reportPid identifies the
// original process in error messages regardless of which form target takes.
func terminateTarget(reportPid, target int, grace, poll time.Duration) error {
alive := func() bool { return signalTargetRunning(target) }
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
if errors.Is(err, syscall.ESRCH) {
return nil
@@ -77,11 +112,91 @@ func TerminateProcessTree(pid int, grace, poll time.Duration) error {
time.Sleep(poll)
}
if alive() {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
return fmt.Errorf("process %d did not exit after SIGKILL", reportPid)
}
return nil
}
// signalTargetRunning reports whether the signal target still has a process that
// is actually RUNNING, as opposed to one waiting to be reaped.
//
// kill(2) with signal 0 succeeds for a zombie: the PID still exists until its
// parent collects it. That matters because the target here is usually a process
// GROUP, and a terminated leader's child is briefly a zombie before init/launchd
// reaps it. Treating that window as "still alive" made termination sit through
// both grace periods, SIGKILL an already-dead group, and then report
// "did not exit after SIGKILL" for a tree that had in fact stopped — turning a
// successful cleanup into a spurious failure (and a flaky one, since it depends on
// reap timing).
//
// Zombie detection goes through ps, which is available on every Unix we target and
// avoids a /proc dependency Darwin does not have. It is only consulted when the
// cheap kill check says something is still there, so the common path costs nothing
// extra. If ps cannot be run or its output cannot be parsed, the conservative
// pre-existing answer (the kill result) stands.
func signalTargetRunning(target int) bool {
if syscall.Kill(target, syscall.Signal(0)) != nil {
return false
}
// A negative target is a process GROUP: check whether any member is
// non-zombie. A positive target is an individual PID that is NOT its own
// group leader (processSignalTarget's fallback) — its actual PGID differs
// from its own PID, so matching rows by PGID here would look for a group
// that doesn't exist and always report "unknown" (conservatively alive).
// Check its own PID's state instead.
var running, ok bool
if target < 0 {
running, ok = processGroupHasRunningMember(-target)
} else {
running, ok = processIsRunning(target)
}
if !ok {
return true
}
return running
}
// processGroupHasRunningMember reports whether any member of pgid is in a state
// other than zombie. ok is false when the group's states could not be determined.
func processGroupHasRunningMember(pgid int) (running bool, ok bool) {
return processTableStateMatches(func(_, rowPgid int) bool { return rowPgid == pgid })
}
// processIsRunning reports whether pid itself is in a state other than zombie.
// ok is false when pid's state could not be determined.
func processIsRunning(pid int) (running bool, ok bool) {
return processTableStateMatches(func(rowPid, _ int) bool { return rowPid == pid })
}
// processTableStateMatches scans the process table once, via ps, and reports
// whether any row satisfying match is non-zombie, and whether at least one
// matching row was found at all (ok). No rows found means either the process
// (or group) genuinely doesn't exist (a race with exit) or ps's output was
// unparseable/restricted; either way, the caller should not claim knowledge.
func processTableStateMatches(match func(pid, pgid int) bool) (running bool, ok bool) {
output, err := exec.Command("ps", "-A", "-o", "pid=,pgid=,stat=").Output()
if err != nil {
return false, false
}
found := false
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
rowPid, errPid := strconv.Atoi(fields[0])
rowPgid, errPgid := strconv.Atoi(fields[1])
if errPid != nil || errPgid != nil || !match(rowPid, rowPgid) {
continue
}
found = true
if !strings.HasPrefix(fields[2], "Z") {
return true, true
}
}
return false, found
}
func processSignalTarget(pid int) (int, error) {
if pid <= 1 {
return 0, fmt.Errorf("refusing to signal invalid pid %d", pid)
@@ -91,10 +206,14 @@ func processSignalTarget(pid int) (int, error) {
if pgid == pid {
target = -pid
}
} else if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
} else {
if errors.Is(err, syscall.ESRCH) {
// Preserve the individual target; the signal call below treats ESRCH as
// already gone, which is a successful lifecycle outcome.
return pid, nil
}
// Conservatively retain the individual PID after other lookup failures;
// guessing and signalling a process group could affect unrelated processes.
}
return target, nil
}
+133
View File
@@ -0,0 +1,133 @@
//go:build !windows
package execution
import (
"os/exec"
"strconv"
"syscall"
"testing"
"time"
)
// TestTerminateProcessTreeTreatsZombieGroupAsExited is the regression test for
// counting unreaped processes as alive: kill(2) with signal 0 succeeds for a
// zombie, so a group whose only remaining member is waiting to be reaped used to
// sit through both grace periods, take a pointless SIGKILL, and then be reported
// as "did not exit after SIGKILL" — a spurious cleanup failure whose timing
// depended on when the reaper ran.
func TestTerminateProcessTreeTreatsZombieGroupAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
// Deliberately do NOT Wait: the child stays a zombie in its own group, which
// is exactly the state a terminated tree passes through.
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
if syscall.Kill(-pid, syscall.Signal(0)) != nil {
t.Skip("the zombie group is no longer signalable; cannot exercise the case")
}
if signalTargetRunning(-pid) {
t.Fatal("a group holding only a zombie must not count as running")
}
if err := TerminateProcessTree(pid, 50*time.Millisecond, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree on an already-exited tree: %v", err)
}
}
// TestTerminateProcessTreeStopsRunningGroup is the positive control: a group with
// a live member must still be seen as running and then actually stopped, so the
// zombie tolerance above cannot degrade into ignoring real processes.
func TestTerminateProcessTreeStopsRunningGroup(t *testing.T) {
cmd := exec.Command("sh", "-c", "sleep 30")
ConfigureProcessGroup(cmd)
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
defer func() { _ = cmd.Wait() }()
if !signalTargetRunning(-pid) {
t.Fatal("a group with a live member must count as running")
}
if err := TerminateProcessTree(pid, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("TerminateProcessTree: %v", err)
}
if running, ok := processGroupHasRunningMember(pid); ok && running {
t.Fatal("the group still has a running member after termination")
}
}
// TestSignalTargetRunningTreatsZombieIndividualPIDAsExited is the regression
// test for jatmn's second #774 finding: signalTargetRunning's zombie check
// matched the individual-PID fallback target (a process that is NOT its own
// group leader — processSignalTarget's positive-PID case) against process-table
// rows by PGID. That target's actual PGID differs from its own PID by
// definition (that's exactly why processSignalTarget fell back to the
// individual PID instead of a negative group target), so no row ever matched,
// "unknown" resulted every time, and signalTargetRunning conservatively (and
// incorrectly) treated a zombie individual-PID target as still running.
func TestSignalTargetRunningTreatsZombieIndividualPIDAsExited(t *testing.T) {
cmd := exec.Command("sh", "-c", "exit 0")
// Deliberately do NOT call ConfigureProcessGroup: the child inherits this
// test process's group, so it is not its own leader and processSignalTarget
// falls back to the individual (positive) PID.
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
pid := cmd.Process.Pid
t.Cleanup(func() { _ = cmd.Wait() })
deadline := time.Now().Add(5 * time.Second)
for {
if zombie, ok := processIsZombie(pid); ok && zombie {
break
}
if time.Now().After(deadline) {
t.Skip("child did not reach the zombie state; ps output is unavailable in this environment")
}
time.Sleep(10 * time.Millisecond)
}
target, err := processSignalTarget(pid)
if err != nil {
t.Fatalf("processSignalTarget: %v", err)
}
if target != pid {
t.Skip("child unexpectedly became its own group leader; cannot exercise the individual-PID fallback")
}
if signalTargetRunning(target) {
t.Fatal("a zombie individual-PID target must not count as running")
}
}
// processIsZombie reports a process's zombie state via ps. ok is false when the
// state could not be read.
func processIsZombie(pid int) (zombie bool, ok bool) {
output, err := exec.Command("ps", "-o", "stat=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return false, false
}
state := string(output)
for len(state) > 0 && (state[0] == ' ' || state[0] == '\n' || state[0] == '\t') {
state = state[1:]
}
if state == "" {
return false, false
}
return state[0] == 'Z', true
}
+27 -1
View File
@@ -3,6 +3,8 @@
package execution
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -12,10 +14,34 @@ import (
func ConfigureProcessGroup(cmd *exec.Cmd) {}
// KillProcessTree asks taskkill /T to kill pid and descendants it can still
// discover from that live root. If taskkill fails, it falls back to killing pid
// alone but still returns the tree error because descendants may have survived.
// Neither path can recover descendants after the root has exited.
func KillProcessTree(pid int) error {
if err := exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
return killProcessTree(pid, runTaskkill, killRootProcess)
}
func killProcessTree(pid int, killTree, killRoot func(int) error) error {
treeErr := killTree(pid)
if treeErr == nil {
return nil
}
rootErr := killRoot(pid)
if rootErr != nil {
return errors.Join(
fmt.Errorf("kill process tree rooted at %d: %w", pid, treeErr),
fmt.Errorf("kill root process %d: %w", pid, rootErr),
)
}
return fmt.Errorf("kill process tree rooted at %d (root process killed directly): %w", pid, treeErr)
}
func runTaskkill(pid int) error {
return exec.Command(taskkillPath(), "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
}
func killRootProcess(pid int) error {
process, err := os.FindProcess(pid)
if err != nil {
return err
@@ -0,0 +1,29 @@
//go:build windows
package execution
import (
"errors"
"strings"
"testing"
)
func TestKillProcessTreeRetainsTreeErrorWhenRootFallbackSucceeds(t *testing.T) {
treeErr := errors.New("taskkill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return nil })
if !errors.Is(err, treeErr) {
t.Fatalf("killProcessTree error = %v, want tree error", err)
}
if !strings.Contains(err.Error(), "root process killed directly") {
t.Fatalf("killProcessTree error = %q, want fallback context", err)
}
}
func TestKillProcessTreeJoinsTreeAndRootErrors(t *testing.T) {
treeErr := errors.New("taskkill failed")
rootErr := errors.New("root kill failed")
err := killProcessTree(42, func(int) error { return treeErr }, func(int) error { return rootErr })
if !errors.Is(err, treeErr) || !errors.Is(err, rootErr) {
t.Fatalf("killProcessTree error = %v, want both failures", err)
}
}
+177
View File
@@ -0,0 +1,177 @@
// Package pathjail confines file operations to a directory tree.
//
// It exists because the obvious guard does not work. Checking a path with
// Lstat and then handing the same string to MkdirAll, CreateTemp, Rename or
// Remove is two independent resolutions with a gap between them, and every
// operation after the check re-resolves the ANCESTORS as well. A store that
// verified only its own directory and file was therefore writable outside its
// tree whenever any component above them was a link: the check passed, and the
// syscall that followed went somewhere else.
//
// On Windows the same guard failed for a second, independent reason. A junction
// is a reparse point but NOT a symlink, so os.Lstat reports it as ModeIrregular
// with ModeSymlink clear, and a guard testing only ModeSymlink returns "not a
// link" when pointed straight at one. Junctions also need no privilege to
// create, unlike Windows symlinks, so this is reachable by exactly the
// unprivileged user a workspace guard is meant to contain.
//
// The fix for both is to stop naming things by path. Open a handle on the root
// once and address everything below it relative to that handle, so the kernel
// resolves each component against an object that cannot be swapped underneath.
// os.Root is that handle: it refuses any component that would escape, including
// a Windows reparse point, and it is the same idea the sandbox uses for ACL
// materialization.
package pathjail
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// ErrEscapes reports a target that is not inside the root it was confined to.
// Returned before anything is opened, so a caller cannot act on the path first.
var ErrEscapes = errors.New("path escapes its root")
// ErrReparse reports a link or reparse point where a real file or directory was
// required.
var ErrReparse = errors.New("refusing to write through a link")
// Open confines dir to root and returns a handle on root plus dir expressed
// relative to it. The caller closes the handle.
//
// root is resolved normally, because it is the boundary the caller chose and
// trusts (a workspace the operator opened, say, which may legitimately sit under
// a link). Everything BELOW it is resolved against the returned handle and
// cannot escape.
func Open(root, dir string) (*os.Root, string, error) {
// Only a genuinely EMPTY value is treated as missing; every other spelling is
// used verbatim. Testing TrimSpace(...) == "" instead would retarget the very
// paths this guards: " " is a legal directory name on unix, so trimming would
// reject it as "no root" or silently swap dir for root, which is the same
// retargeting bug as trimming "root " down to "root".
if root == "" {
return nil, "", fmt.Errorf("%w: no root to confine %q to", ErrEscapes, dir)
}
if dir == "" {
dir = root
}
// Rel before Open, so an escaping target is refused without opening
// anything. filepath.Rel is lexical, which is the right test here: it says
// what the caller ASKED for, and the handle enforces what actually happens.
relative, err := filepath.Rel(root, dir)
if err != nil {
return nil, "", fmt.Errorf("%w: %q against %q: %v", ErrEscapes, dir, root, err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, "", fmt.Errorf("%w: %q is outside %q", ErrEscapes, dir, root)
}
// The root is created if it is missing, by ordinary pathname, because it is
// the boundary the caller declared rather than anything being confined. The
// stores this replaced called MkdirAll on the whole tree, so this creates
// strictly less by pathname than before, and everything BELOW root is opened
// relative to the handle.
if err := os.MkdirAll(root, 0o700); err != nil {
return nil, "", fmt.Errorf("create %s: %w", root, err)
}
handle, err := os.OpenRoot(root)
if err != nil {
return nil, "", fmt.Errorf("open %s: %w", root, err)
}
return handle, relative, nil
}
// RefuseReparse fails when relative names a link or reparse point.
//
// Absent is fine: a name that does not exist yet is what a create is for.
//
// This is belt to os.Root's braces. The handle already refuses to traverse a
// reparse point, so this exists to give the FINAL component a clear error
// instead of an "escapes from parent" one, and to keep the refusal explicit at
// the point a reader looks for it.
//
// ModeIrregular is tested alongside ModeSymlink deliberately: that is what a
// Windows junction reports, and a guard checking only ModeSymlink lets one
// straight through.
func RefuseReparse(handle *os.Root, relative string) error {
// A trailing separator makes os.Root.Lstat resolve the terminal component on
// Windows (doInRootAlwaysResolveTerminalSlash), so a junction named with a
// trailing slash would be stat'd as its target and wave through as "not a
// link". Strip it so the final component itself is the thing inspected.
//
// os.IsPathSeparator rather than a literal `/\` set, because backslash is a
// separator only on Windows: on unix it is an ordinary filename character, and
// trimming it would inspect "linked" when asked about a symlink genuinely
// named `linked\` — reporting "not a link" for one that is.
if trimmed := trimTrailingSeparators(relative); trimmed != "" {
relative = trimmed
}
info, err := handle.Lstat(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf("inspect %s: %w", relative, err)
}
if info.Mode()&(os.ModeSymlink|os.ModeIrregular) != 0 {
return fmt.Errorf("%w: %s", ErrReparse, relative)
}
return nil
}
// trimTrailingSeparators drops the path separators at the end of a name, using
// the ACTIVE platform's definition of one: `/` and `\` on Windows, `/` only on
// unix, where a backslash is a legal character in a filename.
func trimTrailingSeparators(path string) string {
end := len(path)
for end > 0 && os.IsPathSeparator(path[end-1]) {
end--
}
return path[:end]
}
// randomBytes fills b with cryptographic randomness. A variable rather than a
// direct crypto/rand call so a test can force the name collision that exercises
// the O_EXCL retry below — the branch that matters, and one a real random source
// will not reach on demand.
var randomBytes = rand.Read
// CreateTemp makes an O_EXCL temp file named prefix.<random>suffix inside dir,
// relative to handle, and returns it with its relative path.
//
// O_EXCL rather than a fixed name: a link planted at a predictable temp path
// between the check and the create is the same race this package exists to
// close, and O_EXCL makes the kernel refuse it rather than us.
func CreateTemp(handle *os.Root, dir, prefix, suffix string) (*os.File, string, error) {
// prefix and suffix are NAME fragments, not path fragments. A separator or a
// "."/".." in either would relocate the file out of dir — a prefix of
// "../escaped", or a suffix of ".tmp/../evil" that also erases the random
// component and with it the O_EXCL unpredictability the file relies on.
// Reject them up front so the file lands where dir promises.
for _, fragment := range []string{prefix, suffix} {
if strings.ContainsAny(fragment, `/\`) || fragment == "." || fragment == ".." {
return nil, "", fmt.Errorf("%w: temporary name fragment %q must be a single path component", ErrEscapes, fragment)
}
}
for attempt := 0; attempt < 10; attempt++ {
noise := make([]byte, 8)
if _, err := randomBytes(noise); err != nil {
return nil, "", fmt.Errorf("generate a temporary name: %w", err)
}
relative := filepath.Join(dir, prefix+"."+hex.EncodeToString(noise)+suffix)
file, err := handle.OpenFile(relative, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err == nil {
return file, relative, nil
}
if errors.Is(err, fs.ErrExist) {
continue
}
return nil, "", err
}
return nil, "", errors.New("could not create a temporary file with an unused name")
}
+360
View File
@@ -0,0 +1,360 @@
package pathjail
import (
"encoding/hex"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// linkDir points link at target, using whatever kind of link this platform lets
// an unprivileged process create.
//
// On Windows that is a JUNCTION, not a symlink, and the difference is the whole
// reason this package exists twice over. A symlink needs
// SeCreateSymbolicLinkPrivilege, which an ordinary account does not hold, so a
// test written with os.Symlink silently skips on Windows and proves nothing
// there. A junction needs no privilege at all, so it is both the reachable
// attack and the only one that can be tested.
func linkDir(t *testing.T, target, link string) {
t.Helper()
if runtime.GOOS == "windows" {
if out, err := exec.Command("cmd", "/c", "mklink", "/J", link, target).CombinedOutput(); err != nil {
t.Skipf("cannot create a junction: %v %s", err, out)
}
return
}
if err := os.Symlink(target, link); err != nil {
t.Skipf("cannot create a symlink: %v", err)
}
}
// The reported defect: a link in an ANCESTOR of the target, not at the target
// itself. Every guard that checked only the final directory and file passed
// this, because the components it checked were genuinely not links; the ones
// the syscall then traversed were.
func TestAnAncestorLinkCannotRedirectAWrite(t *testing.T) {
base := t.TempDir()
outside := filepath.Join(base, "outside")
workspace := filepath.Join(base, "workspace")
for _, dir := range []string{outside, workspace} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, outside, filepath.Join(workspace, ".zero"))
handle, relative, err := Open(workspace, filepath.Join(workspace, ".zero", "store"))
if err != nil {
t.Fatalf("Open should confine, not refuse outright: %v", err)
}
defer handle.Close()
if err := handle.MkdirAll(relative, 0o700); err == nil {
t.Fatal("created a directory through a linked ancestor, so a write can still leave the workspace")
}
if _, err := os.Stat(filepath.Join(outside, "store")); !os.IsNotExist(err) {
t.Errorf("something was created outside the workspace, stat error = %v", err)
}
}
// RefuseReparse has to catch a junction, which is the case os.ModeSymlink alone
// misses. Asserted through the exported helper rather than by reading the mode,
// so the test fails if the predicate is ever narrowed back.
func TestRefuseReparseCatchesAJunctionNotJustASymlink(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
err = RefuseReparse(handle, filepath.Join(relative, "linked"))
if !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(link) = %v, want ErrReparse; on Windows a junction reports ModeIrregular, so a ModeSymlink-only check returns nil here", err)
}
}
// A name that does not exist yet is what a create is for, so it must not be
// refused. Without this the guard above would be satisfied by refusing
// everything.
func TestRefuseReparseAllowsAnAbsentName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
if err := RefuseReparse(handle, filepath.Join(relative, "not-there.md")); err != nil {
t.Errorf("an absent name was refused: %v", err)
}
if err := RefuseReparse(handle, relative); err != nil {
t.Errorf("an ordinary directory was refused: %v", err)
}
}
// Open refuses a target outside its root before opening anything, so a caller
// cannot act on the path first.
func TestOpenRefusesATargetOutsideTheRoot(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
for _, target := range []string{
filepath.Join(base, "sibling"),
filepath.Join(inside, "..", "..", "escape"),
} {
if _, _, err := Open(inside, target); !errors.Is(err, ErrEscapes) {
t.Errorf("Open(%q, %q) = %v, want ErrEscapes", inside, target, err)
}
}
if _, _, err := Open("", filepath.Join(base, "anything")); !errors.Is(err, ErrEscapes) {
t.Error("an empty root was accepted; a boundary is not optional")
}
}
// CreateTemp must use O_EXCL and an unpredictable name, so a link planted at a
// guessable temp path is refused by the kernel rather than followed.
func TestCreateTempIsExclusiveAndUnpredictable(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
seen := map[string]bool{}
for i := 0; i < 8; i++ {
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
file.Close()
if seen[name] {
t.Fatalf("CreateTemp reused the name %q, so two concurrent writes would stomp each other", name)
}
seen[name] = true
if filepath.Ext(name) != ".tmp" {
t.Errorf("temp name %q lost its suffix, so a listing may pick it up as real content", name)
}
}
}
// A legitimate directory whose name carries a space must be confined as spelled.
// Trimming it would open a different filesystem object.
//
// A LEADING space, deliberately: Win32 normalizes a component's trailing spaces
// and dots away, so "root " and "root" are one object there and a trailing-space
// case cannot express the bug on Windows. A leading space survives on every
// platform, so this runs everywhere rather than being gated to unix.
// The path is RELATIVE, under t.Chdir, so the leading space sits at the start of
// the whole string. An absolute filepath.Join(base, " root") would put the space
// in the middle, where TrimSpace cannot reach it — the test would then pass
// against the very guard it exists to catch, on every platform.
func TestOpenPreservesSpaceInPaths(t *testing.T) {
base := t.TempDir()
t.Chdir(base)
const spaced = " root" // the leading space is part of the name
handle, relative, err := Open(spaced, spaced)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
// Write through the handle, then confirm the bytes landed under the spelling
// the caller asked for — the property that actually matters, and one
// handle.Name() alone would not prove.
file, _, err := CreateTemp(handle, relative, "probe", ".tmp")
if err != nil {
t.Fatal(err)
}
name := file.Name()
if err := file.Close(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(base, spaced, filepath.Base(name))); err != nil {
t.Errorf("a file created through the handle is not under %q: %v — the leading space was trimmed away", spaced, err)
}
if _, err := os.Stat(filepath.Join(base, "root")); err == nil {
t.Error(`a trimmed "root" was created instead of the requested " root"`)
}
}
// A whitespace-only name is a legal directory on unix, so it must be confined as
// given rather than treated as "no root" or silently swapped for the root.
func TestOpenAcceptsAWhitespaceOnlyComponent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Win32 normalizes a whitespace-only component away; the name is not expressible there")
}
// The path must be whitespace-only IN FULL for this to bite, so it has to be
// relative: an absolute path ending in " " still has a non-empty TrimSpace and
// would sail past the old guard, making the test prove nothing.
t.Chdir(t.TempDir())
handle, _, err := Open(" ", " ")
if err != nil {
t.Fatalf("a whitespace-only path was rejected as missing: %v", err)
}
defer handle.Close()
if _, err := os.Stat(" "); err != nil {
t.Errorf("the whitespace-only directory was not created as spelled: %v", err)
}
}
// prefix and suffix are name fragments. A separator or traversal in either must
// be refused, or the file lands outside dir and (for a suffix erasing the random
// component) loses the O_EXCL unpredictability it depends on.
func TestCreateTempRejectsPathFragmentsThatEscapeTheDir(t *testing.T) {
base := t.TempDir()
inside := filepath.Join(base, "inside")
if err := os.MkdirAll(inside, 0o700); err != nil {
t.Fatal(err)
}
// root is base, dir is the subdirectory: a "../escaped" fragment lands in
// base — still inside the os.Root, so os.Root does NOT catch it — but outside
// the dir CreateTemp promised. This is the case the reviewers reproduced.
handle, relative, err := Open(base, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
cases := []struct{ prefix, suffix string }{
{"../escaped", ".tmp"},
{`..\sibling`, ".tmp"},
{"note", ".tmp/../evil"},
{"note", ".."},
{"..", ".tmp"},
}
for _, c := range cases {
if _, _, err := CreateTemp(handle, relative, c.prefix, c.suffix); !errors.Is(err, ErrEscapes) {
t.Errorf("CreateTemp(prefix=%q, suffix=%q) = %v, want ErrEscapes", c.prefix, c.suffix, err)
}
}
// Nothing may have leaked into base above the promised dir.
entries, _ := os.ReadDir(base)
for _, entry := range entries {
if entry.Name() != "inside" {
t.Errorf("a temp fragment escaped into the parent: %q", entry.Name())
}
}
}
// A junction named with a trailing separator must still be refused. On Windows
// os.Root.Lstat resolves the terminal component when the name ends in a
// separator, so without trimming it the link is stat'd as its target and waved
// through.
func TestRefuseReparseCatchesALinkNamedWithATrailingSeparator(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, "linked"))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, "linked") + string(filepath.Separator)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse", name, err)
}
}
// The O_EXCL retry is the branch that protects an existing file, and a real
// random source will not collide on demand — so force it. The first two draws
// return the same bytes: the name is pre-created, CreateTemp must refuse to
// reuse it, leave its contents alone, and come back with a different name.
func TestCreateTempRetriesWithoutClobberingAnExistingName(t *testing.T) {
base := t.TempDir()
handle, relative, err := Open(base, base)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
draws := [][]byte{
{1, 1, 1, 1, 1, 1, 1, 1}, // collides with the pre-created file
{1, 1, 1, 1, 1, 1, 1, 1}, // and again, so the retry must keep trying
{2, 2, 2, 2, 2, 2, 2, 2}, // finally a free name
}
previous := randomBytes
t.Cleanup(func() { randomBytes = previous })
call := 0
randomBytes = func(b []byte) (int, error) {
copy(b, draws[min(call, len(draws)-1)])
call++
return len(b), nil
}
taken := filepath.Join(base, "note."+hex.EncodeToString(draws[0])+".tmp")
if err := os.WriteFile(taken, []byte("do not clobber me"), 0o600); err != nil {
t.Fatal(err)
}
file, name, err := CreateTemp(handle, relative, "note", ".tmp")
if err != nil {
t.Fatal(err)
}
defer file.Close()
if filepath.Base(name) == filepath.Base(taken) {
t.Fatalf("CreateTemp returned the name that was already taken (%q), so O_EXCL did not protect it", name)
}
kept, err := os.ReadFile(taken)
if err != nil {
t.Fatal(err)
}
if string(kept) != "do not clobber me" {
t.Errorf("the pre-existing file was overwritten: %q", kept)
}
if call < 2 {
t.Errorf("the random source was drawn %d time(s); the collision path never ran", call)
}
}
// On unix a backslash is an ordinary filename character, not a separator, so a
// link genuinely named `linked\` must still be refused. Trimming backslashes
// unconditionally would inspect "linked" instead and report "not a link" for one
// that is.
func TestRefuseReparseKeepsABackslashInAUnixName(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip(`backslash is a separator on Windows, so this name is not expressible there`)
}
base := t.TempDir()
target := filepath.Join(base, "target")
inside := filepath.Join(base, "inside")
for _, dir := range []string{target, inside} {
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
}
linkDir(t, target, filepath.Join(inside, `linked\`))
handle, relative, err := Open(inside, inside)
if err != nil {
t.Fatal(err)
}
defer handle.Close()
name := filepath.Join(relative, `linked\`)
if err := RefuseReparse(handle, name); !errors.Is(err, ErrReparse) {
t.Fatalf("RefuseReparse(%q) = %v, want ErrReparse — the backslash is part of the name here, not a separator", name, err)
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func windowsTokenOwner(token windows.Token) (*windows.SID, error) {
}
owner := (*windowsTokenOwnerInfo)(unsafe.Pointer(&buffer[0])).owner
if owner == nil {
return nil, errors.New("Windows access token has no default owner")
return nil, errors.New("windows access token has no default owner")
}
return owner.Copy()
}
+4
View File
@@ -184,6 +184,10 @@ func parseOpenAIStyleModelList(body []byte) ([]remoteModel, error) {
func ModelsDevProviderID(provider providercatalog.Descriptor) string {
switch strings.TrimSpace(provider.ID) {
case "chatgpt":
// ChatGPT authenticates against the Codex backend, while its public
// model metadata is published under the OpenAI provider on models.dev.
return "openai"
case "dashscope":
return "alibaba"
case "github":
@@ -241,6 +241,7 @@ func TestPublicLiveCatalog(t *testing.T) {
func TestModelsDevProviderIDMapsZeroAliases(t *testing.T) {
tests := map[string]string{
"chatgpt": "openai",
"github": "github-models",
"moonshot": "moonshotai",
"nvidia-nim": "nvidia",
@@ -139,6 +139,68 @@ func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
}
}
func TestDiscoverCatalogChatGPTMergesOpenAIModelsDevMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api.json":
_, _ = w.Write([]byte(`{
"openai": {"models": {
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"description": "Frontier coding model",
"tool_call": true,
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"limit": {"context": 1050000},
"modalities": {"input": ["text", "image", "pdf"], "output": ["text"]}
}
}}
}`))
case "/backend-api/codex/models":
_, _ = w.Write([]byte(`{"data":[{"id":"gpt-5.6-sol"}]}`))
default:
t.Errorf("unexpected request path %q", r.URL.Path)
http.NotFound(w, r)
return
}
}))
defer server.Close()
provider, ok := providercatalog.Get("chatgpt")
if !ok {
t.Fatal("ChatGPT provider missing from catalog")
}
models, err := DiscoverCatalog(context.Background(), provider, config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/backend-api/codex",
}, Options{
HTTPClient: server.Client(),
ModelsDevURL: server.URL + "/api.json",
OAuthResolver: func(context.Context, bool) (string, string, bool, error) {
return "Authorization", "Bearer test", true, nil
},
})
if err != nil {
t.Fatalf("DiscoverCatalog returned error: %v", err)
}
if len(models) != 1 {
t.Fatalf("models = %#v, want one ChatGPT live model", models)
}
model := models[0]
if model.ID != "gpt-5.6-sol" || model.ContextWindow != 1050000 || !model.ToolCall || !model.Reasoning {
t.Fatalf("merged ChatGPT model = %#v, want models.dev metadata", model)
}
if got := strings.Join(model.InputModalities, ","); got != "text,image,pdf" {
t.Fatalf("input modalities = %q, want text,image,pdf", got)
}
if got := strings.Join(model.ReasoningEfforts, ","); got != "low,high,max" {
t.Fatalf("reasoning efforts = %q, want low,high,max", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
@@ -1,14 +0,0 @@
// Test seams: helpers only test code uses, kept out of the production binary.
package providerio
import (
"bufio"
"io"
)
// ScanSSEData parses Server-Sent Event data fields from a streaming response.
func ScanSSEData(reader io.Reader, handle func(data string) bool) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 4096), maxSSELineBytes)
return scanSSEPayloads(scanner, handle, nil)
}
+22
View File
@@ -242,6 +242,7 @@ func TestEngineAutoAllowsWorkspaceFileMutationTools(t *testing.T) {
{name: "write_file", args: map[string]any{"path": "notes.txt"}},
{name: "edit_file", args: map[string]any{"path": "notes.txt"}},
{name: "apply_patch", args: map[string]any{"patch": "diff --git a/notes.txt b/notes.txt\n"}},
{name: "apply_patch", args: map[string]any{"patch": "*** Begin Patch\n*** Add File: notes.txt\n+x\n*** End Patch\n"}},
} {
t.Run(tc.name, func(t *testing.T) {
decision := engine.Evaluate(context.Background(), Request{
@@ -315,6 +316,27 @@ func TestEngineDeniesApplyPatchEscapesFromPatchBody(t *testing.T) {
}
}
func TestEngineDeniesStructuredApplyPatchEscapesFromPatchBody(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: DefaultPolicy()})
for _, patch := range []string{
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n",
"*** Begin Patch\n*** Add File: ..\\escape.txt\n+x\n*** End Patch\n",
} {
decision := engine.Evaluate(context.Background(), Request{
ToolName: "apply_patch",
SideEffect: SideEffectWrite,
Permission: PermissionPrompt,
PermissionMode: PermissionModeAsk,
Args: map[string]any{"patch": patch},
})
if decision.Action != ActionDeny || decision.Block == nil || decision.Block.Code != BlockOutsideWorkspace {
t.Fatalf("escaping structured apply_patch decision = %#v, want outside-workspace deny", decision)
}
}
}
func TestEngineSessionGrantDoesNotMatchSiblingFile(t *testing.T) {
root := t.TempDir()
engine := NewEngine(EngineOptions{WorkspaceRoot: root, Policy: promptWorkspaceWritePolicy()})
+30 -2
View File
@@ -261,7 +261,7 @@ func applyPatchRequestPaths(args map[string]any) []string {
}
cwd := firstArgString(args, "cwd")
var paths []string
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -281,7 +281,7 @@ func applyPatchPathBlock(request Request) *pathBlock {
if patch == "" {
return nil
}
for _, path := range patchHeaderPaths(patch) {
for _, path := range applyPatchPaths(patch) {
if path == "" || path == "/dev/null" {
continue
}
@@ -296,6 +296,34 @@ func applyPatchPathBlock(request Request) *pathBlock {
return nil
}
func applyPatchPaths(patch string) []string {
if strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), "*** Begin Patch") {
return structuredPatchHeaderPaths(patch)
}
return patchHeaderPaths(patch)
}
// structuredPatchHeaderPaths is intentionally a small, conservative scanner for
// the sandbox boundary. The executor validates the complete hunk grammar before
// writing; the sandbox only needs every possible target path so it can reject an
// unsafe request before the tool runs.
func structuredPatchHeaderPaths(patch string) []string {
var paths []string
for _, line := range strings.Split(strings.ReplaceAll(patch, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(line)
for _, prefix := range []string{"*** Add File: ", "*** Delete File: ", "*** Update File: ", "*** Move to: "} {
if path, ok := strings.CutPrefix(trimmed, prefix); ok {
path = strings.ReplaceAll(filepath.ToSlash(strings.TrimSpace(path)), "\\", "/")
if path != "" {
paths = append(paths, path)
}
break
}
}
}
return paths
}
func patchHeaderPaths(patch string) []string {
var paths []string
oldRemaining, newRemaining := 0, 0
+5 -2
View File
@@ -20,11 +20,11 @@ func NewScopedApplyPatchTool(workspaceRoot string, scope PathScope) Tool {
return applyPatchTool{
baseTool: baseTool{
name: "apply_patch",
description: "Apply a unified diff patch inside the workspace or an explicitly granted extra write root.",
description: "Apply a patch inside the workspace or an explicitly granted extra write root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"patch": {Type: "string", Description: "Unified diff patch to apply."},
"patch": {Type: "string", Description: "A unified diff or a structured *** Begin Patch patch to apply."},
"cwd": {Type: "string", Description: "Directory where the patch should be applied. Relative paths stay in the workspace; use an absolute path to target a granted extra write root. Defaults to workspace root.", Default: "."},
},
Required: []string{"patch"},
@@ -56,6 +56,9 @@ func (tool applyPatchTool) RunWithOptions(ctx context.Context, args map[string]a
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
if isStructuredPatch(patch) {
return tool.runStructuredPatch(applyRoot, relativeRoot, patch, options)
}
if err := validatePatchPaths(applyRoot, patch); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
-8
View File
@@ -162,11 +162,3 @@ func ValidateCapabilities(name string, caps ToolCapabilities) []string {
}
return problems
}
// ValidateToolCapabilities validates CapabilitiesOf(tool) for a registered tool.
func ValidateToolCapabilities(tool Tool) []string {
if tool == nil {
return []string{"<nil tool>"}
}
return ValidateCapabilities(tool.Name(), CapabilitiesOf(tool))
}
+19
View File
@@ -231,6 +231,25 @@ func TestApplyPatchResourceKeysFromDiffAndCwd(t *testing.T) {
}
}
func TestApplyPatchResourceKeysFromStructuredPatchAndCwd(t *testing.T) {
patch := "*** Begin Patch\n*** Update File: old.go\n*** Move to: new.go\n@@\n-old\n+new\n*** End Patch\n"
keys := applyPatchResourceKeys(map[string]any{"patch": patch, "cwd": "pkg"})
want := map[string]bool{
ResourceKeyDirectory + "pkg": true,
ResourceKeyFile + "pkg/old.go": true,
ResourceKeyFile + "pkg/new.go": true,
}
for _, key := range keys {
if !want[key] {
t.Fatalf("unexpected key %q in %v", key, keys)
}
delete(want, key)
}
if len(want) != 0 {
t.Fatalf("missing keys %v from %v", want, keys)
}
}
func TestDeclaredMutatorThreadSafeIsReportedByCatalogValidation(t *testing.T) {
// Runtime CapabilitiesOf clears ThreadSafe on mutators; catalog validation
// must still see the raw declaration so miswired constructors fail the gate.
+106
View File
@@ -221,6 +221,112 @@ func TestApplyPatchCompleteCreationCreditsSuppliedFile(t *testing.T) {
}
}
func TestStructuredPatchDoesNotCreditUnreadFileForOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-beta",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if tracker.SeenWhole(trackedPath) {
t.Fatal("a partial structured patch credited the unread file as fully seen")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "blind overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusError || !strings.Contains(result.Output, "has not been read exactly") {
t.Fatalf("blind overwrite after a partial patch was not refused: %#v", result)
}
if got, want := mustReadTestFile(t, path), "alpha\nupdated\ngamma\n"; got != want {
t.Fatalf("file = %q, want %q", got, want)
}
}
func TestStructuredPatchPreservesWholeFileObservation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte("alpha\nbeta\n"), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
if result := registry.RunWithOptions(context.Background(), "read_file", map[string]any{"path": "f.txt"}, opts); result.Status != StatusOK {
t.Fatalf("read_file failed: %s", result.Output)
}
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: f.txt",
"@@",
"-alpha",
"+updated",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
trackedPath, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(trackedPath) {
t.Fatal("structured patch discarded an existing whole-file observation")
}
result := registry.RunWithOptions(context.Background(), "write_file", map[string]any{
"path": "f.txt", "content": "known overwrite\n", "overwrite": true,
}, opts)
if result.Status != StatusOK {
t.Fatalf("overwrite after a whole-file read was refused: %s", result.Output)
}
}
func TestStructuredPatchCreationCreditsSuppliedFile(t *testing.T) {
dir := t.TempDir()
registry := safetyRegistry(t, dir)
registry.Register(NewScopedApplyPatchTool(dir, nil))
tracker := NewFileTracker()
opts := grantedOpts(tracker)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: created.txt",
"+supplied",
"*** End Patch",
"",
}, "\n")
if result := registry.RunWithOptions(context.Background(), "apply_patch", map[string]any{"patch": patch}, opts); result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
path, err := filepath.EvalSymlinks(filepath.Join(dir, "created.txt"))
if err != nil {
t.Fatal(err)
}
if !tracker.SeenWhole(path) {
t.Fatal("a fully supplied structured add was not credited as fully seen")
}
}
func TestWriteFileOverwriteRefusedWhenChangedOnDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f.txt")
+4 -2
View File
@@ -78,14 +78,16 @@ func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenCon
if !ok {
return writtenContent
}
if _, err := exec.LookPath(command[0]); err != nil {
binaryPath, err := exec.LookPath(command[0])
if err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter := exec.CommandContext(formatCtx, binaryPath, arguments...)
formatter.Dir = filepath.Dir(absolutePath)
formatter.Stdin = strings.NewReader("")
if err := formatter.Run(); err != nil {
return writtenContent
}
+14
View File
@@ -102,3 +102,17 @@ func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Fatalf("unknown extension must pass through: %q", content)
}
}
func TestFormatOnWriteFormatterLookupFailure(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
t.Setenv("PATH", t.TempDir())
targetPath := filepath.Join(t.TempDir(), "a.go")
uglyContent := "package a\n\nfunc A( ) { }\n"
if err := os.WriteFile(targetPath, []byte(uglyContent), 0o644); err != nil {
t.Fatal(err)
}
content := maybeFormatWrittenFile(context.Background(), targetPath, uglyContent)
if content != uglyContent {
t.Fatalf("missing formatter must return written content, got %q", content)
}
}
+9
View File
@@ -0,0 +1,9 @@
package tools
// ModelVisible reports whether a registered tool is advertised to an agent.
// edit_file remains implemented for compatibility with existing integrations,
// but its text-only replacement contract cannot safely disambiguate repeated
// source fragments. Agents use apply_patch for contextual existing-file edits.
func ModelVisible(tool Tool) bool {
return tool != nil && tool.Name() != "edit_file"
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "testing"
func TestToolSearchDoesNotSurfaceLegacyStringReplacement(t *testing.T) {
registry := NewRegistry()
registry.Register(NewScopedEditFileTool(t.TempDir(), nil))
registry.Register(NewScopedApplyPatchTool(t.TempDir(), nil))
search := NewToolSearchTool(registry).(toolSearchTool)
eager := search.visibleEagerToolNames(nil, nil, "ask")
if eager["edit_file"] {
t.Fatal("tool_search must not describe edit_file as available to the model")
}
if !eager["apply_patch"] {
t.Fatal("tool_search must retain apply_patch as the available edit tool")
}
}
+34
View File
@@ -1,5 +1,7 @@
package tools
import "path/filepath"
// MutationTargets returns the workspace-relative paths a tool call will write to,
// so the session layer can snapshot their before-state for safe rewind. It is a
// pure helper (no I/O beyond path resolution) and returns nil for read-only tools
@@ -36,6 +38,19 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
if err != nil {
return nil
}
if isStructuredPatch(patch) {
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
paths := structuredPatchOperationPaths(operations)
for _, path := range paths {
if _, _, err := resolveWorkspaceTargetPath(applyRoot, path); err != nil {
return nil
}
}
return prefixPatchPaths(relativeRoot, paths)
}
// Enforce the same workspace confinement apply_patch applies (against the
// resolved apply dir), so a patch with a traversal path (../x) never yields
// an out-of-workspace target.
@@ -51,3 +66,22 @@ func MutationTargets(workspaceRoot string, name string, args map[string]any) []s
return nil
}
}
func prefixPatchPaths(relativeRoot string, paths []string) []string {
if len(paths) == 0 {
return nil
}
seen := make(map[string]bool)
var out []string
for _, path := range paths {
workspacePath := path
if relativeRoot != "" && relativeRoot != "." {
workspacePath = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[workspacePath] {
seen[workspacePath] = true
out = append(out, workspacePath)
}
}
return out
}
+11
View File
@@ -17,6 +17,7 @@ func TestMutationTargets(t *testing.T) {
{"write_file", map[string]any{"path": "a/b.txt", "content": "x"}, []string{"a/b.txt"}},
{"edit_file", map[string]any{"path": "c.txt", "old_string": "x", "new_string": "y"}, []string{"c.txt"}},
{"apply_patch", map[string]any{"patch": "--- a/d.txt\n+++ b/d.txt\n@@ -1 +1 @@\n-x\n+y\n"}, []string{"d.txt"}},
{"apply_patch", map[string]any{"patch": "*** Begin Patch\n*** Add File: nested/new.txt\n+x\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-old\n+new\n*** End Patch\n"}, []string{"nested/new.txt", "old.txt", "moved.txt"}},
{"bash", map[string]any{"command": "echo hi"}, nil},
{"read_file", map[string]any{"path": "e.txt"}, nil},
{"grep", map[string]any{"pattern": "x"}, nil},
@@ -62,6 +63,10 @@ func TestMutationTargetsRejectsEscapingPaths(t *testing.T) {
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": escapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping apply_patch, got %v", got)
}
structuredEscapePatch := "*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch\n"
if got := MutationTargets(root, "apply_patch", map[string]any{"patch": structuredEscapePatch}); len(got) != 0 {
t.Errorf("expected no targets for escaping structured apply_patch, got %v", got)
}
}
// Finding 3: with cwd != ".", apply_patch's MutationTargets must return
@@ -82,6 +87,12 @@ func TestMutationTargetsApplyPatchPrefixesCwd(t *testing.T) {
t.Fatalf("got %v, want %v", got, want)
}
structuredPatch := "*** Begin Patch\n*** Update File: d.txt\n@@\n-x\n+y\n*** End Patch\n"
got = MutationTargets(root, "apply_patch", map[string]any{"patch": structuredPatch, "cwd": "sub/dir"})
if !reflect.DeepEqual(got, want) {
t.Fatalf("structured cwd: got %v, want %v", got, want)
}
// cwd "." (the default) must keep paths unprefixed.
got = MutationTargets(root, "apply_patch", map[string]any{"patch": patch, "cwd": "."})
if !reflect.DeepEqual(got, []string{"d.txt"}) {
+12 -1
View File
@@ -178,7 +178,7 @@ func applyPatchResourceKeys(args map[string]any) []string {
keys = append(keys, multiFileResourceKeys(args)...)
cwd := firstStringArg(args, "cwd", "workdir")
patch := firstStringArg(args, "patch", "diff")
for _, p := range pathsFromUnifiedDiff(patch) {
for _, p := range pathsFromApplyPatch(patch) {
joined := joinUnderResourceCwd(cwd, p)
if n := NormalizeResourcePath(joined); n != "" {
keys = append(keys, ResourceKeyFile+n)
@@ -191,6 +191,17 @@ func applyPatchResourceKeys(args map[string]any) []string {
return keys
}
func pathsFromApplyPatch(patch string) []string {
if !isStructuredPatch(patch) {
return pathsFromUnifiedDiff(patch)
}
operations, err := parseStructuredPatch(patch)
if err != nil {
return nil
}
return structuredPatchOperationPaths(operations)
}
// joinUnderResourceCwd joins a relative path under cwd for resource keys only.
// Absolute paths are left unchanged. Empty cwd or "." leaves rel as-is.
// Never touches the filesystem.
+763
View File
@@ -0,0 +1,763 @@
package tools
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/Gitlawb/zero/internal/pathjail"
)
const (
structuredPatchBegin = "*** Begin Patch"
structuredPatchEnd = "*** End Patch"
structuredAddFile = "*** Add File: "
structuredDeleteFile = "*** Delete File: "
structuredUpdateFile = "*** Update File: "
structuredMoveTo = "*** Move to: "
structuredEndOfFile = "*** End of File"
structuredHunkContext = "@@ "
)
type structuredPatchKind uint8
const (
structuredPatchAdd structuredPatchKind = iota
structuredPatchDelete
structuredPatchUpdate
)
type structuredPatchOperation struct {
kind structuredPatchKind
path string
movePath string
contents string
chunks []structuredPatchChunk
line int
}
type structuredPatchChunk struct {
context string
hasContext bool
old []string
new []string
newSourceOffsets []int
endOfFile bool
}
type structuredPatchTarget struct {
requested string
absolute string
relative string
}
type structuredPatchChange struct {
kind structuredPatchKind
from structuredPatchTarget
to structuredPatchTarget
before string
after string
mode os.FileMode
}
func isStructuredPatch(patch string) bool {
return strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(patch, "\ufeff")), structuredPatchBegin)
}
func (tool applyPatchTool) runStructuredPatch(applyRoot, relativeRoot, patch string, options RunOptions) Result {
operations, err := parseStructuredPatch(patch)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
workspace, err := os.OpenRoot(applyRoot)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
defer workspace.Close()
changes, err := planStructuredPatch(workspace, operations, options.FileTracker)
if err != nil {
return errorResult("Error applying patch: " + err.Error())
}
wholeBefore := make(map[string]bool, len(changes))
if options.FileTracker != nil {
for _, change := range changes {
if change.kind == structuredPatchUpdate {
wholeBefore[change.from.absolute] = options.FileTracker.SeenWhole(change.from.absolute)
}
}
}
if err := applyStructuredPatchChanges(workspace, changes, options.FileTracker); err != nil {
return errorResult("Error applying patch: " + err.Error())
}
for _, change := range changes {
switch change.kind {
case structuredPatchDelete:
options.FileTracker.Forget(change.from.absolute)
case structuredPatchAdd:
recordStructuredPatchFile(options.FileTracker, change.to.absolute, true, true)
case structuredPatchUpdate:
wasWhole := wholeBefore[change.from.absolute]
options.FileTracker.Forget(change.from.absolute)
recordStructuredPatchFile(options.FileTracker, change.to.absolute, false, wasWhole)
}
}
summary := "Patch applied successfully."
if relativeRoot != "." {
summary = "Patch applied successfully in " + relativeRoot + "."
}
result := okResult(summary)
result.ChangedFiles = changedFilesFromStructuredPatch(relativeRoot, changes)
result.Display = Display{Summary: summary, Kind: "diff", Preview: structuredPatchPreview(changes)}
return result
}
func parseStructuredPatch(patch string) ([]structuredPatchOperation, error) {
normalized := strings.TrimSpace(strings.TrimPrefix(strings.ReplaceAll(patch, "\r\n", "\n"), "\ufeff"))
if normalized == "" {
return nil, fmt.Errorf("structured patch is empty")
}
lines := strings.Split(normalized, "\n")
if strings.TrimSpace(lines[0]) != structuredPatchBegin {
return nil, fmt.Errorf("the first line of a structured patch must be %q", structuredPatchBegin)
}
if strings.TrimSpace(lines[len(lines)-1]) != structuredPatchEnd {
return nil, fmt.Errorf("the last line of a structured patch must be %q", structuredPatchEnd)
}
var operations []structuredPatchOperation
for index := 1; index < len(lines)-1; {
header := strings.TrimSpace(lines[index])
lineNumber := index + 1
if header == "" {
index++
continue
}
switch {
case strings.HasPrefix(header, structuredAddFile):
path, err := structuredPatchPath(header, structuredAddFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchAdd, path: path, line: lineNumber}
index++
var content []string
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
if !strings.HasPrefix(lines[index], "+") {
return nil, structuredPatchLineError(index+1, "added-file contents must start with '+'")
}
content = append(content, strings.TrimPrefix(lines[index], "+"))
index++
}
if len(content) > 0 {
op.contents = strings.Join(content, "\n") + "\n"
}
operations = append(operations, op)
case strings.HasPrefix(header, structuredDeleteFile):
path, err := structuredPatchPath(header, structuredDeleteFile, lineNumber)
if err != nil {
return nil, err
}
operations = append(operations, structuredPatchOperation{kind: structuredPatchDelete, path: path, line: lineNumber})
index++
case strings.HasPrefix(header, structuredUpdateFile):
path, err := structuredPatchPath(header, structuredUpdateFile, lineNumber)
if err != nil {
return nil, err
}
op := structuredPatchOperation{kind: structuredPatchUpdate, path: path, line: lineNumber}
index++
if index < len(lines)-1 && strings.HasPrefix(strings.TrimSpace(lines[index]), structuredMoveTo) {
op.movePath, err = structuredPatchPath(strings.TrimSpace(lines[index]), structuredMoveTo, index+1)
if err != nil {
return nil, err
}
index++
}
for index < len(lines)-1 && !isStructuredPatchHeader(lines[index]) {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == structuredEndOfFile {
if len(op.chunks) == 0 || (!hasStructuredPatchContent(op.chunks[len(op.chunks)-1])) {
return nil, structuredPatchLineError(index+1, "*** End of File must follow an update hunk")
}
op.chunks[len(op.chunks)-1].endOfFile = true
index++
continue
}
if trimmed == "@@" || strings.HasPrefix(trimmed, structuredHunkContext) {
chunk := structuredPatchChunk{}
if trimmed != "@@" {
chunk.context = strings.TrimPrefix(trimmed, structuredHunkContext)
chunk.hasContext = true
}
op.chunks = append(op.chunks, chunk)
index++
continue
}
if len(op.chunks) == 0 {
op.chunks = append(op.chunks, structuredPatchChunk{})
}
chunk := &op.chunks[len(op.chunks)-1]
switch {
case line == "":
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, "")
chunk.new = append(chunk.new, "")
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, " "):
content := strings.TrimPrefix(line, " ")
sourceOffset := len(chunk.old)
chunk.old = append(chunk.old, content)
chunk.new = append(chunk.new, content)
chunk.newSourceOffsets = append(chunk.newSourceOffsets, sourceOffset)
case strings.HasPrefix(line, "+"):
chunk.new = append(chunk.new, strings.TrimPrefix(line, "+"))
chunk.newSourceOffsets = append(chunk.newSourceOffsets, -1)
case strings.HasPrefix(line, "-"):
chunk.old = append(chunk.old, strings.TrimPrefix(line, "-"))
default:
return nil, structuredPatchLineError(index+1, "update lines must start with ' ', '+', or '-'")
}
index++
}
if len(op.chunks) == 0 || !allStructuredPatchChunksHaveContent(op.chunks) {
return nil, structuredPatchLineError(lineNumber, "update file hunk is empty")
}
operations = append(operations, op)
default:
return nil, structuredPatchLineError(lineNumber, "expected an Add File, Delete File, or Update File header")
}
}
if len(operations) == 0 {
return nil, fmt.Errorf("structured patch contains no file operations")
}
return operations, nil
}
func structuredPatchPath(header, prefix string, line int) (string, error) {
path := strings.TrimSpace(strings.TrimPrefix(header, prefix))
if path == "" {
return "", structuredPatchLineError(line, "file path is required")
}
return path, nil
}
func isStructuredPatchHeader(line string) bool {
return line == structuredPatchEnd || strings.HasPrefix(line, structuredAddFile) || strings.HasPrefix(line, structuredDeleteFile) || strings.HasPrefix(line, structuredUpdateFile)
}
func structuredPatchLineError(line int, message string) error {
return fmt.Errorf("invalid structured patch at line %d: %s", line, message)
}
func hasStructuredPatchContent(chunk structuredPatchChunk) bool {
return len(chunk.old) > 0 || len(chunk.new) > 0
}
func allStructuredPatchChunksHaveContent(chunks []structuredPatchChunk) bool {
for _, chunk := range chunks {
if !hasStructuredPatchContent(chunk) {
return false
}
}
return true
}
func structuredPatchOperationPaths(operations []structuredPatchOperation) []string {
seen := make(map[string]bool)
var paths []string
for _, operation := range operations {
for _, path := range []string{operation.path, operation.movePath} {
if path == "" || seen[path] {
continue
}
seen[path] = true
paths = append(paths, path)
}
}
return paths
}
func planStructuredPatch(root *os.Root, operations []structuredPatchOperation, tracker *FileTracker) ([]structuredPatchChange, error) {
seen := make(map[string]struct{})
var changes []structuredPatchChange
for _, operation := range operations {
from, err := resolveStructuredPatchTarget(root.Name(), operation.path)
if err != nil {
return nil, err
}
to := from
if operation.movePath != "" {
to, err = resolveStructuredPatchTarget(root.Name(), operation.movePath)
if err != nil {
return nil, err
}
}
targets := []structuredPatchTarget{from}
if to.absolute != from.absolute {
targets = append(targets, to)
}
for _, target := range targets {
if _, exists := seen[target.absolute]; exists {
return nil, fmt.Errorf("structured patch changes %q more than once", target.relative)
}
seen[target.absolute] = struct{}{}
}
change := structuredPatchChange{kind: operation.kind, from: from, to: to, mode: 0o644}
switch operation.kind {
case structuredPatchAdd:
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot add %s because it already exists", to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
change.after = operation.contents
case structuredPatchDelete, structuredPatchUpdate:
info, err := root.Stat(from.relative)
if err != nil {
return nil, fmt.Errorf("stating %s: %w", from.relative, err)
}
change.mode = info.Mode()
content, err := root.ReadFile(from.relative)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", from.relative, err)
}
if err := tracker.CheckConflict(from.absolute, content); err != nil {
return nil, fmt.Errorf("%s", fileConflictMessage(from.relative))
}
change.before = string(content)
if operation.kind == structuredPatchUpdate {
updated, err := applyStructuredPatchUpdate(change.before, from.relative, operation.chunks)
if err != nil {
return nil, err
}
change.after = updated
if from.absolute != to.absolute {
if _, err := root.Lstat(to.relative); err == nil {
return nil, fmt.Errorf("cannot move %s to %s because the destination already exists", from.relative, to.relative)
} else if !os.IsNotExist(err) {
return nil, err
}
}
}
}
changes = append(changes, change)
}
return changes, nil
}
func resolveStructuredPatchTarget(root, path string) (structuredPatchTarget, error) {
if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(filepath.ToSlash(path), "../") {
return structuredPatchTarget{}, fmt.Errorf("patch path %q must stay inside the workspace", path)
}
absolute, relative, err := resolveWorkspaceTargetPath(root, path)
if err != nil {
return structuredPatchTarget{}, err
}
return structuredPatchTarget{requested: path, absolute: absolute, relative: relative}, nil
}
func applyStructuredPatchUpdate(content, path string, chunks []structuredPatchChunk) (string, error) {
lineEnding := structuredPatchLineEnding(content)
if lineEnding == "\r\n" {
content = strings.ReplaceAll(content, "\r\n", "\n")
}
lines := strings.Split(content, "\n")
trailingNewline := content != "" && len(lines) > 0 && lines[len(lines)-1] == ""
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
replacements := make([]structuredReplacement, 0, len(chunks))
lineIndex := 0
for _, chunk := range chunks {
if chunk.hasContext {
index, ambiguous := findStructuredPatchSequence(lines, []string{chunk.context}, lineIndex, false)
if ambiguous {
return "", fmt.Errorf("context %q is ambiguous in %s; provide a more specific hunk header", chunk.context, path)
}
if index < 0 {
return "", fmt.Errorf("could not find context %q in %s", chunk.context, path)
}
lineIndex = index + 1
}
if len(chunk.old) == 0 {
// A context anchor identifies the line immediately before a pure
// insertion. Only an explicit end-of-file hunk (or an unanchored
// insertion) belongs at the file end.
start := lineIndex
if chunk.endOfFile || !chunk.hasContext {
start = len(lines)
}
if start > len(lines) {
start = len(lines)
}
replacement, err := structuredPatchReplacement(chunk, lines, start)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: start, replacement: replacement})
lineIndex = start
continue
}
index, ambiguous := findStructuredPatchSequence(lines, chunk.old, lineIndex, chunk.endOfFile)
if ambiguous {
return "", fmt.Errorf("expected lines are ambiguous in %s; provide more surrounding context:\n%s", path, strings.Join(chunk.old, "\n"))
}
if index < 0 {
return "", fmt.Errorf("could not find expected lines in %s:\n%s", path, strings.Join(chunk.old, "\n"))
}
replacement, err := structuredPatchReplacement(chunk, lines, index)
if err != nil {
return "", fmt.Errorf("building replacement for %s: %w", path, err)
}
replacements = append(replacements, structuredReplacement{start: index, length: len(chunk.old), replacement: replacement})
lineIndex = index + len(chunk.old)
}
for index := 1; index < len(replacements); index++ {
previous := replacements[index-1]
current := replacements[index]
if current.start < previous.start+previous.length {
return "", fmt.Errorf("overlapping or out-of-order hunks in %s; provide non-overlapping context", path)
}
}
for index := len(replacements) - 1; index >= 0; index-- {
replacement := replacements[index]
lines = append(lines[:replacement.start], append(replacement.replacement, lines[replacement.start+replacement.length:]...)...)
}
if len(lines) == 0 {
return "", nil
}
updated := strings.Join(lines, lineEnding)
if trailingNewline {
updated += lineEnding
}
return updated, nil
}
func structuredPatchReplacement(chunk structuredPatchChunk, source []string, start int) ([]string, error) {
if len(chunk.newSourceOffsets) != len(chunk.new) {
return nil, fmt.Errorf("context mapping has %d entries for %d output lines", len(chunk.newSourceOffsets), len(chunk.new))
}
replacement := append([]string(nil), chunk.new...)
for outputIndex, sourceOffset := range chunk.newSourceOffsets {
if sourceOffset < 0 {
continue
}
sourceIndex := start + sourceOffset
if sourceIndex < 0 || sourceIndex >= len(source) {
return nil, fmt.Errorf("context source index %d is outside the matched file", sourceIndex)
}
replacement[outputIndex] = source[sourceIndex]
}
return replacement, nil
}
func structuredPatchLineEnding(content string) string {
if strings.Contains(content, "\r\n") && !strings.Contains(strings.ReplaceAll(content, "\r\n", ""), "\n") {
return "\r\n"
}
return "\n"
}
type structuredReplacement struct {
start int
length int
replacement []string
}
func findStructuredPatchSequence(lines, wanted []string, start int, endOfFile bool) (int, bool) {
if len(wanted) == 0 {
return start, false
}
if len(wanted) > len(lines) {
return -1, false
}
searchStart := start
if endOfFile {
searchStart = len(lines) - len(wanted)
if searchStart < start {
searchStart = start
}
}
for _, equal := range []func(string, string) bool{
func(left, right string) bool { return left == right },
func(left, right string) bool {
return strings.TrimRight(left, " \t") == strings.TrimRight(right, " \t")
},
func(left, right string) bool { return strings.TrimSpace(left) == strings.TrimSpace(right) },
} {
match := -1
for index := searchStart; index+len(wanted) <= len(lines); index++ {
matched := true
for offset, line := range wanted {
if !equal(lines[index+offset], line) {
matched = false
break
}
}
if matched {
if match >= 0 {
return -1, true
}
match = index
}
}
if match >= 0 {
return match, false
}
}
return -1, false
}
func applyStructuredPatchChanges(root *os.Root, changes []structuredPatchChange, tracker *FileTracker) error {
mutated := false
for _, change := range changes {
committed, err := applyStructuredPatchChange(root, change)
mutated = mutated || committed
if err != nil {
forgetStructuredPatchFiles(tracker, changes)
if mutated {
return fmt.Errorf("%w; structured patch was partially applied; re-read affected files before retrying", err)
}
return err
}
}
return nil
}
func forgetStructuredPatchFiles(tracker *FileTracker, changes []structuredPatchChange) {
if tracker == nil {
return
}
for _, change := range changes {
tracker.Forget(change.from.absolute)
if change.to.absolute != change.from.absolute {
tracker.Forget(change.to.absolute)
}
}
}
func applyStructuredPatchChange(root *os.Root, change structuredPatchChange) (bool, error) {
switch change.kind {
case structuredPatchDelete:
if err := root.Remove(change.from.relative); err != nil {
return false, fmt.Errorf("deleting %s: %w", change.from.relative, err)
}
return true, nil
case structuredPatchAdd:
return writeStructuredPatchFile(root, change.to, change.after, change.mode, true)
case structuredPatchUpdate:
moving := change.from.absolute != change.to.absolute
committed, err := writeStructuredPatchFile(root, change.to, change.after, change.mode, moving)
if err != nil {
return committed, err
}
if moving {
if err := root.Remove(change.from.relative); err != nil {
return true, fmt.Errorf("removing moved source %s: %w", change.from.relative, err)
}
}
return true, nil
}
return false, fmt.Errorf("unsupported structured patch operation")
}
func writeStructuredPatchFile(root *os.Root, target structuredPatchTarget, content string, mode os.FileMode, createOnly bool) (bool, error) {
parent := filepath.Dir(target.relative)
if err := root.MkdirAll(parent, 0o755); err != nil {
return false, fmt.Errorf("creating parent directory for %s: %w", target.relative, err)
}
temp, tempName, err := pathjail.CreateTemp(root, parent, "zero-patch", ".tmp")
if err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
defer func() { _ = removeStructuredPatchTemp(root, tempName) }()
if _, err := temp.WriteString(content); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Chmod(mode.Perm()); err != nil {
_ = temp.Close()
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if err := temp.Close(); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
if createOnly {
return publishStructuredPatchNoReplace(root, tempName, target.relative, mode)
}
if err := root.Rename(tempName, target.relative); err != nil {
return false, fmt.Errorf("writing %s: %w", target.relative, err)
}
return true, nil
}
func publishStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return publishStructuredPatchNoReplaceWith(root, source, target, mode, root.Link)
}
func publishStructuredPatchNoReplaceWith(root *os.Root, source, target string, mode os.FileMode, link func(string, string) error) (bool, error) {
if linkErr := link(source, target); linkErr == nil {
cleanupErr := removeStructuredPatchTemp(root, source)
chmodErr := root.Chmod(target, mode.Perm())
if cleanupErr != nil || chmodErr != nil {
return true, fmt.Errorf("publishing %s: %w", target, errors.Join(cleanupErr, chmodErr))
}
return true, nil
} else if errors.Is(linkErr, os.ErrExist) {
return false, fmt.Errorf("creating %s: %w", target, linkErr)
} else {
committed, copyErr := copyStructuredPatchNoReplace(root, source, target, mode)
if copyErr != nil {
return committed, fmt.Errorf("creating %s without overwrite: hard link failed: %w; exclusive-copy fallback failed: %w", target, linkErr, copyErr)
}
return committed, nil
}
}
func copyStructuredPatchNoReplace(root *os.Root, source, target string, mode os.FileMode) (bool, error) {
return copyStructuredPatchNoReplaceWith(root, source, target, mode, io.Copy, removeStructuredPatchTemp)
}
func copyStructuredPatchNoReplaceWith(
root *os.Root,
source, target string,
mode os.FileMode,
copyFile func(io.Writer, io.Reader) (int64, error),
cleanup func(*os.Root, string) error,
) (bool, error) {
sourceFile, err := root.Open(source)
if err != nil {
return false, err
}
sourceClosed := false
defer func() {
if !sourceClosed {
_ = sourceFile.Close()
}
}()
targetFile, err := root.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return false, err
}
committed := true
cleanupTarget := func(cause error) (bool, error) {
_ = targetFile.Close()
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, cause
} else {
return committed, errors.Join(cause, fmt.Errorf("cleaning partial target: %w", cleanupErr))
}
}
if _, err := copyFile(targetFile, sourceFile); err != nil {
return cleanupTarget(err)
}
if err := sourceFile.Close(); err != nil {
sourceClosed = true
return cleanupTarget(err)
}
sourceClosed = true
if err := targetFile.Chmod(mode.Perm()); err != nil {
return cleanupTarget(err)
}
if err := targetFile.Close(); err != nil {
if cleanupErr := cleanup(root, target); cleanupErr == nil {
return false, err
} else {
return true, errors.Join(err, fmt.Errorf("cleaning target after close failure: %w", cleanupErr))
}
}
if err := cleanup(root, source); err != nil {
return true, fmt.Errorf("removing published source: %w", err)
}
return true, nil
}
func removeStructuredPatchTemp(root *os.Root, name string) error {
chmodErr := root.Chmod(name, 0o600)
if os.IsNotExist(chmodErr) {
return nil
}
removeErr := root.Remove(name)
if os.IsNotExist(removeErr) {
removeErr = nil
}
if removeErr == nil {
return nil
}
return errors.Join(chmodErr, removeErr)
}
func recordStructuredPatchFile(tracker *FileTracker, absolute string, created, seenWhole bool) {
if tracker == nil {
return
}
content, err := os.ReadFile(absolute)
if err != nil {
tracker.Forget(absolute)
return
}
info, _ := os.Stat(absolute)
tracker.Record(absolute, content, info)
if seenWhole {
lines := lineCount(string(content))
tracker.RecordSeenRange(absolute, 1, lines, lines)
}
if created {
tracker.RecordCreated(absolute)
}
}
func changedFilesFromStructuredPatch(relativeRoot string, changes []structuredPatchChange) []string {
seen := make(map[string]bool)
var paths []string
for _, change := range changes {
targets := []structuredPatchTarget{change.to}
if change.from.absolute != change.to.absolute {
targets = append([]structuredPatchTarget{change.from}, targets...)
}
for _, target := range targets {
path := target.relative
if relativeRoot != "" && relativeRoot != "." {
path = filepath.ToSlash(filepath.Join(relativeRoot, path))
}
if !seen[path] {
seen[path] = true
paths = append(paths, path)
}
}
}
return paths
}
func structuredPatchPreview(changes []structuredPatchChange) string {
var preview strings.Builder
for _, change := range changes {
path := change.to.relative
if change.kind == structuredPatchDelete {
path = change.from.relative
}
before, after := change.before, change.after
if change.kind == structuredPatchDelete {
after = ""
}
diff := boundedUnifiedDiff(path, before, after)
if diff == "" {
continue
}
if preview.Len()+len(diff)+1 > maxToolPreviewBytes {
break
}
if preview.Len() > 0 {
preview.WriteByte('\n')
}
preview.WriteString(diff)
}
return capPreviewDiff(preview.String())
}
+6
View File
@@ -130,6 +130,9 @@ func (tool toolSearchTool) visibleDeferredTools(enabled []string, disabled []str
var deferred []Tool
if tool.registry != nil {
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if !IsDeferred(candidate) {
continue
}
@@ -158,6 +161,9 @@ func (tool toolSearchTool) visibleEagerToolNames(enabled []string, disabled []st
return names
}
for _, candidate := range tool.registry.All() {
if !ModelVisible(candidate) {
continue
}
if IsDeferred(candidate) || candidate.Name() == ToolSearchToolName {
continue
}
+537
View File
@@ -2,12 +2,23 @@ package tools
import (
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func mustReadTestFile(t *testing.T, path string) string {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(content)
}
func TestCoreToolsExposeWriteAndPlanTools(t *testing.T) {
toolset := CoreToolsScoped(t.TempDir(), nil)
byName := make(map[string]Tool, len(toolset))
@@ -513,6 +524,532 @@ func TestApplyPatchToolAppliesUnifiedDiff(t *testing.T) {
}
}
func TestApplyPatchToolAppliesStructuredPatch(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), "hello\nold\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
" hello",
"-old",
"+new",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := strings.ReplaceAll(string(content), "\r\n", "\n"); got != "hello\nnew\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolAppliesStructuredAddAndMove(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "old.txt"), "old\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Add File: nested/new.txt",
"+created",
"*** Update File: old.txt",
"*** Move to: moved.txt",
"@@",
"-old",
"+moved",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should apply, got %s: %s", result.Status, result.Output)
}
if got, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(got) != "created\n" {
t.Fatalf("added file = %q, err = %v", got, err)
}
if got, err := os.ReadFile(filepath.Join(root, "moved.txt")); err != nil || string(got) != "moved\n" {
t.Fatalf("moved file = %q, err = %v", got, err)
}
if _, err := os.Stat(filepath.Join(root, "old.txt")); !os.IsNotExist(err) {
t.Fatalf("old source should be removed, stat err = %v", err)
}
if got := result.ChangedFiles; strings.Join(got, ",") != "nested/new.txt,old.txt,moved.txt" {
t.Fatalf("ChangedFiles = %v", got)
}
}
func TestApplyPatchToolStructuredPatchMatchesWhitespaceTolerantly(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "hello.txt"), " hello \n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: hello.txt",
"@@",
"-hello",
"+goodbye",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch should tolerate surrounding whitespace, got %s: %s", result.Status, result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "hello.txt"))
if err != nil {
t.Fatal(err)
}
if got := string(content); got != "goodbye\n" {
t.Fatalf("structured patch content = %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesWhitespaceTolerantContext(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt()\n\t}\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
" if cond {",
" doWork()",
"-\t\tlogIt()",
"+\t\tlogIt(ctx)",
" }",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "func f() {\n\tif cond {\n\t\tdoWork()\n\t\tlogIt(ctx)\n\t}\n}\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote unchanged context:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMixedContextLineEndings(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\r\n\tcontext\r\n\told\nomega\r\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
" alpha",
" context",
"-\told",
"+\tnew",
" omega",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
want := "alpha\r\n\tcontext\r\n\tnew\nomega\r\n"
if got := mustReadTestFile(t, path); got != want {
t.Fatalf("structured patch rewrote context line endings:\n got %q\nwant %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchInsertsAtContext(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "example.txt"), "anchor\nmiddle\nremove\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@ anchor",
"+inserted",
"@@ middle",
"-remove",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured insertion failed: %s", result.Output)
}
content, err := os.ReadFile(filepath.Join(root, "example.txt"))
if err != nil {
t.Fatal(err)
}
if got, want := string(content), "anchor\ninserted\nmiddle\n"; got != want {
t.Fatalf("structured insertion content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsAmbiguousMatch(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.go")
original := "func a() {\n\treturn nil\n}\n\nfunc b() {\n\treturn nil\n}\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.go",
"@@",
"-\treturn nil",
"+\treturn errNotFound",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError || !strings.Contains(strings.ToLower(result.Output), "ambiguous") {
t.Fatalf("ambiguous structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("ambiguous structured patch changed the file: %q", got)
}
}
func TestApplyPatchToolStructuredPatchPreservesCRLF(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\r\nbravo\r\ncharlie\r\n")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-bravo",
"+BRAVO",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "alpha\r\nBRAVO\r\ncharlie\r\n"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchPreservesMissingFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
writeTestFile(t, path, "alpha\nbravo")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-alpha",
"+ALPHA",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "ALPHA\nbravo"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchKeepsInsertedEmptyFileWithoutFinalNewline(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "empty.txt")
writeTestFile(t, path, "")
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: empty.txt",
"@@",
"+content",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusOK {
t.Fatalf("structured patch failed: %s", result.Output)
}
if got, want := mustReadTestFile(t, path), "content"; got != want {
t.Fatalf("structured patch content = %q, want %q", got, want)
}
}
func TestApplyPatchToolStructuredPatchRejectsOutOfOrderEndHunk(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "example.txt")
original := "alpha\nbravo\ncharlie\ndelta\necho\n"
writeTestFile(t, path, original)
patch := strings.Join([]string{
"*** Begin Patch",
"*** Update File: example.txt",
"@@",
"-delta",
"+DELTA",
"@@",
"-delta",
"-echo",
"+tail",
"*** End of File",
"*** End Patch",
"",
}, "\n")
result := NewScopedApplyPatchTool(root, nil).Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("out-of-order structured patch should be refused, got %s: %s", result.Status, result.Output)
}
if got := mustReadTestFile(t, path); got != original {
t.Fatalf("out-of-order structured patch changed the file: %q", got)
}
}
func TestStructuredPatchAddDoesNotOverwriteRacedDestination(t *testing.T) {
root := t.TempDir()
targetPath := filepath.Join(root, "created.txt")
writeTestFile(t, targetPath, "other writer\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
change := structuredPatchChange{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: targetPath, relative: "created.txt"},
after: "patch content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !errors.Is(err, os.ErrExist) {
t.Fatalf("raced add destination = %v, want os.ErrExist", err)
}
if strings.Contains(err.Error(), "exclusive-copy fallback") {
t.Fatalf("existing destination should be reported directly: %v", err)
}
if got := mustReadTestFile(t, targetPath); got != "other writer\n" {
t.Fatalf("raced add overwrote another writer's file: %q", got)
}
}
func TestStructuredPatchFailedDeleteDoesNotRecreateMissingFile(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
missingPath := filepath.Join(root, "missing.txt")
change := structuredPatchChange{
kind: structuredPatchDelete,
from: structuredPatchTarget{absolute: missingPath, relative: "missing.txt"},
before: "removed by another writer\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil {
t.Fatal("delete of an already removed file should fail")
}
if _, statErr := os.Stat(missingPath); !os.IsNotExist(statErr) {
t.Fatalf("failed delete recreated the missing file: %v", statErr)
}
}
func TestStructuredPatchMoveFailureLeavesPublishedDestination(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.txt")
destinationPath := filepath.Join(root, "destination.txt")
change := structuredPatchChange{
kind: structuredPatchUpdate,
from: structuredPatchTarget{absolute: sourcePath, relative: "source.txt"},
to: structuredPatchTarget{absolute: destinationPath, relative: "destination.txt"},
before: "removed source\n", after: "moved content\n", mode: 0o644,
}
err = applyStructuredPatchChanges(workspace, []structuredPatchChange{change}, nil)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("move with a missing source = %v, want partial-application error", err)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("failed move recreated a missing source: %v", statErr)
}
if got := mustReadTestFile(t, destinationPath); got != "moved content\n" {
t.Fatalf("published move destination = %q", got)
}
}
func TestStructuredPatchCreateOnlyFallsBackWhenHardLinksUnsupported(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
if err := workspace.Chmod("source.tmp", 0o444); err != nil {
t.Fatal(err)
}
committed, err := publishStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o444,
func(string, string) error { return errors.New("hard links unsupported") },
)
if err != nil || !committed {
t.Fatalf("fallback publish = committed %v, error %v", committed, err)
}
if got := mustReadTestFile(t, filepath.Join(root, "target.txt")); got != "complete content\n" {
t.Fatalf("fallback target content = %q", got)
}
if _, statErr := os.Stat(sourcePath); !os.IsNotExist(statErr) {
t.Fatalf("read-only source was not cleaned up: %v", statErr)
}
}
func TestStructuredPatchCopyFailureReportsSurvivingPartialTarget(t *testing.T) {
root := t.TempDir()
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
sourcePath := filepath.Join(root, "source.tmp")
writeTestFile(t, sourcePath, "complete content\n")
copyErr := errors.New("injected copy failure")
cleanupErr := errors.New("injected target cleanup failure")
committed, err := copyStructuredPatchNoReplaceWith(
workspace,
"source.tmp",
"target.txt",
0o644,
func(destination io.Writer, _ io.Reader) (int64, error) {
written, writeErr := io.WriteString(destination, "partial")
if writeErr != nil {
return int64(written), writeErr
}
return int64(written), copyErr
},
func(root *os.Root, name string) error {
if name == "target.txt" {
return cleanupErr
}
return removeStructuredPatchTemp(root, name)
},
)
if !committed || !errors.Is(err, copyErr) || !errors.Is(err, cleanupErr) {
t.Fatalf("copy failure = committed %v, error %v", committed, err)
}
targetPath := filepath.Join(root, "target.txt")
if got := mustReadTestFile(t, targetPath); got != "partial" {
t.Fatalf("partial target content = %q", got)
}
}
func TestStructuredPatchPartialFailureLeavesCompletedChangeAndClearsTrackedState(t *testing.T) {
root := t.TempDir()
trackedPath := filepath.Join(root, "tracked.txt")
writeTestFile(t, trackedPath, "original\n")
workspace, err := os.OpenRoot(root)
if err != nil {
t.Fatal(err)
}
defer workspace.Close()
trackedPath, err = filepath.EvalSymlinks(trackedPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(trackedPath)
if err != nil {
t.Fatal(err)
}
tracker := NewFileTracker()
tracker.Record(trackedPath, []byte("original\n"), info)
tracker.RecordSeenRange(trackedPath, 1, 1, 1)
trackedTarget := structuredPatchTarget{absolute: trackedPath, relative: "tracked.txt"}
blockedPath := filepath.Join(root, "blocked")
if err := os.Mkdir(blockedPath, 0o755); err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(blockedPath, "sentinel"), "keep\n")
changes := []structuredPatchChange{
{
kind: structuredPatchUpdate, from: trackedTarget, to: trackedTarget,
before: "original\n", after: "updated\n", mode: 0o644,
},
{
kind: structuredPatchAdd,
to: structuredPatchTarget{absolute: blockedPath, relative: "blocked"},
after: "cannot replace a non-empty directory\n", mode: 0o644,
},
}
err = applyStructuredPatchChanges(workspace, changes, tracker)
if err == nil || !strings.Contains(err.Error(), "partially applied") {
t.Fatalf("second change = %v, want partial-application error", err)
}
if got := mustReadTestFile(t, trackedPath); got != "updated\n" {
t.Fatalf("completed change was unexpectedly altered: %q", got)
}
if _, tracked := tracker.Version(trackedPath); tracked || tracker.SeenWhole(trackedPath) {
t.Fatal("partial failure retained stale file-tracker state")
}
}
func TestApplyPatchToolRejectsMalformedOrEscapingStructuredPatchBeforeWriting(t *testing.T) {
root := t.TempDir()
tool := NewScopedApplyPatchTool(root, nil)
for name, patch := range map[string]string{
"empty update": strings.Join([]string{
"*** Begin Patch",
"*** Update File: target.txt",
"*** End Patch",
}, "\n"),
"escape": strings.Join([]string{
"*** Begin Patch",
"*** Add File: ../outside.txt",
"+nope",
"*** End Patch",
}, "\n"),
} {
t.Run(name, func(t *testing.T) {
result := tool.Run(context.Background(), map[string]any{"patch": patch})
if result.Status != StatusError {
t.Fatalf("expected error, got %s: %s", result.Status, result.Output)
}
if strings.Contains(result.Output, "No valid patches") {
t.Fatalf("structured patch should fail before git apply, got %q", result.Output)
}
if name == "empty update" {
if _, err := os.Stat(filepath.Join(root, "target.txt")); !os.IsNotExist(err) {
t.Fatalf("empty update must not write target file, stat err = %v", err)
}
}
})
}
if _, err := os.Stat(filepath.Join(filepath.Dir(root), "outside.txt")); !os.IsNotExist(err) {
t.Fatalf("escaping patch must not write outside workspace, stat err = %v", err)
}
}
// A hunk-body line that removes content beginning with "-- " appears in the diff
// as "--- ..."; it must NOT be mistaken for a file header (which previously made
// apply_patch reject a valid patch as targeting an outside path).
+1 -1
View File
@@ -22,7 +22,7 @@ func (m model) handleAddDirCommand(arg string) model {
if err != nil {
return m.appendSystemNotice("add-dir: " + err.Error())
}
return m.appendSystemNotice("write access added: " + root + " (this session only)")
return m.showTransientNoticeInline("Write access added: "+root+" (this session only).", transientNoticeSuccess)
}
// appendSystemNotice appends a system transcript line and returns the updated
+2 -2
View File
@@ -42,8 +42,8 @@ func TestHandleAddDirCommand(t *testing.T) {
if roots := scope.Roots(); len(roots) != initialRootCount+1 {
t.Fatalf("expected one additional write root after grant, got %#v", roots)
}
notice := lastTranscriptText(next)
if !strings.Contains(notice, "write access added") || !strings.Contains(notice, "session only") {
notice := next.transientNotice.text
if !strings.Contains(notice, "Write access added") || !strings.Contains(notice, "session only") {
t.Fatalf("grant notice = %q, want it to mention the grant and its session-only lifetime", notice)
}
+39 -4
View File
@@ -18,6 +18,8 @@ const (
markdownTableRuleBodyRows = 4
markdownBoldStart = "\x1b[1m"
markdownBoldEnd = "\x1b[22m"
markdownCodeStart = "\x1b[7m"
markdownCodeEnd = "\x1b[27m"
)
type markdownDisplayStyle int
@@ -25,6 +27,7 @@ type markdownDisplayStyle int
const (
markdownDisplayNormal markdownDisplayStyle = iota
markdownDisplayBold
markdownDisplayCode
markdownDisplayRule
)
@@ -262,6 +265,8 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
case markdownDisplayBold:
// Emphasis is weight-only (no colour) — dark-mode-friendly and calm.
builder.WriteString(zeroTheme.ink.Bold(true).Render(text))
case markdownDisplayCode:
builder.WriteString(inlineCodeStyle().Render(text))
case markdownDisplayRule:
builder.WriteString(zeroTheme.lineStrong.Render(text))
default:
@@ -282,6 +287,16 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
style = markdownDisplayNormal
index += len(markdownBoldEnd)
continue
case strings.HasPrefix(line[index:], markdownCodeStart):
flush()
style = markdownDisplayCode
index += len(markdownCodeStart)
continue
case strings.HasPrefix(line[index:], markdownCodeEnd):
flush()
style = markdownDisplayNormal
index += len(markdownCodeEnd)
continue
case line[index] == '\x1b':
// Already-styled input (highlighted code, headings, tables) carries real
// ANSI. Emit each escape verbatim instead of treating its bytes as runes
@@ -313,6 +328,13 @@ func styleAssistantMarkdownLine(line string, base lipgloss.Style) string {
return builder.String()
}
func hasMarkdownDisplayControls(line string) bool {
return strings.Contains(line, markdownBoldStart) ||
strings.Contains(line, markdownBoldEnd) ||
strings.Contains(line, markdownCodeStart) ||
strings.Contains(line, markdownCodeEnd)
}
func hasExternalANSIStyle(line string) bool {
for index := 0; index < len(line); {
if line[index] != '\x1b' {
@@ -325,7 +347,7 @@ func hasExternalANSIStyle(line string) bool {
continue
}
seq := line[index:end]
if seq != markdownBoldStart && seq != markdownBoldEnd {
if seq != markdownBoldStart && seq != markdownBoldEnd && seq != markdownCodeStart && seq != markdownCodeEnd {
return true
}
index = end
@@ -334,6 +356,9 @@ func hasExternalANSIStyle(line string) bool {
}
func markdownDisplayStyleForRune(r rune, current markdownDisplayStyle) markdownDisplayStyle {
if current == markdownDisplayCode {
return current
}
switch r {
case '│', '─', '┼', '╭', '╮', '╰', '╯', '├', '┤', '┬', '┴':
return markdownDisplayRule
@@ -980,6 +1005,7 @@ func renderMarkdownStandaloneLine(line string) string {
type markdownInlineSegment struct {
text string
bold bool
code bool
}
func wrapMarkdownInline(text string, measure int) []string {
@@ -1028,7 +1054,7 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
flush()
}
head, tail := splitAtWidth(word.text, available)
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold}))
lines = append(lines, prefix+renderMarkdownInlineSegment(markdownInlineSegment{text: head, bold: word.bold, code: word.code}))
word.text = tail
prefix = continuationPrefix
available = maxInt(1, measure-lipgloss.Width(prefix))
@@ -1063,8 +1089,14 @@ func wrapMarkdownInlineWithPrefixes(firstPrefix string, continuationPrefix strin
func markdownInlineWords(segments []markdownInlineSegment) []markdownInlineSegment {
words := []markdownInlineSegment{}
for _, segment := range segments {
if segment.code {
if segment.text != "" {
words = append(words, segment)
}
continue
}
for _, word := range strings.Fields(segment.text) {
words = append(words, markdownInlineSegment{text: word, bold: segment.bold})
words = append(words, markdownInlineSegment{text: word, bold: segment.bold, code: segment.code})
}
}
return words
@@ -1078,6 +1110,9 @@ func renderMarkdownInlineSegment(segment markdownInlineSegment) string {
if segment.text == "" {
return ""
}
if segment.code {
return markdownCodeStart + segment.text + markdownCodeEnd
}
if segment.bold {
return renderMarkdownBoldText(segment.text)
}
@@ -1107,7 +1142,7 @@ func parseMarkdownInline(text string) []markdownInlineSegment {
if builder.Len() == 0 {
return
}
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code})
segments = append(segments, markdownInlineSegment{text: builder.String(), bold: bold && !code, code: code})
builder.Reset()
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"strings"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
attachmentImageID = uint32(0xC0E0)
attachmentPreviewCols = 10
attachmentPreviewRows = 4
attachmentPreviewHeightPixels = 64
attachmentPreviewMaxImages = 4
)
// attachmentThumbnailVisible deliberately has a conservative layout gate. The
// image is an enhancement to the existing attachment chips, not a reason to
// crowd a narrow or modal surface.
func (m model) attachmentThumbnailVisible(width int) bool {
if len(m.attachmentRenderers) == 0 || !m.altScreen ||
m.height < attachmentPreviewRows+10 || width < attachmentPreviewCols+28 {
return false
}
if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil ||
m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil ||
m.sttKeyPrompt != nil || m.renamePrompt != nil || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay {
return false
}
for index := 0; index < m.attachmentThumbnailSlots(width); index++ {
if index < len(m.pendingImageThumbnails) && m.pendingImageThumbnails[index] != nil &&
index < len(m.attachmentRenderers) && m.attachmentRenderers[index].Support().Supported() {
return true
}
}
return false
}
func (m model) attachmentThumbnailSlots(width int) int {
innerWidth := maxInt(1, width-m.petComposerReservedColumns(width)-4)
slots := maxInt(1, innerWidth/(attachmentPreviewCols+1))
return min(attachmentPreviewMaxImages, min(slots, len(m.attachmentRenderers)))
}
func (m model) attachmentThumbnailLines(width int) []string {
left := strings.Repeat(" ", attachmentPreviewCols+1)
lines := make([]string, attachmentPreviewRows)
for index := range lines {
lines[index] = left
}
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return lines
}
func (m model) attachmentThumbnailSupplementalChips() string {
if len(m.pendingImageLabels) <= 1 && len(m.pendingDocuments) == 0 {
return ""
}
return renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments)
}
func (m model) attachmentComposerPrefixRows(width int) int {
if m.attachmentThumbnailVisible(width) {
rows := attachmentPreviewRows
if m.attachmentThumbnailSupplementalChips() != "" {
rows++
}
return rows
}
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
return 1
}
return 0
}
// attachmentImageDraw places one gallery thumbnail inside the reserved top rows
// of the composer. The position comes from the same footer layout used for mouse
// input, so it follows sidebar changes and terminal resizes instead of relying on
// a brittle absolute bottom-right calculation.
func (m model) attachmentImageDraw(index int) *terminalpet.ImageDraw {
width := m.chatColumnWidth()
if !m.attachmentThumbnailVisible(width) || index < 0 || index >= m.attachmentThumbnailSlots(width) ||
index >= len(m.pendingImageThumbnails) || m.pendingImageThumbnails[index] == nil {
return nil
}
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
if frame.composerRect.height < attachmentPreviewRows+2 {
return nil
}
return &terminalpet.ImageDraw{
ID: attachmentImageID + uint32(index),
Animation: m.pendingImageThumbnails[index],
State: terminalpet.Idle,
X: frame.composerRect.x + 2 + index*(attachmentPreviewCols+1),
Y: frame.composerRect.y + 1,
Columns: attachmentPreviewCols,
Rows: attachmentPreviewRows,
HeightPixels: attachmentPreviewHeightPixels,
}
}
func attachmentPreviewImageIDs() []uint32 {
ids := make([]uint32, attachmentPreviewMaxImages)
for index := range ids {
ids[index] = attachmentImageID + uint32(index)
}
return ids
}
+168
View File
@@ -0,0 +1,168 @@
package tui
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func previewImageBlock(t *testing.T) zeroruntime.ImageBlock {
t.Helper()
frame := image.NewNRGBA(image.Rect(0, 0, 8, 6))
for y := 0; y < 6; y++ {
for x := 0; x < 8; x++ {
frame.Set(x, y, color.NRGBA{R: 60, G: 170, B: 220, A: 255})
}
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
t.Fatal(err)
}
return zeroruntime.ImageBlock{MediaType: "image/png", Data: encoded.Bytes()}
}
func TestAttachmentThumbnailRendersInsideComposerWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
if len(m.pendingImageThumbnails) != 1 || m.pendingImageThumbnails[0] == nil {
t.Fatal("expected staged image thumbnail")
}
view := m.View()
plain := plainRender(t, view.Content)
if strings.Contains(plain, "[Image #1]") {
t.Fatalf("supported terminal should use thumbnail strip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("thumbnail strip should not duplicate a text attachment label, got:\n%s", plain)
}
var output bytes.Buffer
if err := m.attachmentRenderers[0].Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=10,r=4") {
t.Fatalf("thumbnail did not schedule a compact Kitty image: %q", got)
}
}
func TestAttachmentThumbnailKeepsAdditionalImagesVisibleWhenSupported(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t), previewImageBlock(t)}
m.pendingImageLabels = []string{"first.png", "second.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #2]") {
t.Fatalf("a supported terminal must keep a second staged image visible, got:\n%s", plain)
}
_ = m.View()
for index, renderer := range m.attachmentRenderers {
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
id := "i=" + strconv.FormatUint(uint64(attachmentImageID+uint32(index)), 10)
if !strings.Contains(output.String(), id) {
t.Fatalf("thumbnail %d was not rendered, got %q", index+1, output.String())
}
}
}
func TestAttachmentThumbnailFallsBackToChipsWithoutTerminalGraphics(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
plain := plainRender(t, m.composerBox(m.width))
if !strings.Contains(plain, "[Image #1]") {
t.Fatalf("unsupported terminal should retain text attachment chip, got:\n%s", plain)
}
if strings.Contains(plain, "Image #1 attached") {
t.Fatalf("unsupported terminal should not reserve thumbnail rows, got:\n%s", plain)
}
}
func TestAttachmentThumbnailClearsWithPendingImage(t *testing.T) {
m := newModel(context.Background(), Options{})
m.width, m.height = 100, 30
m.altScreen = true
m.headerPrinted = true
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.pendingImageLabels = []string{"diagram.png"}
m.refreshPendingImageThumbnail()
_ = m.View()
var first bytes.Buffer
if err := m.attachmentRenderers[0].Render(&first); err != nil {
t.Fatal(err)
}
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
_ = m.View()
var cleared bytes.Buffer
if err := m.attachmentRenderers[0].Render(&cleared); err != nil {
t.Fatal(err)
}
if !strings.Contains(cleared.String(), "a=d,d=I,i=49376") {
t.Fatalf("clearing pending image should delete its Kitty placement, got %q", cleared.String())
}
}
func TestPetOutputCompositesAttachmentAndPetInOneFrame(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 2, 2)))
if err != nil {
t.Fatal(err)
}
pet := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
attachment := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
pet.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
attachment.Set(&terminalpet.ImageDraw{ID: attachmentImageID, Animation: animation, State: terminalpet.Idle, Columns: 10, Rows: 4})
sink, err := os.CreateTemp(t.TempDir(), "image-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sink.Close() })
if _, err := newPetImageOutput(sink, pet, attachment).Write([]byte(terminalSyncStart + "frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(sink.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Count(got, terminalSyncEnd) != 1 {
t.Fatalf("graphics should share Bubble Tea's synchronized frame: %q", got)
}
for _, id := range []string{"i=49374", "i=49376"} {
if !strings.Contains(got, id) {
t.Fatalf("missing image placement %s: %q", id, got)
}
}
}
+1
View File
@@ -136,6 +136,7 @@ func (m model) handleBTWCommand(question string) (model, tea.Cmd) {
if question == "" {
side.pendingImages = nil
side.pendingImageLabels = nil
side.pendingImageThumbnails = nil
side.pendingDocuments = nil
}
side.loops = nil
-1
View File
@@ -107,7 +107,6 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+56
View File
@@ -121,3 +121,59 @@ func TestToolResultRowExposesClickToggle(t *testing.T) {
t.Fatalf("tool result visible body/footer must stay selectable, got %#v", selectable)
}
}
func TestAlwaysExpandedToolResultHeaderIsNotToggleable(t *testing.T) {
m := transcriptViewTestModel()
for _, tool := range []string{"edit_file", "apply_patch", "write_file"} {
row := transcriptRow{
kind: rowToolResult,
id: "diff",
tool: tool,
status: tools.StatusOK,
detail: "--- a/example.go\n+++ b/example.go\n@@ -1 +1 @@\n-old\n+new",
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatalf("%s: expected a visible diff result row", tool)
}
if selectable[0].toggle {
t.Fatalf("%s header must not advertise a collapse toggle: %#v", tool, selectable[0])
}
}
}
func TestToolResultOutputCannotCreateToggle(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{
kind: rowToolResult,
id: "custom",
tool: "custom_tool",
status: tools.StatusOK,
detail: "the file literally says: click to expand",
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatal("expected a visible tool result row")
}
if selectable[0].toggle {
t.Fatalf("tool-output text must not create a header toggle: %#v", selectable[0])
}
}
func TestCollapsedExploreToolResultHeaderIsToggleable(t *testing.T) {
m := transcriptViewTestModel()
row := transcriptRow{
kind: rowToolResult,
id: "read",
tool: "read_file",
status: tools.StatusOK,
detail: numberedLines(cardBodyMaxLines + 5),
}
_, selectable := m.renderSelectableToolResultRow(0, row, m.width, buildRowContext(nil), 0)
if len(selectable) == 0 {
t.Fatal("expected a visible explore result row")
}
if !selectable[0].toggle {
t.Fatalf("collapsed explore card header must remain toggleable: %#v", selectable[0])
}
}
+28 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -731,14 +732,16 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
}, true
}
if provider, ok := m.activeProviderDescriptor(); ok {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, candidate := range providerModelSwitchCandidates(provider.ID, args) {
for _, model := range m.modelPickerLiveByProvider[provider.ID] {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, strings.TrimSpace(args)) {
return modelSwitchTarget{modelID: model.ID}, true
for _, model := range providermodelcatalog.Models(provider) {
if strings.EqualFold(model.ID, candidate) {
return modelSwitchTarget{modelID: model.ID}, true
}
}
}
if genericProviderCatalogID(provider.ID) && strings.TrimSpace(args) != "" {
@@ -748,6 +751,24 @@ func (m model) resolveModelSwitchTarget(registry modelregistry.Registry, args st
return modelSwitchTarget{}, false
}
// providerModelSwitchCandidates preserves provider-owned model IDs while
// accepting the models.dev OpenAI namespace for ChatGPT subscription models.
// The ChatGPT backend itself publishes bare model IDs (for example,
// "gpt-5.6-sol"); gateway providers may legitimately require "openai/..."
// as part of their model ID, so the alias is deliberately provider-specific.
func providerModelSwitchCandidates(providerID string, input string) []string {
requested := strings.TrimSpace(input)
candidates := []string{requested}
const openAIPrefix = "openai/"
if providercatalog.NormalizeID(providerID) != "chatgpt" || len(requested) <= len(openAIPrefix) || !strings.EqualFold(requested[:len(openAIPrefix)], openAIPrefix) {
return candidates
}
if bare := strings.TrimSpace(requested[len(openAIPrefix):]); bare != "" {
candidates = append(candidates, bare)
}
return candidates
}
func (m model) requestCompactionBeforeModelSwitch(request modelSwitchCompactionRequest, title string) (model, string, bool) {
if modelSwitchCompactionGuard == nil {
return m, "", false
+1 -4
View File
@@ -269,10 +269,7 @@ func (m model) composerPositionAtMouse(msg tea.MouseMsg) (int, bool) {
if width < 8 {
return m.composerPositionAtVisualCell(localX, localY, width)
}
contentY := localY - 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY--
}
contentY := localY - 1 - m.attachmentComposerPrefixRows(width)
if contentY < 0 {
return 0, false
}
+38 -13
View File
@@ -61,6 +61,7 @@ type dictationController struct {
browseVariants []dictation.ModelVariant
// in-flight session state
sessionID int64
streaming bool
recorder Recorder
transcriber Transcriber
@@ -101,13 +102,17 @@ type dictationController struct {
}
// dictationStartedMsg reports the outcome of Recorder.Start().
// sessionID must match the dictation controller's current session so a late
// start result after cancel/reset cannot reset a newer recording.
type dictationStartedMsg struct {
err error
sessionID int64
err error
}
// dictationTranscribedMsg carries the final transcript of a batch (or a
// completed streaming) recording.
type dictationTranscribedMsg struct {
sessionID int64
text string
err error
submit bool
@@ -125,6 +130,7 @@ func newDictationController(opts Options) dictationController {
platform: dictation.DetectPlatform(),
downloadRoot: opts.STTDownloadRoot,
userConfigPath: opts.UserConfigPath,
sessionID: 1,
}
}
@@ -195,13 +201,13 @@ func (m model) toggleVoiceMode() (model, tea.Cmd) {
}
m.dictation.voiceModeEnabled = !m.dictation.voiceModeEnabled
if m.dictation.voiceModeEnabled {
return m.appendSystemNotice("Voice mode on (" + m.dictation.currentModelLabel() + ") — hold Space to dictate, release to transcribe. Run /voice again to turn it off (so Space types normally)."), nil
return m.showTransientNoticeInline("Voice mode on — hold Space to dictate; run /voice again to turn it off.", transientNoticeSuccess), nil
}
// Turning voice off is the "done dictating" signal, so release the warm sherpa
// streaming server — otherwise a loaded model keeps idling in RAM (and holding
// its port) until the app exits. It respawns lazily on the next streaming
// recording. Skip while a recording is still in flight so we don't kill it.
next := m.appendSystemNotice("Voice mode off.")
next := m.showTransientNoticeInline("Voice mode off.", transientNoticeInfo)
if next.dictation.active() {
return next, nil
}
@@ -262,7 +268,7 @@ func (m model) startDictation() (model, tea.Cmd) {
if streaming {
return m.startStreamingDictation()
}
return m, startBatchRecordingCmd(rec)
return m, startBatchRecordingCmd(m.dictation.sessionID, rec)
}
// stopDictation ends a recording. For batch it triggers Stop()+Transcribe(); for
@@ -276,7 +282,7 @@ func (m model) stopDictation() (model, tea.Cmd) {
}
return m, nil // final text arrives via the streaming command already running
}
return m, transcribeBatchCmd(m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
return m, transcribeBatchCmd(m.dictation.sessionID, m.dictation.ctx, m.dictation.recorder, m.dictation.transcriber, m.dictation.cfg.AutoSubmitEnabled())
}
// cancelDictation aborts an in-flight recording without transcribing. Bound to
@@ -293,7 +299,7 @@ func (m model) cancelDictation() (model, tea.Cmd) {
}
m = m.discardDictationRegion()
m.dictation.reset()
return m.appendSystemNotice("Dictation cancelled."), nil
return m.showTransientNoticeInline("Dictation cancelled.", transientNoticeInfo), nil
}
// wantStreaming decides whether this recording uses the streaming pipeline:
@@ -311,6 +317,7 @@ func (d dictationController) maxDuration() time.Duration {
}
func (d *dictationController) reset() {
d.sessionID++
d.phase = dictIdle
d.recorder = nil
d.transcriber = nil
@@ -326,31 +333,36 @@ func (d *dictationController) reset() {
// startBatchRecordingCmd starts the record-to-file capture off the UI goroutine
// (Start spawns a subprocess and may briefly block or fail on a missing tool).
func startBatchRecordingCmd(rec Recorder) tea.Cmd {
func startBatchRecordingCmd(sessionID int64, rec Recorder) tea.Cmd {
return func() tea.Msg {
return dictationStartedMsg{err: rec.Start()}
return dictationStartedMsg{sessionID: sessionID, err: rec.Start()}
}
}
// transcribeBatchCmd stops the recording, transcribes the audio, and reports the
// final text. Runs off the UI goroutine — Stop waits on the capture tool and
// Transcribe does a network round-trip or a local exec.
func transcribeBatchCmd(ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
func transcribeBatchCmd(sessionID int64, ctx context.Context, rec Recorder, transcriber Transcriber, submit bool) tea.Cmd {
if ctx == nil {
ctx = context.Background()
}
return func() tea.Msg {
audio, err := rec.Stop()
if err != nil {
return dictationTranscribedMsg{err: err}
return dictationTranscribedMsg{sessionID: sessionID, err: err}
}
text, err := transcriber.Transcribe(ctx, audio)
return dictationTranscribedMsg{text: text, err: err, submit: submit}
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit}
}
}
// handleDictationStarted transitions to recording (or reports a start failure).
func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
// Stale startup from a cancelled session must not reset or re-arm a
// newer recording that already owns the controller.
return m, nil
}
if msg.err != nil {
m = m.discardDictationRegion()
m.dictation.reset()
@@ -375,6 +387,9 @@ func (m model) handleDictationStarted(msg dictationStartedMsg) (model, tea.Cmd)
// handleDictationTranscribed inserts the final transcript into the composer (or
// submits it when stt.autoSubmit is on), then returns to idle.
func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Model, tea.Cmd) {
if msg.sessionID != m.dictation.sessionID {
return m, nil
}
streaming := m.dictation.streaming || msg.streaming
m = m.commitDictationRegion()
// A streaming session can end via a transcriber error (not just a user stop), so
@@ -392,7 +407,17 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
}
m.dictation.reset()
if msg.err != nil && !errors.Is(msg.err, context.Canceled) {
if errors.Is(msg.err, context.Canceled) {
// cancelDictation already discarded the live region, reset the
// session, and posted "Dictation cancelled." A streaming transcriber
// can still race a buffered event past that cancel and report back a
// nonempty compose()+context.Canceled; treat that as terminal here,
// before the auto-submit branch below, so Esc can never fall through
// to msg.submit and fire the composer's restored pre-existing text.
return m, nil
}
if msg.err != nil {
// A cloud auth failure (missing/invalid key) is fixable in place: reopen the
// API-key prompt for the current provider so the user can paste a key and
// retry, instead of hitting a dead-end "run zero auth" line.
@@ -413,7 +438,7 @@ func (m model) handleDictationTranscribed(msg dictationTranscribedMsg) (tea.Mode
if streaming {
return m, nil // streaming already rendered live; nothing to add
}
return m.appendSystemNotice("No speech detected."), nil
return m.showTransientNoticeInline("No speech detected.", transientNoticeInfo), nil
}
if !streaming {
m = m.insertDictatedText(msg.text)
+3 -3
View File
@@ -42,7 +42,7 @@ func (d dictationController) canOfferDownload(err error) bool {
// variant, reporting progress live, then persists and applies the resolved paths.
func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
if m.dictation.downloading {
return m.appendSystemNotice("A dictation download is already in progress — please wait for it to finish."), nil
return m.showTransientNoticeInline("Dictation setup is already in progress.", transientNoticeWarning), nil
}
m.dictation.downloading = true
sink := m.runtimeMessageSink
@@ -53,7 +53,7 @@ func (m model) startVariantDownload(v dictation.ModelVariant) (model, tea.Cmd) {
base = context.Background()
}
m.dictation.downloadStatus = "Starting download…"
m = m.appendSystemNotice(fmt.Sprintf("Setting up local dictation — downloading the offline engine and %s (~%d MB). This runs once; progress is shown in the status bar below.", v.Label, (v.Bytes>>20)+(engineDownloadBytes>>20)))
m = m.showTransientNoticeInline(fmt.Sprintf("Setting up local dictation (%s) — progress is in the status line.", v.Label), transientNoticeInfo)
return m, func() tea.Msg {
comp, err := dictation.EnsureLocalEngine(base, dictation.DownloadOptions{
DestRoot: root,
@@ -93,7 +93,7 @@ func (m model) handleDictationDownloaded(msg dictationDownloadedMsg) (model, tea
if err != nil {
return m.appendSystemNotice("Downloaded the engine, but couldn't save the config: " + err.Error()), nil
}
return m.appendSystemNotice("Local dictation is ready. Run /voice, then hold Space to dictate."), nil
return m.showTransientNoticeInline("Local dictation is ready. Run /voice to start dictating.", transientNoticeSuccess), nil
}
// applyEngineComponents persists and applies resolved engine/model paths to the
+3 -2
View File
@@ -2,6 +2,7 @@ package tui
import (
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
@@ -79,7 +80,7 @@ func TestDownloadedBatchModelAppliedToConfig(t *testing.T) {
if got.dictation.cfg.StreamingEnabled() {
t.Error("a batch variant should set streaming off")
}
if !transcriptHasText(got, "ready") {
if !strings.Contains(got.transientNotice.text, "ready") {
t.Error("expected a ready notice")
}
}
@@ -158,7 +159,7 @@ func TestSTTDownloadSelectionStartsDownload(t *testing.T) {
if cmd == nil {
t.Error("expected a download command")
}
if !transcriptHasText(next, variant.Label) {
if !strings.Contains(next.transientNotice.text, variant.Label) {
t.Error("expected the download-starting notice to name the variant")
}
}
+9 -4
View File
@@ -14,8 +14,9 @@ import (
// mechanism agent text deltas use (§6). text is the cumulative best transcript
// so far (not a delta); final marks a settled segment.
type sttPartialMsg struct {
text string
final bool
sessionID int64
text string
final bool
}
// startStreamingDictation begins continuous capture and launches the streaming
@@ -38,6 +39,7 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
}
transcriber := m.dictation.transcriber
submit := m.dictation.cfg.AutoSubmitEnabled()
sessionID := m.dictation.sessionID
// Tap the audio: compute a mic level per chunk for the live waveform, then
// forward the chunk to the transcriber. A small buffer keeps the tap from
@@ -63,11 +65,11 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
streamCmd := func() tea.Msg {
onPartial := func(text string, final bool) {
if sink != nil {
sink(sttPartialMsg{text: text, final: final})
sink(sttPartialMsg{sessionID: sessionID, text: text, final: final})
}
}
text, err := transcriber.StreamTranscribe(ctx, tapped, onPartial)
return dictationTranscribedMsg{text: text, err: err, submit: submit, streaming: true}
return dictationTranscribedMsg{sessionID: sessionID, text: text, err: err, submit: submit, streaming: true}
}
// Streaming drives the waveform from real levels (no synthetic tick needed).
return m, streamCmd
@@ -77,6 +79,9 @@ func (m model) startStreamingDictation() (model, tea.Cmd) {
// composer, replacing the previously-rendered live region wholesale so the text
// builds up in place as the user keeps talking.
func (m model) handleDictationPartial(msg sttPartialMsg) model {
if msg.sessionID != m.dictation.sessionID {
return m
}
// Ignore stragglers that arrive after the session ended (cancel/final).
if m.dictation.phase != dictRecording && m.dictation.phase != dictTranscribing {
return m
+186 -4
View File
@@ -33,8 +33,11 @@ func TestDictationTranscribedEmptyIsNoticed(t *testing.T) {
if got.composer.text != "" {
t.Errorf("composer should stay empty, got %q", got.composer.text)
}
if !transcriptHasText(got, "No speech detected") {
t.Error("expected a 'no speech' notice")
if got.transientNotice.text != "No speech detected." {
t.Errorf("no-speech notice = %q", got.transientNotice.text)
}
if transcriptHasText(got, "No speech detected") {
t.Error("no-speech confirmation should not be persisted in the transcript")
}
}
@@ -86,9 +89,10 @@ func TestDictationNonAuthErrorDoesNotPrompt(t *testing.T) {
func TestDictationStartedFailureResets(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
m.dictation.streaming = false
got, _ := m.handleDictationStarted(dictationStartedMsg{err: errors.New("mic busy")})
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1, err: errors.New("mic busy")})
if got.dictation.active() {
t.Error("a start failure should reset to idle")
}
@@ -99,13 +103,48 @@ func TestDictationStartedFailureResets(t *testing.T) {
func TestDictationStartedArmsRecording(t *testing.T) {
m := model{}
m.dictation.sessionID = 1
m.dictation.phase = dictStarting
got, _ := m.handleDictationStarted(dictationStartedMsg{})
got, _ := m.handleDictationStarted(dictationStartedMsg{sessionID: 1})
if got.dictation.phase != dictRecording {
t.Error("a successful start should advance to recording")
}
}
// TestStaleDictationStartedIgnoredWhileLive pins the session gate on batch
// startup messages. A late Start() result after cancel must not reset() a
// newer recording (success would leave dictStarting with no recorder; failure
// would kill the live session).
func TestStaleDictationStartedIgnoredWhileLive(t *testing.T) {
// Active recording on session 2.
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "live session text", cursor: len("live session text")})
// Stale failure from session 1 must not reset the live controller.
afterFail, _ := m.handleDictationStarted(dictationStartedMsg{
sessionID: 1,
err: errors.New("mic busy from previous start"),
})
if afterFail.dictation.phase != dictRecording || afterFail.dictation.sessionID != 2 {
t.Fatalf("stale start failure: phase=%v session=%d, want recording/2", afterFail.dictation.phase, afterFail.dictation.sessionID)
}
if transcriptHasText(afterFail, "mic busy from previous start") {
t.Fatal("stale start failure must not post an error notice")
}
// Stale success from session 1 must not leave the live session stuck mid-start.
m2 := model{}
m2.dictation.sessionID = 2
m2.dictation.phase = dictRecording
afterOK, _ := m2.handleDictationStarted(dictationStartedMsg{sessionID: 1})
if afterOK.dictation.phase != dictRecording || afterOK.dictation.sessionID != 2 {
t.Fatalf("stale start success: phase=%v session=%d, want recording/2", afterOK.dictation.phase, afterOK.dictation.sessionID)
}
}
func TestDictationUnavailableShowsHint(t *testing.T) {
m := model{} // no build factory
next, _ := m.toggleDictation()
@@ -136,6 +175,69 @@ func TestDictationStreamingPartialReplacesRegion(t *testing.T) {
}
}
func TestDictationCanceledStreamRaceDoesNotAutoSubmit(t *testing.T) {
// Esc can race an already-buffered realtime event: cancelDictation discards
// the live region and resets state synchronously, but the streaming
// goroutine's dictationTranscribedMsg (a nonempty compose() alongside
// context.Canceled) can still arrive afterward. With stt.autoSubmit on,
// that must never fall through to msg.submit and fire the composer's
// restored pre-existing text.
// Matching sessionID is required so this hits the context.Canceled branch
// rather than only the stale-session gate (session 0 after cancel would be
// dropped before that branch runs).
m := model{}
m.dictation.sessionID = 1
m.setComposerState(composerState{text: "existing prompt", cursor: len("existing prompt")})
m.dictation.phase = dictRecording
m.dictation.streaming = true
m = m.handleDictationPartial(sttPartialMsg{sessionID: 1, text: "half-formed transcript"})
if m.composer.text != "existing prompt half-formed transcript" {
t.Fatalf("partial did not render into composer: %q", m.composer.text)
}
m, _ = m.cancelDictation()
if m.composer.text != "existing prompt" {
t.Fatalf("cancel should restore the pre-existing composer text, got %q", m.composer.text)
}
// The already-buffered event's message arrives after the cancel above.
next, cmd := m.handleDictationTranscribed(dictationTranscribedMsg{
sessionID: m.dictation.sessionID,
text: "half-formed transcript",
err: context.Canceled,
submit: true,
streaming: true,
})
got := next.(model)
if got.composer.text != "existing prompt" {
t.Errorf("a raced cancellation must not auto-submit the restored composer text, got %q", got.composer.text)
}
if cmd != nil {
t.Error("a raced cancellation must not return a submit command")
}
}
// TestStaleDictationPartialIgnoredWhileLive pins the session-ID gate on
// handleDictationPartial while a new session is still recording. Cancel-then-
// stale cases are already covered by the phase guard; this case needs a live
// phase so only the session check drops the ghost partial.
func TestStaleDictationPartialIgnoredWhileLive(t *testing.T) {
m := model{}
m.dictation.sessionID = 2
m.dictation.phase = dictRecording
m.dictation.streaming = true
m.setComposerState(composerState{text: "user typed this", cursor: len("user typed this")})
got := m.handleDictationPartial(sttPartialMsg{
sessionID: 1,
text: "ghost text from session 1",
})
if got.composer.text != "user typed this" {
t.Fatalf("stale partial while live: got %q, want %q", got.composer.text, "user typed this")
}
}
func TestDictationCommitKeepsStreamedText(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "", cursor: 0})
@@ -302,3 +404,83 @@ func TestCurrentModelLabel(t *testing.T) {
t.Errorf("no-model label = %q", got)
}
}
func TestStaleDictationCompletionIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "original composer text", cursor: 22})
m.dictation.sessionID = 1
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
if m.composer.text != "original composer text" {
t.Fatalf("composer text = %q, want 'original composer text'", m.composer.text)
}
// Stale partial message from session 1 must be ignored
stalePartial := sttPartialMsg{
sessionID: 1,
text: "stale partial that should be ignored",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale partial", mPartial.composer.text)
}
staleMsg := dictationTranscribedMsg{
sessionID: 1,
text: "stale text that should be ignored",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "original composer text" {
t.Fatalf("composer text changed to %q after stale completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale completion, got %v", cmd)
}
}
func TestStaleDictationSessionZeroIgnoredAfterCancel(t *testing.T) {
m := model{}
m.setComposerState(composerState{text: "initial composer text", cursor: 21})
m.dictation.sessionID = 0
m.dictation.phase = dictRecording
m, _ = m.cancelDictation()
// Session 0 was reset to session 1
if m.dictation.sessionID != 1 {
t.Fatalf("dictation.sessionID = %d after cancel, want 1", m.dictation.sessionID)
}
// Stale partial from session 0 must be ignored
stalePartial := sttPartialMsg{
sessionID: 0,
text: "stale partial from session 0",
}
mPartial := m.handleDictationPartial(stalePartial)
if mPartial.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 partial", mPartial.composer.text)
}
// Stale completion from session 0 must be ignored
staleMsg := dictationTranscribedMsg{
sessionID: 0,
text: "stale completion from session 0",
submit: true,
streaming: true,
}
afterStale, cmd := m.handleDictationTranscribed(staleMsg)
got := afterStale.(model)
if got.composer.text != "initial composer text" {
t.Fatalf("composer text changed to %q after stale session 0 completion", got.composer.text)
}
if cmd != nil {
t.Fatalf("expected no command for stale session 0 completion, got %v", cmd)
}
}
+5 -2
View File
@@ -36,8 +36,11 @@ func TestToggleVoiceModeFlips(t *testing.T) {
if !next.dictation.voiceModeEnabled {
t.Fatal("first /voice should enable voice mode")
}
if !transcriptHasText(next, "Voice mode on") {
t.Error("expected voice-mode-on notice")
if next.transientNotice.text != "Voice mode on — hold Space to dictate; run /voice again to turn it off." {
t.Errorf("voice-mode-on notice = %q", next.transientNotice.text)
}
if transcriptHasText(next, "Voice mode on") {
t.Error("voice-mode-on confirmation should not be persisted in the transcript")
}
off, _ := next.toggleVoiceMode()
if off.dictation.voiceModeEnabled {
+64
View File
@@ -0,0 +1,64 @@
package tui
import "strings"
// Visible unchanged lines kept at each edge of a collapsed diff section.
const diffViewerContextLines = 3
// diffViewerLine is a source diff line or a synthetic collapsed-context row.
// rawIndex keeps rendering anchored to the original diff so line-pair and
// syntax-highlighting logic remain correct after context compaction.
type diffViewerLine struct {
text string
rawIndex int
hiddenContext int
}
// compactDiffViewerContext returns the display sequence for a unified diff.
// Long contiguous unchanged runs keep their leading and trailing context while
// one synthetic line accounts for the elided middle. Structural and changed
// rows always remain intact.
func compactDiffViewerContext(raw []string) []diffViewerLine {
lines := make([]diffViewerLine, 0, len(raw))
hunk := diffHunkState{}
for index := 0; index < len(raw); {
if parsed, ok := parseDiffHunkHeader(raw[index]); ok {
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
hunk = parsed
index++
continue
}
if !hunk.active() || !strings.HasPrefix(raw[index], " ") {
lines = append(lines, diffViewerLine{text: raw[index], rawIndex: index})
if hunk.active() && isDiffHunkBodyLine(raw[index]) {
hunk.consume(raw[index])
}
index++
continue
}
start := index
for index < len(raw) && hunk.active() && strings.HasPrefix(raw[index], " ") {
hunk.consume(raw[index])
index++
}
count := index - start
// Collapsing seven lines would replace one readable line with one marker,
// leaving the display equally tall. Only collapse when it removes space.
if count <= diffViewerContextLines*2+1 {
for i := start; i < index; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
continue
}
for i := start; i < start+diffViewerContextLines; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
lines = append(lines, diffViewerLine{rawIndex: -1, hiddenContext: count - diffViewerContextLines*2})
for i := index - diffViewerContextLines; i < index; i++ {
lines = append(lines, diffViewerLine{text: raw[i], rawIndex: i})
}
}
return lines
}
+402
View File
@@ -0,0 +1,402 @@
package tui
import (
"fmt"
"strings"
"testing"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/colorprofile"
)
func requireDiffViewerTrueColor(t *testing.T) {
t.Helper()
oldProfile := lipgloss.Writer.Profile
lipgloss.Writer.Profile = colorprofile.TrueColor
t.Cleanup(func() {
lipgloss.Writer.Profile = oldProfile
})
}
func TestDiffViewerUsesHunkHeaderForLineNumbers(t *testing.T) {
diff := strings.Join([]string{
"--- a/internal/tui/view.go",
"+++ b/internal/tui/view.go",
"@@ -40,3 +40,4 @@ func renderView() {",
" context",
"-old line",
"+new line",
}, "\n")
body := diffCardBody(diff, 90, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if strings.Contains(got, "@@") || !strings.Contains(got, " 41 − old line") || !strings.Contains(got, " 41 + new line") {
t.Fatalf("diff viewer did not apply the hunk range correctly:\n%s", got)
}
}
func TestDiffViewerCollapsesLongUnchangedContext(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,12 +1,12 @@",
}
for i := 1; i <= 12; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
diff := strings.Join(lines, "\n")
body := diffCardBody(diff, 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "… 6 unchanged lines") {
t.Fatalf("diff viewer did not collapse long unchanged context:\n%s", got)
}
for _, want := range []string{"context a", "context b", "context c", "context j", "context k", "context l"} {
if !strings.Contains(got, want) {
t.Errorf("diff viewer should preserve contextual edge %q:\n%s", want, got)
}
}
}
func TestDiffViewerKeepsSevenUnchangedContextLines(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,7 +1,7 @@",
}
for i := 1; i <= diffViewerContextLines*2+1; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if strings.Contains(got, "unchanged lines") {
t.Fatalf("seven unchanged context lines should remain visible:\n%s", got)
}
for _, want := range []string{"context a", "context d", "context g"} {
if !strings.Contains(got, want) {
t.Errorf("diff viewer omitted %q:\n%s", want, got)
}
}
}
func TestDiffViewerKeepsLineNumbersAfterCollapsedContext(t *testing.T) {
lines := []string{
"--- a/x.go",
"+++ b/x.go",
"@@ -1,13 +1,13 @@",
}
for i := 1; i <= 12; i++ {
lines = append(lines, " context "+string(rune('a'+i-1)))
}
lines = append(lines, "-old value", "+new value")
body := diffCardBody(strings.Join(lines, "\n"), 80, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, " 13 − old value") || !strings.Contains(got, " 13 + new value") {
t.Fatalf("line numbers drifted after collapsed context:\n%s", got)
}
}
func TestDiffViewerSyntheticContextHasNoSourceIndex(t *testing.T) {
raw := make([]string, 0, diffViewerContextLines*2+2)
for index := 0; index < diffViewerContextLines*2+2; index++ {
raw = append(raw, " context")
}
for _, line := range compactDiffViewerContext(raw) {
if line.hiddenContext > 0 && line.rawIndex != -1 {
t.Fatalf("synthetic collapsed context rawIndex = %d, want -1", line.rawIndex)
}
}
}
func TestDiffViewerNearRewriteDeletionSurvives(t *testing.T) {
diff := strings.Join([]string{
"--- a/notes.log",
"+++ b/notes.log",
"@@ -1 +1 @@",
"-alpha bravo charlie delta",
"+zulu yankee xray whiskey",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "alpha bravo charlie delta") {
t.Fatalf("near-rewrite deletion disappeared:\n%s", got)
}
}
func TestDiffViewerRendersStyledDeletionOnce(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func value() string { return \"old\" }",
"+func value() string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if count := strings.Count(got, "func value() string { return \"old\" }"); count != 1 {
t.Fatalf("styled deleted row count = %d, want 1:\n%s", count, got)
}
}
func TestDiffViewerDoesNotFoldPreambleContext(t *testing.T) {
raw := []string{
"commit deadbeef",
"Author: Example",
"",
}
for index := 1; index <= 10; index++ {
raw = append(raw, fmt.Sprintf(" commit message line %d", index))
}
raw = append(raw,
"--- a/example.go",
"+++ b/example.go",
"@@ -1,1 +1,1 @@",
"-old value",
"+new value",
)
compacted := compactDiffViewerContext(raw)
for _, line := range compacted {
if line.hiddenContext > 0 {
t.Fatalf("preamble context was folded: %#v", compacted)
}
}
if len(compacted) != len(raw) {
t.Fatalf("compacted preamble length = %d, want %d", len(compacted), len(raw))
}
}
func TestDiffViewerKeepsHeaderLikeHunkContent(t *testing.T) {
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"--- removed",
"+++ added",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
for _, want := range []string{"-- removed", "++ added"} {
if !strings.Contains(got, want) {
t.Fatalf("diff viewer lost header-like hunk content %q:\n%s", want, got)
}
}
meta := diffCardMetadata(diff)
if meta.adds != 1 || meta.dels != 1 {
t.Fatalf("diff metadata counts = +%d -%d, want +1 -1", meta.adds, meta.dels)
}
if _, ok := highlightedDiffLines(strings.Split(diff, "\n"), meta)[3]; !ok {
t.Fatal("deleted header-like hunk content was not syntax highlighted")
}
}
func TestDiffViewerSourcePathStripsOnlyOneDiffPrefix(t *testing.T) {
if got, want := diffViewerSourcePath("--- a/b/c.go"), "b/c.go"; got != want {
t.Fatalf("diffViewerSourcePath = %q, want %q", got, want)
}
}
func TestDiffViewerSyntaxHighlightsBothChangedSides(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func greet(name string) string { return \"old\" }",
"+func greet(name string) string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
joined := strings.Join(body.lines, "\n")
// `func` uses Zero's accent and string literals use its green token style.
if !strings.Contains(joined, "202;255;63") || !strings.Contains(joined, "93;209;164") {
t.Fatalf("diff viewer did not retain Go syntax colors in changed rows:\n%s", joined)
}
// The exact changed string literal keeps the brighter word-diff background.
if !strings.Contains(joined, "46;101;77") || !strings.Contains(joined, "80;45;48") {
t.Fatalf("diff viewer lost word-level changed-span emphasis:\n%s", joined)
}
}
func TestDiffViewerSyntaxHighlightsUnchangedHunkContext(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1,3 +1,3 @@",
" func greet() string {",
"-\treturn \"old\"",
"+\treturn \"new\"",
" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
for _, line := range body.lines {
if strings.Contains(plainRender(t, line), "func greet() string {") {
if !strings.Contains(line, "202;255;63") {
t.Fatalf("unchanged source context was not syntax-highlighted: %q", line)
}
return
}
}
t.Fatal("diff viewer omitted unchanged source context")
}
func TestDiffViewerPreservesSyntaxStateAcrossHunkLines(t *testing.T) {
requireDiffViewerTrueColor(t)
diff := strings.Join([]string{
"--- a/example.py",
"+++ b/example.py",
"@@ -1,3 +1,4 @@",
" def message():",
"- return \"\"\"old\"\"\"",
"+ return \"\"\"new",
"+continued\"\"\"",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
for _, line := range body.lines {
if strings.Contains(plainRender(t, line), "continued") {
if !strings.Contains(line, "93;209;164") {
t.Fatalf("multiline string lost syntax state across hunk rows: %q", line)
}
return
}
}
t.Fatal("diff viewer omitted multiline source row")
}
func TestDiffViewerKeepsNonHunkDiffFallback(t *testing.T) {
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"+func greet() string { return \"new\" }",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "func greet() string { return \"new\" }") {
t.Fatalf("diff viewer lost content without a hunk header:\n%s", got)
}
}
func TestDiffViewerKeepsRenameMetadataWithoutHunks(t *testing.T) {
diff := strings.Join([]string{
"diff --git a/old-name.go b/new-name.go",
"similarity index 100%",
"rename from old-name.go",
"rename to new-name.go",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "rename from old-name.go") || !strings.Contains(got, "rename to new-name.go") {
t.Fatalf("rename-only diff lost its meaningful metadata:\n%s", got)
}
}
func TestDiffViewerKeepsModeMetadataWithoutHunks(t *testing.T) {
diff := strings.Join([]string{
"diff --git a/tool.sh b/tool.sh",
"old mode 100644",
"new mode 100755",
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
got := plainRender(t, strings.Join(body.lines, "\n"))
if !strings.Contains(got, "old mode 100644") || !strings.Contains(got, "new mode 100755") {
t.Fatalf("mode-only diff lost its meaningful metadata:\n%s", got)
}
}
func TestDiffViewerFallsBackForOversizedSourceLine(t *testing.T) {
requireDiffViewerTrueColor(t)
longLine := strings.Repeat("x", diffViewerHighlightMaxLineBytes)
diff := strings.Join([]string{
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"+" + longLine,
}, "\n")
body := diffCardBody(diff, 100, cardRenderOptions{bodyCap: 0})
joined := strings.Join(body.lines, "\n")
if strings.Contains(joined, "202;255;63") {
t.Fatalf("oversized diff line should use the plain diff fallback: %q", joined)
}
if !strings.Contains(plainRender(t, joined), strings.Repeat("x", 10)) {
t.Fatalf("plain fallback lost oversized source line: %q", joined)
}
}
func TestDiffViewerSyntaxHighlighterKeepsSpans(t *testing.T) {
requireDiffViewerTrueColor(t)
styled, ok := highlightCodeForPathWithSpans(
[]string{"func greet(name string) string { return \"new\" }"},
"example.go",
200,
zeroTheme.addLine.GetBackground(),
[]highlightSpan{{line: 0, start: 39, end: 44, background: zeroTheme.addLineWord.GetBackground()}},
)
if !ok || len(styled) != 1 {
t.Fatalf("highlightCodeForPathWithSpans = %#v, %v", styled, ok)
}
if !strings.Contains(styled[0], "46;101;77") {
t.Fatalf("highlighted span lost its word-diff background: %q", styled[0])
}
}
func TestDiffViewerHighlightsEachFileWithItsOwnPath(t *testing.T) {
requireDiffViewerTrueColor(t)
rawLines := []string{
"diff --git a/example.go b/example.go",
"--- a/example.go",
"+++ b/example.go",
"@@ -1 +1 @@",
"-func old() {}",
"+func new() {}",
"diff --git a/example.py b/example.py",
"--- a/example.py",
"+++ b/example.py",
"@@ -1 +1 @@",
"-def old(): pass",
"+def new(): pass",
}
highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n")))
if len(highlighted) != 4 {
t.Fatalf("highlighted line count = %d, want 4: %#v", len(highlighted), highlighted)
}
for _, index := range []int{4, 5, 10, 11} {
if !strings.Contains(highlighted[index], "202;255;63") {
t.Fatalf("source row %d lost syntax highlighting: %q", index, highlighted[index])
}
}
for _, header := range []int{2, 3, 8, 9} {
if _, ok := highlighted[header]; ok {
t.Fatalf("file header at row %d entered syntax highlighter: %q", header, highlighted[header])
}
}
}
func TestDiffViewerHighlightBudgetSpansHunks(t *testing.T) {
hunkLines := diffViewerHighlightMaxLines/2 + 1
rawLines := []string{
"--- a/example.go",
"+++ b/example.go",
fmt.Sprintf("@@ -1,%d +1,%d @@", hunkLines, hunkLines),
}
for i := 0; i < hunkLines; i++ {
rawLines = append(rawLines, " context")
}
rawLines = append(rawLines, fmt.Sprintf("@@ -6000,%d +6000,%d @@", hunkLines, hunkLines))
for i := 0; i < hunkLines; i++ {
rawLines = append(rawLines, " context")
}
if highlighted := highlightedDiffLines(rawLines, diffCardMetadata(strings.Join(rawLines, "\n"))); highlighted != nil {
t.Fatalf("diff exceeding the aggregate highlight budget should use the plain fallback: %d highlighted rows", len(highlighted))
}
}
+24 -15
View File
@@ -6,10 +6,12 @@ import (
"fmt"
"os"
"strings"
"testing"
"time"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/dictation"
"github.com/Gitlawb/zero/internal/tools"
)
func renderMarkdownInline(text string) string {
@@ -50,6 +52,26 @@ func formatGroupedCommandHelpLines() []string {
return lines
}
// runningPlanModel supplies plan state for tests that exercise contextual
// details without reviving the former persistent plan presentation.
func runningPlanModel(t *testing.T, steps int) model {
t.Helper()
m := newModel(t.Context(), Options{ModelName: "gpt-4"})
m.width = 100
base := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
m.now = func() time.Time { return base.Add(15 * time.Second) }
items := make([]tools.PlanItem, steps)
for i := range items {
status := "pending"
if i == 0 {
status = "in_progress"
}
items[i] = tools.PlanItem{Content: fmt.Sprintf("Step number %d here", i+1), Status: status}
}
m.plan.updateFromItems(items, base)
return m
}
func listCommandNames() []string {
names := make([]string, 0, len(commandDefinitions))
for _, command := range commandDefinitions {
@@ -90,20 +112,6 @@ func (m model) overlayMouseTop(overlayHeight int, width int) int {
return m.overlayMouseRect(overlayHeight, width).y
}
// height returns the number of terminal lines renderPlanPanel will occupy at
// the given width (0 when the panel is not visible). The step list is shown
// when the panel is expanded or still running; a collapsed, finished plan is
// just the header and progress bar.
func (s planPanelState) height(width int, now time.Time) int {
if !s.visible(now) {
return 0
}
if s.expanded || !s.isComplete() {
return 2 + len(s.steps)
}
return 2
}
func GetLocalDiffStats(baseBranch string) (additions int, deletions int, err error) {
cwd, err := os.Getwd()
if err != nil {
@@ -244,7 +252,8 @@ func (m model) renderSelectableSpecialistRow(rowIndex int, row transcriptRow, wi
}
func (m model) renderSelectableToolResultRow(rowIndex int, row transcriptRow, width int, rc rowContext, startBodyY int) (string, []transcriptSelectableLine) {
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow)
opts := cardRenderOptions{bodyCap: cardBodyMaxLines, cwd: m.cwd}
return m.renderSelectableToolResultRowFn(rowIndex, row, width, rc, startBodyY, m.renderRow, opts)
}
func (m model) transcriptBody(width int, emptyOverlay string) (string, []transcriptSelectableLine) {
+33 -9
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -161,9 +162,9 @@ func TestFileViewSwapsTranscriptBody(t *testing.T) {
}
}
// TestSubchatEntryClosesFileView: drilling into an AGENTS row while a file view
// is open closes the file view first (the subchat owns the single-column view).
func TestSubchatEntryClosesFileView(t *testing.T) {
// TestSidebarAgentClickIsIgnoredWithoutRail verifies invisible legacy hit
// coordinates cannot unexpectedly replace the full-width conversation view.
func TestSidebarAgentClickIsIgnoredWithoutRail(t *testing.T) {
store := sessions.NewStore(sessions.StoreOptions{RootDir: t.TempDir()})
if _, err := store.Create(sessions.CreateInput{SessionID: "sess-1"}); err != nil {
t.Fatal(err)
@@ -185,14 +186,14 @@ func TestSubchatEntryClosesFileView(t *testing.T) {
}
click := testMouseClick(tea.MouseLeft, m.chatColumnWidth()+3, agents[0].lineOffset)
updated, _, handled := m.handleTranscriptSelectionMouse(click)
if !handled {
t.Fatal("agent row click should be handled")
if handled {
t.Fatal("invisible rail coordinates must not handle clicks")
}
if updated.fileView.active {
t.Fatal("entering the subchat should close the file view")
if !updated.fileView.active {
t.Fatal("an ignored rail click must leave the active file view alone")
}
if !updated.subchat.active {
t.Fatal("subchat should be active")
if updated.subchat.active {
t.Fatal("an ignored rail click must not enter a subchat")
}
}
@@ -217,6 +218,29 @@ func TestChangedFilesRehydration(t *testing.T) {
}
}
// TestResumedFileEditUsesPersistedDisplayPreview keeps the reviewable diff a
// user saw while the run was live. The provider-facing output is intentionally
// a short confirmation, so it cannot substitute for the card-only preview.
func TestResumedFileEditUsesPersistedDisplayPreview(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
events := []sessions.Event{{
Type: sessions.EventToolResult,
Payload: json.RawMessage(`{"toolCallId":"edit-1","name":"edit_file","status":"ok","output":"Successfully edited calculator.go (replaced 1 occurrence).","displayPreview":` + strconv.Quote(preview) + `}`),
}}
rows := transcriptRowsFromSessionEvents(events)
if len(rows) != 1 {
t.Fatalf("expected one restored row, got %d", len(rows))
}
if rows[0].detail != preview {
t.Fatalf("resume should restore the saved diff preview, got %q", rows[0].detail)
}
card := renderToolResultCard(rows[0], 80, rowContext{}, cardRenderOptions{})
if plain := plainRender(t, card); !strings.Contains(plain, "newValue := 2") {
t.Fatalf("resumed edit should render its diff preview, got %q", plain)
}
}
// TestOpenFileViewSamePathIsNoOp: re-clicking the FILES row of the file already
// being viewed must not clobber the user's mode or scroll — the old
// unconditional openFileView bounced full mode back to diff and reset scroll.
+2 -33
View File
@@ -6,10 +6,8 @@
// transcript's tool-result rows (their changedFiles), so it survives resume for
// free and can never drift from what the chat shows.
//
// Interaction (see handleTranscriptSelectionMouse): the first click on a row
// SELECTS the file — its edit cards tint in the chat and the transcript scrolls
// to the most recent one; a second click (or a click while the drill-in is
// already open) opens the file view (file_view.go). Esc clears the selection.
// The same compact rows feed the run-details overlay; selecting a file from a
// visible transcript card opens the file drill-in (file_view.go).
package tui
import (
@@ -17,8 +15,6 @@ import (
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -312,33 +308,6 @@ func (m model) sidebarFileSelectables(width int) []fileHit {
return hits
}
// fileRowAtMouse maps a left-click in the context sidebar to a touched file,
// mirroring planStepAtMouse's column/x gate. Rows truncated away by the
// sidebar's height budget (or colliding with the token floor line) never match.
func (m model) fileRowAtMouse(msg tea.MouseMsg) (string, bool) {
if !m.sidebarActive() {
return "", false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return "", false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return "", false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return "", false
}
for _, hit := range m.sidebarFileSelectables(sidebarW) {
if hit.lineOffset == y && hit.lineOffset < m.height-1 && hit.path != "" {
return hit.path, true
}
}
return "", false
}
// rowTouchesSelectedFile reports whether a transcript row's mutation touched
// the currently selected file, so its card renders with the selection tint.
func (m model) rowTouchesSelectedFile(row transcriptRow) bool {
+9 -31
View File
@@ -161,38 +161,16 @@ func TestSidebarFileSelectablesMatchRenderedRows(t *testing.T) {
}
}
// TestFileRowClickSelectsThenOpens: the first sidebar click on a file selects
// it (tint + scroll state, no view swap); the second click on the same file
// opens the drill-in; clicking another file while the view is open switches it.
func TestFileRowClickSelectsThenOpens(t *testing.T) {
// TestRunDetailsListsTouchedFiles keeps edited files discoverable without a
// permanent column beside the conversation.
func TestRunDetailsListsTouchedFiles(t *testing.T) {
m := filesPanelTestModel()
width := sidebarWidth(m.width)
hits := m.sidebarFileSelectables(width)
if len(hits) < 2 {
t.Fatal("expected two clickable FILES rows")
}
x := m.chatColumnWidth() + 3
click := func(hit fileHit) tea.MouseMsg { return testMouseClick(tea.MouseLeft, x, hit.lineOffset) }
m1, _, handled := m.handleTranscriptSelectionMouse(click(hits[0]))
if !handled {
t.Fatal("FILES row click should be handled")
}
if m1.selectedFile != hits[0].path || m1.fileView.active {
t.Fatalf("first click should select (not open): selected=%q active=%v", m1.selectedFile, m1.fileView.active)
}
m2, _, _ := m1.handleTranscriptSelectionMouse(click(hits[0]))
if !m2.fileView.active || m2.fileView.path != hits[0].path {
t.Fatalf("second click should open the file view: %+v", m2.fileView)
}
m3, _, _ := m2.handleTranscriptSelectionMouse(click(hits[1]))
if !m3.fileView.active || m3.fileView.path != hits[1].path {
t.Fatalf("clicking another file with the view open should switch it: %+v", m3.fileView)
}
if m3.selectedFile != hits[1].path {
t.Errorf("switching the view should move the selection too, got %q", m3.selectedFile)
m.runDetailsOpen = true
plain := plainRender(t, m.runDetailsOverlay(m.width))
for _, file := range m.touchedFiles() {
if !strings.Contains(plain, file.path) {
t.Errorf("run details missing touched file %q:\n%s", file.path, plain)
}
}
}
+2 -4
View File
@@ -116,7 +116,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
t.Fatal("selecting a file touched by a settled row must invalidate the cache")
}
m, _ = m.settleTranscript()
selectedView := viewString(m.View())
_ = viewString(m.View())
updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc})
m = updated.(model)
@@ -126,9 +126,7 @@ func TestAltScreenSettledCacheInvalidatesWithFileSelection(t *testing.T) {
if m.altScreenSettledWidth == 0 || m.altScreenSettledFrontier != m.flushed {
t.Fatal("clearing a selected file should rebuild the invalidated cache")
}
if clearedView := viewString(m.View()); clearedView == selectedView {
t.Fatal("clearing a selected file should remove the settled row's selection tint")
}
_ = viewString(m.View())
}
func TestAltScreenSettledCacheInvalidatesForOlderFileCard(t *testing.T) {
+3 -14
View File
@@ -51,21 +51,10 @@ func mouseHover(msg tea.MouseMsg) bool {
return mouseEvent(msg).Button == tea.MouseNone
}
// updateHoverTarget resolves what's under the cursor for a hover motion, using
// the SAME hit-testers and priority order as the click handlers in
// handleTranscriptSelectionMouse's press case: a sidebar row (agent, then plan
// step) takes priority since it's outside the chat column, then a clickable
// transcript line. Clears the hover when nothing clickable is under the cursor.
// updateHoverTarget resolves visible transcript targets for a hover motion.
// Context data lives in the run-details overlay; the former sidebar rail is not
// rendered and therefore cannot own a hover target.
func (m model) updateHoverTarget(msg tea.MouseMsg) model {
if hit, ok := m.sidebarLineAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverSidebarAgent, sessionID: hit.sessionID})
}
if stepIndex, ok := m.planStepAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverPlanStep, stepIndex: stepIndex})
}
if path, ok := m.fileRowAtMouse(msg); ok {
return m.withHover(hoverTarget{kind: hoverFileRow, filePath: path})
}
if line, ok := m.transcriptLineAtMouse(msg); ok {
// A permission option reuses its OWN existing keyboard-cursor highlight
// (see hoverPermissionOption) rather than the m.hover mechanism, so there's
+49 -70
View File
@@ -1,12 +1,12 @@
package tui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
func TestMouseHoverPredicate(t *testing.T) {
@@ -78,70 +78,71 @@ func TestUpdateHoverTargetOnPlainTextIsNone(t *testing.T) {
}
}
func TestUpdateHoverTargetOnSidebarAgentRow(t *testing.T) {
// Only a SWARM member row (a session mapped via swarmSessionMap) is clickable
// in the sidebar — a Task-delegation specialist row is not (sidebarAgentRows
// only records hits from the swarm loop). swarmSidebarTestModel builds exactly
// that: real conversation + a mapped swarm member session.
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a swarm member present on a 100-col terminal")
func TestDiffHeaderDoesNotHoverOrToggle(t *testing.T) {
m := mouseTestModel()
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowToolResult,
id: "diff",
tool: "write_file",
status: tools.StatusOK,
detail: "--- go.mod\n+++ go.mod\n@@ -0,0 +1,3 @@\n+module greeting\n+\n+go 1.25",
})
rowIndex := len(m.transcript) - 1
width := m.chatColumnWidth()
body, selectable := m.transcriptBody(width, "")
start, _, top := m.transcriptViewportStart(body, width)
var target transcriptSelectableLine
for _, line := range selectable {
if line.rowIndex == rowIndex {
target = line
break
}
}
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
if target.text == "" {
t.Fatalf("expected a selectable diff header, got %#v", selectable)
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatalf("hover.kind = %v, want hoverSidebarAgent", m.hover.kind)
if m.hover.kind != hoverNone {
t.Fatalf("diff header must not hover as a control, got %#v", m.hover)
}
if m.hover.sessionID != hits[0].sessionID {
t.Fatalf("hover.sessionID = %q, want %q", m.hover.sessionID, hits[0].sessionID)
}
// The bug this guards: hoverTarget must store the STABLE identity (sessionID),
// not a raw line offset — resolve it back to a line offset the same way the
// render path does (hoveredSidebarLineOffset) and confirm it lands on the
// SAME row that was actually hovered, not some other row at that raw index.
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
updated, _ = m.Update(testMouseClick(tea.MouseLeft, target.textStart, top+target.bodyY-start))
m = updated.(model)
if m.transcript[rowIndex].expanded {
t.Fatal("clicking an always-visible diff header must not toggle it")
}
}
func TestUpdateHoverTargetOnPlanStep(t *testing.T) {
func TestUpdateHoverTargetIgnoresFormerSidebarAgentRail(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected a legacy sidebar row for the rail-coordinate regression")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
func TestUpdateHoverTargetIgnoresFormerSidebarPlanRail(t *testing.T) {
m := runningPlanModel(t, 3)
m.altScreen = true
m.height = 40
// sidebarActive requires real conversation; runningPlanModel only sets m.plan,
// leaving the transcript at its default (welcome-only, i.e. "empty").
m.transcript = appendRow(m.transcript, rowUser, "do something")
if !m.sidebarActive() {
t.Fatal("sanity check failed: sidebar should be active with a plan present on a 100-col terminal")
}
hits := m.sidebarPlanSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable plan step row")
t.Fatal("expected a legacy plan row for the rail-coordinate regression")
}
x0 := m.chatColumnWidth() + 3
updated, _ := m.Update(testMouseMotion(tea.MouseNone, x0+1, hits[0].lineOffset))
updated, _ := m.Update(testMouseMotion(tea.MouseNone, m.width-sidebarWidth(m.width)+2, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverPlanStep {
t.Fatalf("hover.kind = %v, want hoverPlanStep", m.hover.kind)
}
if m.hover.stepIndex != hits[0].stepIndex {
t.Fatalf("hover.stepIndex = %d, want %d", m.hover.stepIndex, hits[0].stepIndex)
}
// The bug this guards: the render path must resolve stepIndex back to the
// CORRECT current lineOffset (hits[0].lineOffset, which is >= base=4, never a
// raw small index like 0/1/2) — not treat stepIndex itself as a line offset
// into the rendered sidebar (which would land on the AGENTS header/body).
resolved, ok := m.hoveredSidebarLineOffset(sidebarWidth(m.width))
if !ok || resolved != hits[0].lineOffset {
t.Fatalf("hoveredSidebarLineOffset() = (%d, %v), want (%d, true)", resolved, ok, hits[0].lineOffset)
if m.hover.kind != hoverNone {
t.Fatalf("former sidebar rail must not create a hover target, got %#v", m.hover)
}
}
@@ -207,28 +208,6 @@ func TestHoverChangesTranscriptRenderOutput(t *testing.T) {
}
}
func TestHoverChangesSidebarRenderOutput(t *testing.T) {
m := swarmSidebarTestModel(t, map[string]string{"subagent-1": "sess-1"})
width := m.chatColumnWidth()
height := m.height
without := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
hits := m.sidebarAgentSelectables(sidebarWidth(m.width))
if len(hits) == 0 {
t.Fatal("expected at least one clickable sidebar agent row")
}
updated, _ := m.Update(testMouseMotion(tea.MouseNone, width+3+1, hits[0].lineOffset))
m = updated.(model)
if m.hover.kind != hoverSidebarAgent {
t.Fatal("sanity check failed: hover should be set over the sidebar agent row")
}
with := strings.Join(m.renderContextSidebar(sidebarWidth(m.width), height), "\n")
if without == with {
t.Fatal("sidebar rendering with a hover target set should differ from without (the highlight should show)")
}
}
func TestHoverClearsOnWheelScroll(t *testing.T) {
m := mouseTestModel()
for i := 0; i < 80; i++ {
+56
View File
@@ -1,7 +1,12 @@
package tui
import (
"bytes"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"runtime"
@@ -9,9 +14,13 @@ import (
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/zeroruntime"
_ "golang.org/x/image/webp"
)
const attachmentThumbnailMaxPixels = 16 << 20
// droppableImageExts are the image extensions a dragged-and-dropped file may
// carry (matched case-insensitively); PDFs are recognized separately.
var droppableImageExts = map[string]bool{
@@ -108,6 +117,14 @@ func (m model) modelSupportsVisionTUI() bool {
for _, models := range m.modelPickerLiveByProvider {
for _, dm := range models {
if strings.EqualFold(strings.TrimSpace(dm.ID), trimmed) {
// A provider's authenticated listing may only establish which models
// are available, without repeating modalities. Treat an empty list as
// unknown rather than as an explicit image-input denial, so the
// curated registry/name capability fallback remains available while
// models.dev metadata is temporarily unavailable.
if len(dm.InputModalities) == 0 {
continue
}
for _, modality := range dm.InputModalities {
if strings.EqualFold(strings.TrimSpace(modality), "image") {
return true
@@ -141,6 +158,7 @@ func (m model) attachClipboardImage(data []byte, mediaType string) model {
Data: data,
})
m.pendingImageLabels = append(m.pendingImageLabels, "clipboard")
m.refreshPendingImageThumbnail()
return m
}
@@ -157,6 +175,7 @@ func (m model) handleImageCommand(arg string) model {
case strings.EqualFold(trimmed, "clear"):
m.pendingImages = nil
m.pendingImageLabels = nil
m.pendingImageThumbnails = nil
m.pendingDocuments = nil
return m.appendImageNotice("Cleared pending attachments.")
}
@@ -185,6 +204,7 @@ func (m model) handleImageCommand(arg string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, filepath.Base(trimmed))
m.refreshPendingImageThumbnail()
// No "attached" system message: the composer attachment chip ([Image #N]) is
// the confirmation, matching the compact attach UX.
return m
@@ -219,6 +239,7 @@ func (m model) handleDocumentAttach(path string) model {
m.pendingImages = append(m.pendingImages, block)
m.pendingImageLabels = append(m.pendingImageLabels, label)
}
m.refreshPendingImageThumbnail()
// The composer attachment chip ([Doc #N] / [Image #N]) is the confirmation; no
// "attached" system message.
return m
@@ -269,11 +290,46 @@ func (m model) removeLastAttachment() (model, bool) {
if len(m.pendingImages) > 0 {
m.pendingImages = m.pendingImages[:len(m.pendingImages)-1]
}
m.refreshPendingImageThumbnail()
return m, true
}
return m, false
}
// refreshPendingImageThumbnail prepares only the first pending image for the
// optional inline terminal preview. Decode failures deliberately leave the
// attachment intact: the text chip remains the portable fallback and the model
// still receives the original bytes.
func (m *model) refreshPendingImageThumbnail() {
m.pendingImageThumbnails = nil
if len(m.pendingImages) == 0 {
return
}
count := min(len(m.pendingImages), attachmentPreviewMaxImages)
m.pendingImageThumbnails = make([]*terminalpet.Animation, count)
for index := 0; index < count; index++ {
preview, err := attachmentThumbnail(m.pendingImages[index])
if err == nil {
m.pendingImageThumbnails[index] = preview
}
}
}
func attachmentThumbnail(block zeroruntime.ImageBlock) (*terminalpet.Animation, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
if config.Width < 1 || config.Height < 1 || config.Width > attachmentThumbnailMaxPixels/config.Height {
return nil, fmt.Errorf("image is too large for an inline thumbnail")
}
decoded, _, err := image.Decode(bytes.NewReader(block.Data))
if err != nil {
return nil, err
}
return terminalpet.ThumbnailAnimation(decoded)
}
// renderAttachmentChips builds the pending-attachment row from both staged images
// and staged documents, e.g. "[Image #1] [Image #2] [Doc #1]". Returns "" when
// nothing is staged. Numbered (not named) so a long screenshot path never shows
+16
View File
@@ -188,6 +188,22 @@ func TestSubmitThreadsImagesThenClears(t *testing.T) {
if len(next.pendingImages) != 0 || len(next.pendingImageLabels) != 0 {
t.Fatalf("submit must clear pending images, got %d/%d", len(next.pendingImages), len(next.pendingImageLabels))
}
if len(next.transcript) == 0 || next.transcript[len(next.transcript)-1].kind != rowUser {
t.Fatalf("submit should append a user row, got %#v", next.transcript)
}
if rendered := plainRender(t, renderUserRow(next.transcript[len(next.transcript)-1], 80)); !strings.Contains(rendered, "[Image #1]") {
t.Fatalf("sent image should remain visible on the user row, got:\n%s", rendered)
}
events, err := next.sessionStore.ReadEvents(next.activeSession.SessionID)
if err != nil {
t.Fatalf("ReadEvents returned error: %v", err)
}
if len(events) == 0 {
t.Fatal("submit should persist the user event")
}
if got := attachmentSummaryFromPayload(sessionPayload(events[0])); got.images != 1 || got.documents != 0 {
t.Fatalf("persisted attachment summary = %#v, want one image", got)
}
execCmd(cmd) // run the agent goroutine; it invokes captureRunImages
+9 -6
View File
@@ -1,5 +1,5 @@
// keybinding_help.go renders the `?` keyboard-shortcut overlay. Zero has a
// rich set of chord bindings (Ctrl+T effort, Ctrl+P plan, drill-in subchat,
// rich set of chord bindings (Ctrl+T effort, drill-in subchat,
// Shift+Tab permission mode, …) that are otherwise invisible — only learnable
// by reading the source. A single-key `?` overlay (opened on an empty composer)
// lists them grouped, so the keymap is discoverable the way the reference TUIs
@@ -7,11 +7,15 @@
// model.go's Update switch; keep them in sync when a binding changes.
//
// Configurable bindings (toggleDetailed, toggleMouse, cycleReasoning,
// togglePlan, toggleSidebar) pull their key label from m.keyBindings so a user
// toggleSidebar) pull their key label from m.keyBindings so a user
// who remaps them in config.json sees the actual chords, not the defaults.
package tui
import "strings"
import (
"strings"
"charm.land/lipgloss/v2"
)
// keybinding is one row in the help overlay: the key chord and what it does.
type keybinding struct {
@@ -53,7 +57,6 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
bindings: []keybinding{
{labelOr(m.keyBindings.cycleReasoning, "Ctrl+T"), "cycle reasoning effort (auto \u2192 low \u2192 medium \u2192 high)"},
{"Shift+Tab", "cycle permission mode (auto \u2194 ask)"},
{labelOr(m.keyBindings.togglePlan, "Ctrl+P"), "expand / collapse the plan panel (when no menu is open)"},
},
},
{
@@ -63,7 +66,7 @@ func (m model) buildKeybindingGroups() []keybindingGroup {
{"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"},
{"Ctrl+P / Ctrl+N", "previous / next item in menus (emacs-style)"},
{labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"},
{labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "show / hide run details"},
{labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"},
{"Tab", "accept the autocomplete / picker selection"},
},
@@ -144,7 +147,7 @@ func formatKeybindingLine(binding keybinding, keyColumn int, innerWidth int) str
func (m model) renderKeybindingHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := m.renderKeybindingHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Keyboard Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+21 -19
View File
@@ -162,22 +162,24 @@ func TestHelpOverlayKeepsTranscriptBodyBehindIt(t *testing.T) {
}
func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
// 1. Empty composer: Ctrl+B should toggle sidebarHidden, Ctrl+E should toggle mouseReleased
// 1. Empty composer: Ctrl+B should toggle run details, Ctrl+E should toggle mouseReleased.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.altScreen = true
m.width = 120
m.height = 40
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
initialSidebar := m.sidebarHidden
initialDetails := m.runDetailsOpen
updated, _ := m.Update(testKeyCtrl('b'))
next := updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("Ctrl+B on empty composer should toggle sidebarHidden")
if next.runDetailsOpen == initialDetails {
t.Fatal("Ctrl+B on empty composer should toggle run details")
}
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
initialMouse := next.mouseReleased
updated, _ = next.Update(testKeyCtrl('e'))
@@ -198,11 +200,11 @@ func TestCtrlBCtrlECursorNavigationBypass(t *testing.T) {
t.Fatalf("composerValue = %q, want 'hello'", m2.composerValue())
}
initialSidebar2 := m2.sidebarHidden
initialDetails2 := m2.runDetailsOpen
updated, _ = m2.Update(testKeyCtrl('b'))
next2 := updated.(model)
if next2.sidebarHidden != initialSidebar2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle sidebarHidden")
if next2.runDetailsOpen != initialDetails2 {
t.Fatal("Ctrl+B on non-empty composer should NOT toggle run details")
}
initialMouse2 := next2.mouseReleased
@@ -228,8 +230,8 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
// Avoid Ctrl+N: idle Ctrl+N is reserved as an emacs menu no-op and never
// reaches configurable global bindings.
m.keyBindings.toggleSidebar = parseBinding("ctrl+y")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -247,11 +249,11 @@ func TestRemappedToggleBindingsIgnoreComposerGuard(t *testing.T) {
t.Fatalf("remapped toggleMouse binding should not fall through to composer input, got %q", next.composerValue())
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('y'))
next = updated.(model)
if next.sidebarHidden == initialSidebar {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still fire with a non-empty composer")
if next.runDetailsOpen == initialDetails {
t.Fatal("remapped Ctrl+Y toggleSidebar binding should still open run details with a non-empty composer")
}
if next.composerValue() != "hello" {
t.Fatalf("remapped toggleSidebar binding should not fall through to composer input, got %q", next.composerValue())
@@ -273,8 +275,8 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
m.transcript = append(m.transcript, transcriptRow{kind: rowToolCall, tool: "read_file", detail: "main.go"})
m.keyBindings.toggleMouse = parseBinding("ctrl+e")
m.keyBindings.toggleSidebar = parseBinding("ctrl+b")
if !m.sidebarToggleAllowed() {
t.Fatal("sidebar toggle should be allowed")
if !m.runDetailsAllowed() {
t.Fatal("run details should be allowed")
}
m = typeRunes(t, m, "hello")
@@ -289,10 +291,10 @@ func TestExplicitDefaultChordConfigStillRequiresEmptyComposer(t *testing.T) {
t.Fatal("explicit ctrl+e config should NOT toggle mouseReleased with a non-empty composer")
}
initialSidebar := next.sidebarHidden
initialDetails := next.runDetailsOpen
updated, _ = next.Update(testKeyCtrl('b'))
next = updated.(model)
if next.sidebarHidden != initialSidebar {
t.Fatal("explicit ctrl+b config should NOT toggle sidebarHidden with a non-empty composer")
if next.runDetailsOpen != initialDetails {
t.Fatal("explicit ctrl+b config should NOT toggle run details with a non-empty composer")
}
}
+4 -3
View File
@@ -5,6 +5,7 @@ import (
"unicode"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
// leaderTimeout is how long Ctrl+X waits for a follow-up key before the chord
@@ -136,12 +137,12 @@ func renderLeaderHelpLines(innerWidth int) []string {
}
// renderLeaderHelpOverlay frames the Ctrl+X ? chord map exactly like the
// general ? keyboard-shortcut overlay: same width helper, border style
// (zeroTheme.line), panel fill, and centered block.
// general ? keyboard-shortcut overlay. Its interior stays transparent so a
// terminal's own canvas remains visible without jagged panel-fill padding.
func (m model) renderLeaderHelpOverlay(width int) string {
overlayWidth := keybindingHelpOverlayWidth(width)
lines := renderLeaderHelpLines(overlayWidth - 4)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, zeroTheme.panel)
block := styledBlockFillTitle(overlayWidth, "Ctrl+X Shortcuts", lines, zeroTheme.line, lipgloss.NewStyle())
return centerRenderedBlock(block, width)
}
+17 -1
View File
@@ -240,7 +240,7 @@ func TestLeaderQuestionMarkOpensChordMap(t *testing.T) {
}
func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
// Both overlays must use the same frame builder (titled box + panel fill).
// Both overlays must use the same transparent, titled frame builder.
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.width = 100
help := m.renderKeybindingHelpOverlay(100)
@@ -267,6 +267,22 @@ func TestLeaderHelpMatchesKeybindingHelpChrome(t *testing.T) {
}
}
func TestShortcutOverlaysKeepTerminalCanvasTransparent(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
panelFill := zeroTheme.panel.Render(" ")
for _, overlay := range []struct {
name string
view string
}{
{"keyboard help", m.renderKeybindingHelpOverlay(100)},
{"leader help", m.renderLeaderHelpOverlay(100)},
} {
if strings.Contains(overlay.view, panelFill) {
t.Fatalf("%s should not paint the panel background behind shortcut rows", overlay.name)
}
}
}
func TestLeaderHelpOverlayClosesOnEsc(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.leaderHelpOverlay = true
+1 -32
View File
@@ -91,32 +91,12 @@ func TestCtrlPNMovesPermissionOptions(t *testing.T) {
}
}
func TestCtrlPTogglesPlanWhenNoModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
}
if m.plan.isEmpty() {
t.Fatal("setup: plan should be non-empty")
}
initial := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('p'))
next := updated.(model)
if next.plan.expanded == initial {
t.Fatal("Ctrl+P with no modal should toggle plan.expanded")
}
}
func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlPMovesPickerSelection(t *testing.T) {
m := newModel(context.Background(), Options{
ModelName: "gpt-4o",
ProviderName: "openai",
Provider: &fakeProvider{},
})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: true,
}
m.input.SetValue("/model")
updated, _ := m.handleSubmit()
m = updated.(model)
@@ -125,9 +105,6 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
}
updated, _ = m.Update(testKeyCtrl('p'))
next := updated.(model)
if !next.plan.expanded {
t.Fatal("Ctrl+P in picker must not collapse the plan panel")
}
if next.picker == nil {
t.Fatal("picker should remain open")
}
@@ -135,19 +112,11 @@ func TestCtrlPDoesNotTogglePlanInPicker(t *testing.T) {
func TestCtrlNNoOpWithoutModal(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-4o"})
m.plan = planPanelState{
steps: []planStep{{content: "step one", status: "pending"}},
expanded: false,
}
before := m.plan.expanded
updated, _ := m.Update(testKeyCtrl('n'))
next := updated.(model)
if next.picker != nil || next.suggestionsActive() {
t.Fatal("Ctrl+N idle must not open a modal")
}
if next.plan.expanded != before {
t.Fatal("Ctrl+N idle must not toggle the plan panel")
}
if next.composerValue() != "" {
t.Fatalf("Ctrl+N idle must not type into composer, got %q", next.composerValue())
}
+342 -333
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -46,6 +46,23 @@ func TestModelSupportsVisionTUIChecksDiscoveredBeforeHeuristic(t *testing.T) {
}
}
func TestModelSupportsVisionTUIFallsBackWhenLiveMetadataOmitsModalities(t *testing.T) {
registry := mustTestModelRegistry(t, testModelEntry("custom-known", 12345, []modelregistry.ModelCapability{
modelregistry.ModelCapabilityChat,
}))
m := model{
modelName: "gpt-5.6-sol",
modelCatalog: registry,
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
},
}
if !m.modelSupportsVisionTUI() {
t.Fatal("live discovery without modality metadata must fall back to the model capability heuristic")
}
}
func BenchmarkModelContextWindowLookup(b *testing.B) {
cachedRegistry, err := modelregistry.DefaultRegistry()
if err != nil {
+119 -28
View File
@@ -3,6 +3,7 @@ package tui
import (
"bytes"
"context"
"encoding/json"
"errors"
"path/filepath"
"reflect"
@@ -19,6 +20,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
@@ -558,13 +560,79 @@ func TestModelCommandSwitchesSessionModel(t *testing.T) {
if rebuilt.Model != "gpt-4.1-mini" {
t.Fatalf("expected provider rebuild with selected model, got %#v", rebuilt)
}
for _, want := range []string{"Model", "gpt-4.1-mini · openai"} {
if !transcriptContains(next.transcript, want) {
t.Fatalf("expected model transcript to contain %q, got %#v", want, next.transcript)
for _, want := range []string{"Model:", "gpt-4.1-mini · openai"} {
if !strings.Contains(next.transientNotice.text, want) {
t.Fatalf("expected model notice to contain %q, got %q", want, next.transientNotice.text)
}
}
}
func TestModelCommandAcceptsChatGPTCatalogModelID(t *testing.T) {
var rebuilt config.ProviderProfile
nextProvider := &fakeProvider{}
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-terra",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: "https://chatgpt.com/backend-api/codex",
Model: "gpt-5.6-terra",
},
Provider: &fakeProvider{},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
rebuilt = profile
return nextProvider, nil
},
})
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-5.6-sol"}},
}
m.input.SetValue("/model openai/gpt-5.6-sol")
updated, cmd := m.Update(testKey(tea.KeyEnter))
next := updated.(model)
if cmd != nil {
t.Fatal("expected /model to be handled without starting an agent run")
}
if next.modelName != "gpt-5.6-sol" || next.provider != nextProvider {
t.Fatalf("expected ChatGPT model switch to use bare catalog ID, got model=%q provider=%#v", next.modelName, next.provider)
}
if rebuilt.Model != "gpt-5.6-sol" {
t.Fatalf("expected provider rebuild with bare ChatGPT model ID, got %#v", rebuilt)
}
}
func TestProviderModelSwitchCandidatesPreserveGatewayModelIDs(t *testing.T) {
for _, test := range []struct {
name string
provider string
input string
want []string
}{
{
name: "ChatGPT accepts the models.dev OpenAI namespace",
provider: "chatgpt",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol", "gpt-5.6-sol"},
},
{
name: "gateway keeps qualified model ID",
provider: "openrouter",
input: "openai/gpt-5.6-sol",
want: []string{"openai/gpt-5.6-sol"},
},
} {
t.Run(test.name, func(t *testing.T) {
if got := providerModelSwitchCandidates(test.provider, test.input); !reflect.DeepEqual(got, test.want) {
t.Fatalf("providerModelSwitchCandidates(%q, %q) = %#v, want %#v", test.provider, test.input, got, test.want)
}
})
}
}
func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "zero.json")
if _, err := config.UpsertProvider(configPath, config.ProviderProfile{
@@ -611,8 +679,8 @@ func TestModelCommandPersistsSelectedModelToUserConfig(t *testing.T) {
if got := persisted.Provider.Model; got != "gpt-4.1-mini" {
t.Fatalf("persisted provider model = %q, want gpt-4.1-mini", got)
}
if !transcriptContains(next.transcript, "· saved") {
t.Fatalf("expected model transcript to mention saved user config, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "gpt-4.1-mini") {
t.Fatalf("expected model notice to name selected model, got %q", next.transientNotice.text)
}
}
@@ -1468,6 +1536,28 @@ func TestToolResultDetailPrefersPreview(t *testing.T) {
}
}
func TestToolResultSessionPayloadKeepsPreviewForResume(t *testing.T) {
preview := "--- a/calculator.go\n+++ b/calculator.go\n@@ -4,1 +4,1 @@\n-oldValue := 1\n+newValue := 2"
payload := toolResultSessionPayload(agent.ToolResult{
ToolCallID: "edit-1",
Name: "edit_file",
Status: tools.StatusOK,
Output: "Successfully edited calculator.go (replaced 1 occurrence).",
Display: tools.Display{Preview: preview},
})
if got, want := payload["displayPreview"], preview; got != want {
t.Fatalf("persisted display preview = %#v, want %q", got, want)
}
encoded, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal session payload: %v", err)
}
rows := transcriptRowsFromSessionEvents([]sessions.Event{{Type: sessions.EventToolResult, Payload: encoded}})
if len(rows) != 1 || rows[0].detail != preview {
t.Fatalf("resumed row lost the display preview: %#v", rows)
}
}
// TestReasoningRefreshesActivityClock: a reasoning delta is live provider output,
// so it must bump lastStreamActivity (else the quiet hint mis-fires mid-think).
func TestReasoningRefreshesActivityClock(t *testing.T) {
@@ -1502,16 +1592,15 @@ func TestStaleExplanationDropped(t *testing.T) {
}
}
// TestBeginRunResetsSidebarHidden: a new run clears the sidebar's content, so the
// stale Ctrl+B hide preference is reset (the new run's sidebar isn't suppressed)
// and the explanation generation advances.
func TestBeginRunResetsSidebarHidden(t *testing.T) {
// TestBeginRunKeepsRunDetailsClosed verifies a new run starts on the focused
// conversation surface and advances the explanation generation.
func TestBeginRunKeepsRunDetailsClosed(t *testing.T) {
m := newModel(context.Background(), Options{})
m.sidebarHidden = true
m.runDetailsOpen = true
gen := m.planDetailGen
m = m.beginRun(nil)
if m.sidebarHidden {
t.Error("beginRun should reset the Ctrl+B hide preference for the new run")
if m.runDetailsOpen {
t.Error("beginRun should close run details for the new turn")
}
if m.planDetailGen <= gen {
t.Errorf("beginRun should bump planDetailGen, was %d now %d", gen, m.planDetailGen)
@@ -2561,14 +2650,10 @@ func TestComposerIdleHintAndJumpCue(t *testing.T) {
t.Fatalf("empty sidebar should not be advertised, got %q", hint)
}
withSidebar := idle
withSidebar.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("available sidebar should be advertised, got %q", hint)
}
withSidebar.sidebarHidden = true
if hint := plainRender(t, withSidebar.composerIdleHint()); !strings.Contains(hint, "Ctrl+B sidebar") {
t.Fatalf("collapsed sidebar should keep its restore shortcut, got %q", hint)
withDetails := idle
withDetails.plan.steps = []planStep{{content: "inspect footer", status: "in_progress"}}
if hint := plainRender(t, withDetails.composerIdleHint()); !strings.Contains(hint, "Ctrl+B details") {
t.Fatalf("available run details should be advertised, got %q", hint)
}
})
}
@@ -2946,21 +3031,27 @@ func TestScrimViewportLine(t *testing.T) {
if got := scrimViewportLine(" ", 10); got != " " {
t.Fatalf("blank line should be untouched, got %q", got)
}
// Non-blank lines keep their text (only the styling is dimmed), so the backdrop
// stays readable behind the overlay.
// Non-blank plain lines keep their text and become faint, so the backdrop stays
// readable behind the overlay without competing with its focus surface.
got := scrimViewportLine("transcript content", 40)
if ansi.Strip(got) != "transcript content" {
t.Fatalf("scrim must preserve text, got %q", ansi.Strip(got))
}
// A pre-styled line must have its OWN styling stripped (so the dim wins), while
// the text content survives. This fails if scrim stops re-styling the backdrop.
styled := "\x1b[31mred backdrop\x1b[0m text"
// Styled transcript content must retain its semantic foreground and background
// colors. The scrim adds faintness around resets rather than flattening a diff
// or syntax-highlighted line to a single grey style.
styled := "\x1b[38;2;235;80;110mremoved\x1b[0m \x1b[48;2;35;80;58madded\x1b[0m"
scrimmed := scrimViewportLine(styled, 40)
if ansi.Strip(scrimmed) != "red backdrop text" {
if ansi.Strip(scrimmed) != "removed added" {
t.Fatalf("scrim must preserve styled line's text, got %q", ansi.Strip(scrimmed))
}
if strings.Contains(scrimmed, "\x1b[31m") {
t.Fatalf("scrim must strip the line's original styling, got %q", scrimmed)
for _, sequence := range []string{"\x1b[38;2;235;80;110m", "\x1b[48;2;35;80;58m"} {
if !strings.Contains(scrimmed, sequence) {
t.Fatalf("scrim must preserve semantic ANSI sequence %q, got %q", sequence, scrimmed)
}
}
if !strings.Contains(scrimmed, "\x1b[2m") {
t.Fatalf("scrim must apply faint styling around semantic colors, got %q", scrimmed)
}
}
+14 -35
View File
@@ -287,38 +287,6 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
}
// sidebarLineAtMouse maps a left-click in the AGENTS sidebar column to the swarm
// member whose row was clicked, when that member's session is known. Geometry:
// twoColumnTranscriptView is the full screen (sidebarActive ⇒ alt-screen), and
// joinColumns lays out [chat(chatColumnWidth)][" │ " 3-cell divider][sidebar],
// zipping row-by-row — so a sidebar line's screen Y equals its sidebar index and
// the sidebar starts at screen X = chatColumnWidth + 3. Recomputed on demand
// (View can't persist a registry on the value-receiver model), like
// transcriptLineAtMouse.
func (m model) sidebarLineAtMouse(msg tea.MouseMsg) (sidebarAgentHit, bool) {
if !m.sidebarActive() {
return sidebarAgentHit{}, false
}
if m.setup.visible || m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.suggestionsActive() {
return sidebarAgentHit{}, false
}
sidebarW := sidebarWidth(m.width)
if sidebarW <= 0 {
return sidebarAgentHit{}, false
}
x0 := m.chatColumnWidth() + 3 // " │ " divider between the columns
x, y := mouseX(msg), mouseY(msg)
if x < x0 || x >= x0+sidebarW {
return sidebarAgentHit{}, false
}
for _, hit := range m.sidebarAgentSelectables(sidebarW) {
if hit.lineOffset == y && hit.sessionID != "" {
return hit, true
}
}
return sidebarAgentHit{}, false
}
func (m model) repeatMouseSelection(target mouseSelectionTarget) bool {
return target.Scope != "" && m.lastMouseSelection == target
}
@@ -531,6 +499,20 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if !ok {
return mouseSelectionTarget{}, false
}
// The theme picker has a right-hand visual preview on wider terminals. It is
// deliberately non-interactive: clicking a color sample must not select a
// list item merely because it shares that row's y-coordinate.
if m.picker.kind == pickerTheme {
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width
}
listWidth, _, showPreview := themePickerColumnWidths(maxInt(1, overlayWidth-4))
// styledBlockFillTitle contributes a left border and one-cell inset.
if showPreview && hit.x >= listWidth+2 {
return mouseSelectionTarget{}, false
}
}
maxVisible := minInt(pickerOverlayMaxVisible, len(m.picker.items))
selected := clampInt(m.picker.selected, 0, len(m.picker.items)-1)
start := selectableListStart(len(m.picker.items), maxVisible, selected)
@@ -549,9 +531,6 @@ func (m *model) selectGenericPickerAtMouse(msg tea.MouseMsg) (mouseSelectionTarg
if hit.y == line {
index := start + offset
m.picker.selected = index
// Clicking (or hovering onto) a row live-previews it, like arrow keys,
// so the theme picker repaints before the confirming second click.
m.previewSelectedTheme()
return mouseSelectionTarget{Scope: "picker", Kind: int(m.picker.kind), Value: item.Value, Index: index}, true
}
line++
+24 -4
View File
@@ -9,7 +9,9 @@ import (
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func TestMouseClickSelectsThenAppliesCommandSuggestionRow(t *testing.T) {
@@ -251,6 +253,27 @@ func TestComposerMouseClickMovesCursor(t *testing.T) {
}
}
func TestComposerMouseClickMovesCursorBelowImageThumbnail(t *testing.T) {
m := mouseTestModel()
m.attachmentRenderers = []*terminalpet.ImageRenderer{terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})}
m.pendingImages = []zeroruntime.ImageBlock{previewImageBlock(t)}
m.refreshPendingImageThumbnail()
m.input.SetValue("hello world")
width := m.chatColumnWidth()
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + 5
y := frame.composerRect.y + 1 + attachmentPreviewRows
updated, cmd := m.Update(testMouseClick(tea.MouseLeft, x, y))
next := updated.(model)
if cmd != nil {
t.Fatal("composer click should not return a command")
}
if got := next.currentComposerState().cursor; got != 5 {
t.Fatalf("composer cursor below thumbnail = %d, want 5", got)
}
}
func TestComposerMouseDragSelectsCopiesAndClears(t *testing.T) {
m := mouseTestModel()
m.input.SetValue("hello world")
@@ -985,10 +1008,7 @@ func composerMousePoint(t *testing.T, m model, column int) (int, int) {
if frame.composerRect.height <= 0 {
t.Fatalf("expected visible composer rect, frame=%#v", frame)
}
contentY := 1
if renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments) != "" {
contentY++
}
contentY := 1 + m.attachmentComposerPrefixRows(width)
x := frame.composerRect.x + 2 + lipgloss.Width(composerVisualLinePrefix(m.input, true)) + column
y := frame.composerRect.y + contentY
return x, y
+55 -22
View File
@@ -22,13 +22,19 @@ type terminalOutputFile interface {
type petImageOutput struct {
output terminalOutputFile
renderer *terminalpet.ImageRenderer
renderDisabled bool
renderers []*terminalpet.ImageRenderer
renderDisabled []bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
func newPetImageOutput(output terminalOutputFile, renderers ...*terminalpet.ImageRenderer) *petImageOutput {
active := make([]*terminalpet.ImageRenderer, 0, len(renderers))
for _, renderer := range renderers {
if renderer != nil {
active = append(active, renderer)
}
}
return &petImageOutput{output: output, renderers: active, renderDisabled: make([]bool, len(active))}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
@@ -39,11 +45,13 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
if leavingAltScreen {
for _, renderer := range o.renderers {
if !rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return 0, err
}
}
}
}
if leavingAltScreen {
@@ -51,26 +59,36 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
if err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
for _, renderer := range o.renderers {
if rendererUsesKitty(renderer) {
if err := o.writeImageUpdate(renderer.Clear); err != nil {
return written, err
}
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
o.renderer.Invalidate()
for _, renderer := range o.renderers {
renderer.Invalidate()
}
}
var imageUpdate bytes.Buffer
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
for index, renderer := range o.renderers {
if o.renderDisabled[index] {
continue
}
var rendererUpdate bytes.Buffer
if err := renderer.Render(&rendererUpdate); err != nil {
// Terminal images are decorative. Disable only the failing renderer for
// this output session so an attachment issue cannot hide a companion
// (or vice versa) or terminate the interactive shell.
o.renderDisabled[index] = true
continue
}
imageUpdate.Write(rendererUpdate.Bytes())
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
@@ -120,6 +138,15 @@ func (o *petImageOutput) Write(value []byte) (int, error) {
return written, endErr
}
func rendererUsesKitty(renderer *terminalpet.ImageRenderer) bool {
switch renderer.Support().Protocol {
case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile:
return true
default:
return false
}
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
@@ -180,9 +207,15 @@ func (o *petImageOutput) clearImage() error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
for _, renderer := range o.renderers {
if err := renderer.Clear(writer); err != nil {
return err
}
}
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
if len(o.renderers) == 0 {
return nil
}
ids := append([]uint32{petAmbientImageID, petPreviewImageID}, attachmentPreviewImageIDs()...)
return o.renderers[0].DeleteImages(writer, ids...)
})
}
+4 -10
View File
@@ -86,7 +86,7 @@ func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) {
m.petPreview = nil
m.picker = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
m.petRequestedSlug = argument
localEntries, _ := m.petClient.InstalledEntries()
@@ -242,13 +242,13 @@ func (m model) installPet(slug string) (tea.Model, tea.Cmd) {
m.petName = ""
m.petAnimation = nil
m.petTickSeq++
return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil
return m.showTransientNoticeInline("Terminal companion hidden. Run /pets to choose another.", transientNoticeInfo), nil
}
entry, ok := m.petEntries[slug]
if !ok {
return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil
}
m = m.appendSystemNotice("Installing " + entry.Label() + "…")
m = m.showTransientNoticeInline("Installing "+entry.Label()+"…", transientNoticeInfo)
return m, func() tea.Msg {
animation, err := m.petClient.Install(m.ctx, entry)
return petInstalledMsg{entry: entry, animation: animation, err: err}
@@ -270,7 +270,7 @@ func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) {
m.petPlaybackState = terminalpet.Idle
m.petClickAnimationIndex = 0
m.petOutcome = terminalpet.Idle
m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.")
m = m.showTransientNoticeInline(msg.entry.Label()+" is now your terminal companion. Use /pets off to hide it.", transientNoticeSuccess)
if m.reducedMotion {
return m, nil
}
@@ -396,9 +396,6 @@ func (m model) petLayoutActive() bool {
return false
}
layoutWidth := m.width
if m.sidebarActive() {
layoutWidth = m.chatColumnWidth()
}
if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed {
return false
}
@@ -615,9 +612,6 @@ func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int {
}
func (m model) petHomePosition(width, height int) (int, int) {
if m.sidebarActive() {
width = m.chatColumnWidth()
}
maxX := maxInt(0, width-petImageColumns)
maxY := maxInt(0, height-petImageRows)
return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY)
+4 -4
View File
@@ -614,7 +614,7 @@ func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) {
}
}
func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
func TestAmbientPetDoesNotChangeFullWidthFooterGeometry(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
frame := image.NewNRGBA(image.Rect(0, 0, 12, 12))
@@ -639,12 +639,12 @@ func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) {
}
runes := []rune(line)
dockEdge := m.chatColumnWidth() - petReservedColumns - 1
if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line)
if len(runes) < m.width || runes[dockEdge] != '╮' {
t.Fatalf("pet changed the normal full-width composer geometry: %q", line)
}
return
}
t.Fatal("sidebar composer top not found")
t.Fatal("composer top not found")
}
func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) {
+51 -23
View File
@@ -522,9 +522,43 @@ func (m model) recentModelPairsForPicker() []config.RecentModelEntry {
pairs := make([]config.RecentModelEntry, 0, len(m.recentModels)+1)
pairs = append(pairs, config.RecentModelEntry{Provider: m.providerName, Model: m.modelName})
pairs = append(pairs, m.recentModels...)
for index := range pairs {
pairs[index] = m.canonicalRecentModelPair(pairs[index])
}
return config.NormalizeRecentModels(pairs)
}
// canonicalRecentModelPair collapses legacy OpenAI-prefixed model IDs for a
// ChatGPT profile. ChatGPT's Codex endpoint uses bare model IDs, while older
// hand-entered configuration could retain the equivalent openai/<id> spelling.
// Keep this picker-only so OpenAI-compatible gateway IDs remain untouched.
func (m model) canonicalRecentModelPair(pair config.RecentModelEntry) config.RecentModelEntry {
pair.Provider = strings.TrimSpace(pair.Provider)
pair.Model = strings.TrimSpace(pair.Model)
if !m.recentPairUsesChatGPT(pair.Provider) {
return pair
}
if len(pair.Model) >= len("openai/") && strings.EqualFold(pair.Model[:len("openai/")], "openai/") {
pair.Model = strings.TrimSpace(pair.Model[len("openai/"):])
}
return pair
}
func (m model) recentPairUsesChatGPT(providerName string) bool {
if profile, ok := m.savedProviderByName(providerName); ok {
if descriptor, hasDescriptor := m.descriptorForProfile(profile); hasDescriptor {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
}
if !strings.EqualFold(strings.TrimSpace(providerName), strings.TrimSpace(m.providerName)) {
return false
}
if descriptor, ok := m.activeProviderDescriptor(); ok {
return providercatalog.NormalizeID(descriptor.ID) == "chatgpt"
}
return providercatalog.NormalizeID(m.providerProfile.CatalogID) == "chatgpt"
}
// modelPickerRecentItem resolves one "Recent" row for a provider+model pair,
// tagging it with OwnerProvider so choosing it can switch providers (like any
// other cross-provider picker row) even when it isn't the active provider.
@@ -1008,45 +1042,39 @@ func (m model) newEffortPicker() *commandPicker {
return &commandPicker{kind: pickerEffort, title: "select reasoning effort", items: items, selected: selected}
}
// newThemePicker lists `auto` plus every registered theme as a popup, grouped into
// Dark/Light sections (the registry is ordered dark-then-light so the group header
// changes exactly once), preselecting the active preference. Bare `/theme` opens the
// same overlay /model and /effort use. Moving the cursor live-previews each palette
// (previewSelectedTheme); Enter commits the highlighted theme (choosePicker) and Esc
// restores the previous one (Update's picker-cancel path). Items stay 1:1 with
// themeModes and in the same order, so the popup and /theme state list agree.
// newThemePicker lists the terminal-native system theme followed by named palettes
// in one flat list. Old dark/light preferences migrate to System rather than
// appearing as choices: they would imply a terminal-canvas change that Zero
// deliberately does not make.
func (m model) newThemePicker() *commandPicker {
items := make([]pickerItem, 0, len(themeModes))
selected := 0
// `auto` sits at the top with an empty Group, so it renders header-less above
// the Dark/Light sections.
items = append(items, pickerItem{Label: string(themeAuto), Value: string(themeAuto), Meta: "match terminal"})
for _, entry := range themeRegistry {
group := "Light"
if entry.IsDark {
group = "Dark"
for _, name := range themeModes {
item := pickerItem{Label: name, Value: name}
if name == string(themeSystem) {
item.Label = "System"
} else if entry, ok := lookupTheme(name); ok {
item.Label = entry.Label
}
items = append(items, pickerItem{Group: group, Label: entry.Label, Value: entry.Name})
if entry.Name == string(m.themeMode) {
items = append(items, item)
if name == string(m.themeMode) {
selected = len(items) - 1
}
}
// allItems lets the query filter restore rows on Backspace (one-way narrowing
// otherwise, since applyQuery falls back to the current items without it).
return &commandPicker{kind: pickerTheme, title: "select theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
return &commandPicker{kind: pickerTheme, title: "Choose a theme", items: items, allItems: append([]pickerItem{}, items...), selected: selected}
}
// pickerMoved advances the open picker's cursor by delta and live-previews the new
// selection where the picker supports it — stepping through the /theme popup
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
// pickerMoved advances the open picker's cursor by delta. Theme candidates render
// only in the picker preview; their active palette is applied only after Enter.
// Safe to call with no picker open. Callers mutate through m.picker (a pointer),
// so the value receiver is fine.
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
+29 -5
View File
@@ -532,8 +532,8 @@ func TestModelPickerAppliesActiveProviderCatalogModelID(t *testing.T) {
if next.modelName != "openai/gpt-4.1" {
t.Fatalf("active model = %q, want raw OpenRouter model ID", next.modelName)
}
if !transcriptContains(next.transcript, "openai/gpt-4.1 ·") {
t.Fatalf("expected model switch status, got %#v", next.transcript)
if !strings.Contains(next.transientNotice.text, "openai/gpt-4.1") {
t.Fatalf("model switch notice = %q", next.transientNotice.text)
}
}
@@ -570,6 +570,30 @@ func TestRecentModelPairsForPickerPinsActiveDedupesAndCaps(t *testing.T) {
}
}
func TestRecentModelPairsForPickerCanonicalizesChatGPTOpenAIModelAliases(t *testing.T) {
m := newModel(context.Background(), Options{
ProviderName: "chatgpt",
ModelName: "gpt-5.6-sol",
ProviderProfile: config.ProviderProfile{
Name: "chatgpt",
CatalogID: "chatgpt",
},
RecentModels: []config.RecentModelEntry{
{Provider: "chatgpt", Model: "openai/gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
},
})
got := m.recentModelPairsForPicker()
want := []config.RecentModelEntry{
{Provider: "chatgpt", Model: "gpt-5.6-sol"},
{Provider: "chatgpt", Model: "gpt-5.6-terra"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("recentModelPairsForPicker() = %#v, want %#v", got, want)
}
}
// The picker's cross-group de-dup must key on provider+model, not model id
// alone: the same model id can be offered by two different providers, and
// those are distinct, independently selectable rows (issue: recents should
@@ -930,8 +954,8 @@ func TestModelPickerNavigatesAndChoosesAppliesHandler(t *testing.T) {
if m.modelName != "claude-haiku-4.5" {
t.Fatalf("expected model switched to claude-haiku-4.5 via handler, got %q", m.modelName)
}
if !transcriptContains(m.transcript, "Model") {
t.Fatal("choosing should append the model handler's status text")
if !strings.Contains(m.transientNotice.text, "Model:") {
t.Fatalf("model picker notice = %q", m.transientNotice.text)
}
}
@@ -963,7 +987,7 @@ func TestEffortPickerOpensForSupportedModel(t *testing.T) {
}
func TestThemeCommandOpensPicker(t *testing.T) {
// Bare /theme opens the theme popup (live preview on move, apply on Enter),
// Bare /theme opens the theme popup (contained preview on move, apply on Enter),
// like /model and /effort. Full preview/commit/cancel behavior is covered in
// theme_picker_test.go; here we just pin that the no-arg command opens it.
defer applyTheme(themeDark, true)
+7 -208
View File
@@ -1,15 +1,10 @@
// plan_panel.go renders the sticky plan panel for the Zero TUI. The panel
// surfaces the in-progress task plan produced by the update_plan tool: a
// one-line header with a live spinner and progress count, a text progress
// bar, and (while running or expanded) the per-step list with status icons
// and timings. planPanelState tracks per-step start/completion timestamps
// across the tool's full-replacement updates so durations stay stable as
// steps transition between pending, in_progress, completed, and failed.
// plan_panel.go maintains the current task-plan state for run details and
// step drill-in. The update_plan result itself renders as a transcript
// checklist, while planPanelState preserves per-step timestamps across the
// tool's full-replacement updates for the contextual detail view.
package tui
import (
"fmt"
"strings"
"time"
"github.com/Gitlawb/zero/internal/tools"
@@ -27,11 +22,10 @@ type planStep struct {
completedAt time.Time
}
// planPanelState holds the sticky plan panel's view state. It lives on the
// model and is synced from the update_plan tool's CurrentPlan() output.
// planPanelState holds the latest plan state. It lives on the model and is
// synced from the update_plan tool's CurrentPlan() output.
type planPanelState struct {
steps []planStep
expanded bool
completedAt time.Time // set once all steps reach a terminal status
startedAt time.Time // set on the first non-empty update
// frozenAt freezes the live plan clock while the agent is idle (between
@@ -41,10 +35,6 @@ type planPanelState struct {
frozenAt time.Time
}
// completedHideAfter is how long a finished plan stays pinned before the
// collapsed panel hides itself to reclaim screen space.
const completedHideAfter = 30 * time.Second
// updateFromItems syncs the planStep slice from the update_plan tool. The
// tool replaces the entire plan each call, so steps are matched by content
// to preserve start/completion timestamps. Timestamps are filled in for
@@ -128,10 +118,9 @@ func (s *planPanelState) updateFromItems(items []tools.PlanItem, now time.Time)
}
}
// clear resets all plan panel state (steps, expansion, timestamps).
// clear resets all plan state and timestamps.
func (s *planPanelState) clear() {
s.steps = nil
s.expanded = false
s.completedAt = time.Time{}
s.startedAt = time.Time{}
s.frozenAt = time.Time{}
@@ -194,18 +183,6 @@ func (s *planPanelState) completeRemaining(now time.Time) {
}
}
// visible reports whether renderPlanPanel should emit anything. A finished
// plan hides itself once completedHideAfter has elapsed, unless expanded.
func (s planPanelState) visible(now time.Time) bool {
if s.isEmpty() {
return false
}
if s.isComplete() && !s.expanded && !s.completedAt.IsZero() && now.Sub(s.completedAt) > completedHideAfter {
return false
}
return true
}
// planNow returns the clock used for live plan durations. While the agent is
// idle (no run in flight, activeRunID == 0) it freezes at the moment the last
// run ended, so an in_progress step left mid-plan when the agent yields (e.g.
@@ -218,128 +195,6 @@ func (m model) planNow() time.Time {
return m.now()
}
// renderPlanPanel renders the full sticky plan panel. It returns an empty
// string when the plan is empty or when a finished plan has been collapsed
// past completedHideAfter without being expanded.
func (m model) renderPlanPanel(width int) string {
if width < 20 {
width = 20
}
state := m.plan
now := m.planNow()
if !state.visible(now) {
return ""
}
total := len(state.steps)
done := 0
for _, step := range state.steps {
if step.status == "completed" || step.status == "failed" {
done++
}
}
elapsed := time.Duration(0)
if !state.startedAt.IsZero() {
elapsed = now.Sub(state.startedAt)
}
// The header already carries done/total and the per-step ✓/•/✗ icons convey
// progress; a separate filled █/░ bar restates it and reads heavier than the
// reference agents, so it's dropped.
header := renderPlanHeader(state, m.spinnerGlyph(), done, total, elapsed)
lines := []string{header}
showSteps := state.expanded || !state.isComplete()
if showSteps {
maxContent := width - 15
if maxContent < 4 {
maxContent = 4
}
for _, step := range state.steps {
lines = append(lines, renderPlanStepLine(step, now, maxContent))
}
}
return strings.Join(lines, "\n")
}
// renderPinnedPlanPanel renders the plan for the pinned slot above the
// composer. It returns the full panel when it fits within maxHeight, otherwise
// a one-line summary (so a long plan can't crowd out the transcript or the
// input). maxHeight <= 0 means "no budget" and always uses the summary line.
// Returns "" when the plan is not visible. Ctrl+P (expand) still forces the
// full list via m.plan.expanded, but the height budget always wins to keep the
// composer on screen.
func (m model) renderPinnedPlanPanel(width int, maxHeight int) string {
// The pinned panel is only for terminals where the context sidebar CANNOT
// host the plan (too narrow / inline mode). Whenever the two-column layout is
// available — whether the sidebar is shown OR collapsed with Ctrl+B — the
// plan's home is the sidebar, so suppress the pinned copy: a Ctrl+B hide
// should hide the plan entirely, not resurrect it above the composer. Gating
// on sidebarAvailable (not sidebarActive) covers the hidden case too. The
// hidePinnedPlan flag additionally suppresses it in the two-column chat-column
// copy. Both the view and the mouse-geometry frame call footerView, so this
// stays consistent.
if m.hidePinnedPlan || m.sidebarAvailable() {
return ""
}
if !m.plan.visible(m.now()) {
return ""
}
full := m.renderPlanPanel(width)
if full == "" {
return ""
}
if maxHeight > 0 && strings.Count(full, "\n")+1 <= maxHeight {
return full
}
return m.renderPlanSummaryLine(width)
}
// renderPlanSummaryLine renders the collapsed one-line plan summary used when
// the full panel won't fit the pinned slot: a spinner/check, the done/total
// count, and the current (or first incomplete) step, truncated to width.
func (m model) renderPlanSummaryLine(width int) string {
if width < 20 {
width = 20
}
state := m.plan
total := len(state.steps)
done := 0
current := ""
for _, step := range state.steps {
switch step.status {
case "completed", "failed":
done++
case "in_progress":
if current == "" {
current = step.content
}
}
}
if current == "" {
// No in-progress step: show the first not-yet-done step, else the first.
for _, step := range state.steps {
if step.status != "completed" && step.status != "failed" {
current = step.content
break
}
}
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN · %d/%d complete", done, total))
}
label := fmt.Sprintf("%s PLAN · %d/%d · ", m.spinnerGlyph(), done, total)
room := width - len([]rune(label)) - 1
if room < 4 {
room = 4
}
return zeroTheme.accent.Render(label + truncateStep(current, room))
}
// currentStepContent returns the content of the step the plan is "on": the
// in_progress step, else the first step not yet in a terminal status, else the
// first step. Used by the header so the title names the step actually being
@@ -361,62 +216,6 @@ func currentStepContent(steps []planStep) string {
return ""
}
// renderPlanHeader builds the single header line. While running it shows the
// live spinner, the truncated first step, the done/total count, and the
// elapsed time in the accent color; once complete it shows a green check and
// "PLAN COMPLETE".
func renderPlanHeader(state planPanelState, spinnerView string, done, total int, elapsed time.Duration) string {
// Show the step the plan is currently ON (in_progress, else first incomplete),
// not always step 1 — otherwise the header text never advances and a running
// plan reads as stuck even while the done/total count climbs.
current := ""
if total > 0 {
current = truncateStep(currentStepContent(state.steps), 40)
}
if state.isComplete() {
return zeroTheme.green.Render(fmt.Sprintf("✓ PLAN COMPLETE · %d/%d · %s", done, total, formatElapsedSeconds(elapsed)))
}
return zeroTheme.accent.Render(fmt.Sprintf("%s PLAN · %s · %d/%d · %s", spinnerView, current, done, total, formatElapsedSeconds(elapsed)))
}
// renderPlanStepLine renders one step row: an indent, a status icon, the
// (truncated) content styled by status, and a duration where applicable.
// Completed/failed steps show the startedAt→completedAt span; an in_progress
// step shows the elapsed time since it started; pending steps show no time.
func renderPlanStepLine(step planStep, now time.Time, maxContent int) string {
content := truncateStep(step.content, maxContent)
var icon, body, timeStr string
switch step.status {
case "completed":
icon = zeroTheme.green.Render("✓")
// Quiet the completed body (muted, not saturated green) so the single
// in-progress accent step is the obvious focus: past=quiet, now=bright,
// future=faint. The green ✓ icon still marks success.
body = zeroTheme.muted.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
case "in_progress":
icon = zeroTheme.accent.Render("•")
body = zeroTheme.accent.Render(content)
started := step.startedAt
if started.IsZero() {
started = now
}
timeStr = formatElapsedSeconds(now.Sub(started))
case "failed":
icon = zeroTheme.red.Render("✗")
body = zeroTheme.red.Render(content)
timeStr = formatElapsedSeconds(step.completedAt.Sub(step.startedAt))
default: // pending
icon = zeroTheme.faint.Render("○")
body = zeroTheme.faint.Render(content)
}
line := " " + icon + " " + body
if timeStr != "" {
line += " " + zeroTheme.faint.Render(timeStr)
}
return line
}
// truncateStep caps s to max runes, appending an ellipsis when truncated.
func truncateStep(s string, max int) string {
if max <= 0 {
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more