WIP: fix: add ACPCommand/ACPArgs fields and protocolVersion to ACP init#1
WIP: fix: add ACPCommand/ACPArgs fields and protocolVersion to ACP init#1tesdal wants to merge 94 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR extends provider configuration to support ACP-specific launch overrides (acp_command, acp_args) and updates the ACP JSON-RPC initialize handshake to include an explicit protocolVersion, with corresponding resolver, docs, and template-launch updates.
Changes:
- Add
ACPCommand/ACPArgsto provider specs and resolved provider, plus anACPCommandString()helper. - Merge/copy the new fields through provider resolution and packing, and document them in schema/reference docs.
- Include
protocolVersion: 1in ACPinitializerequest params.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/worker/builtin/profiles.go | Adds ACP command/args fields to built-in provider specs and clones ACP args. |
| internal/runtime/acp/protocol.go | Adds protocolVersion to ACP initialize params and sets it to 1. |
| internal/config/provider.go | Adds new config fields and introduces ACPCommandString(). |
| internal/config/resolve.go | Merges ACPCommand and ACPArgs through provider resolution and folding. |
| internal/config/pack.go | Deep-copies ACPArgs in provider spec packing. |
| cmd/gc/template_resolve.go | Uses ACP-specific command string when building the template command for ACP sessions. |
| internal/config/resolve_test.go | Extends merge field-sync test data to include the new fields. |
| internal/config/field_sync_test.go | Marks new ACP fields as intentionally not patchable. |
| docs/schema/city-schema.txt | Documents new acp_command / acp_args fields in schema. |
| docs/schema/city-schema.json | Documents new acp_command / acp_args fields in schema. |
| docs/reference/config.md | Documents new acp_command / acp_args provider fields. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| rp.PrintArgs = make([]string, len(spec.PrintArgs)) | ||
| copy(rp.PrintArgs, spec.PrintArgs) | ||
| } | ||
| if len(spec.ACPArgs) > 0 { |
There was a problem hiding this comment.
specToResolved only deep-copies ACPArgs when len(spec.ACPArgs) > 0, which collapses an explicitly configured empty slice (e.g., acp_args = []) back to nil. That breaks the documented semantics where nil means “fall back to Args” but an empty slice should mean “override to no args”. Copy ACPArgs when spec.ACPArgs != nil (even if empty) to preserve this distinction.
| if len(spec.ACPArgs) > 0 { | |
| if spec.ACPArgs != nil { |
There was a problem hiding this comment.
Fixed — changed to spec.ACPArgs != nil in 58a7341.
| var command string | ||
| if cfgAgent.Session == "acp" { | ||
| command = resolved.ACPCommandString() | ||
| } else { | ||
| command = resolved.CommandString() | ||
| } |
There was a problem hiding this comment.
ACP command selection is based only on cfgAgent.Session == "acp". If the city-level session provider is "acp" (cfg.Session.Provider) and the agent leaves Session empty, this will incorrectly use CommandString() instead of ACPCommandString(), and the SupportsACP validation above will also be skipped. Derive an effective isACP value using both agent override and the city session provider (p.sessionProvider) and use it consistently.
There was a problem hiding this comment.
ACP transport is agent-level only (Agent.Session = "acp"). There is no city-level ACP override — cfg.Session is the SessionConfig struct (tmux backend config), not a transport selector. An agent that leaves session empty always uses tmux, regardless of city config. No change needed.
| // ACPCommandString returns the command line for ACP transport sessions. | ||
| // Falls back to CommandString() when no ACP-specific command is set. | ||
| func (rp *ResolvedProvider) ACPCommandString() string { | ||
| cmd := rp.ACPCommand | ||
| args := rp.ACPArgs | ||
| if cmd == "" { |
There was a problem hiding this comment.
ACPCommandString() is added, but it appears to be used only in cmd/gc/template_resolve.go. Other launch paths (e.g., internal/config/launch_command.go BuildProviderLaunchCommand) still hardcode resolved.CommandString(), so ACP sessions created via the API/worker may ignore acp_command/acp_args. Consider threading “is ACP transport” into those launch-command builders so ACP overrides are applied consistently.
There was a problem hiding this comment.
Fixed — BuildProviderLaunchCommand now takes a transport parameter and uses ACPCommandString() when transport == "acp". All callers updated in 58a7341.
| // ACPCommandString returns the command line for ACP transport sessions. | ||
| // Falls back to CommandString() when no ACP-specific command is set. | ||
| func (rp *ResolvedProvider) ACPCommandString() string { | ||
| cmd := rp.ACPCommand | ||
| args := rp.ACPArgs | ||
| if cmd == "" { | ||
| cmd = rp.Command | ||
| } | ||
| if args == nil { | ||
| args = rp.Args | ||
| } | ||
| if len(args) == 0 { | ||
| return cmd | ||
| } | ||
| return cmd + " " + shellquote.Join(args) | ||
| } |
There was a problem hiding this comment.
New ACPCommandString() behavior isn’t covered by tests yet (provider_test.go currently only asserts CommandString()). Add unit tests that cover: (1) ACPCommand set + ACPArgs nil fallback to Args, (2) ACPArgs explicitly empty overriding Args to none, and (3) ACPCommand empty falling back to Command.
There was a problem hiding this comment.
Fixed — added TestACPCommandString with 6 subtests (full override, fallback, partial command-only, partial args-only, empty args, fallback-matches-CommandString) and TestBuildProviderLaunchCommandUsesACPCommand with 2 subtests in 58a7341.
| // newInitializeRequest creates an "initialize" request. | ||
| func newInitializeRequest() (JSONRPCMessage, int64) { | ||
| return newRequest("initialize", InitializeParams{ | ||
| ClientInfo: ClientInfo{Name: "gc", Version: "1.0"}, | ||
| ProtocolVersion: 1, | ||
| ClientInfo: ClientInfo{Name: "gc", Version: "1.0"}, | ||
| }) |
There was a problem hiding this comment.
ProtocolVersion is now part of the initialize params and is set in newInitializeRequest(), but protocol_test.go’s round-trip test doesn’t assert that protocolVersion is present/expected. Add an assertion to prevent regressions (and update any fake ACP server fixtures if they start validating initialize params).
There was a problem hiding this comment.
Fixed — added TestInitializeRequest_IncludesProtocolVersion that asserts both struct field (params.ProtocolVersion == 1) and raw JSON presence in 58a7341.
| if city.ACPCommand != "" { | ||
| result.ACPCommand = city.ACPCommand | ||
| } |
There was a problem hiding this comment.
Provider provenance tracking doesn’t appear to include the new acp_command/acp_args fields yet. buildProviderProvenance → recordScalarProvenance in internal/config/chain.go currently records title_model and print_args but not ACPCommand/ACPArgs, so gc config explain --provider ... (and any provenance-driven logic) won’t attribute these new fields correctly. Add both fields to recordScalarProvenance (and corresponding tests in provenance_test.go).
There was a problem hiding this comment.
Fixed — added acp_command and acp_args to recordScalarProvenance in 2d3ee8d.
1b3a90d to
58a7341
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (3)
cmd/gc/worker_handle.go:336
- resolvedWorkerRuntimeWithConfig rebuilds the command using
resolved.CommandString()and calls BuildProviderLaunchCommand withtransport == "". For sessions whose transport is ACP (e.g., agent.Session == "acp"), this can overwrite the stored ACP runtime command during SessionByID resolution and end up resuming with the wrong base command/args. Derive the effective transport for the session (from the agent template in cfg or from session bead metadata) and useACPCommandString()/transport == "acp"when appropriate.
command := strings.TrimSpace(info.Command)
if !shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) {
launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, nil, "")
command = resolved.CommandString()
if err == nil {
command = launchCommand.Command
}
}
cmd/gc/cmd_session.go:412
- resolvedSessionCommand always calls BuildProviderLaunchCommand with
transport == "", even thoughgc session newcan create ACP sessions whenfound.Session == "acp". That meansacp_command/acp_argsare ignored and the stored command may not match the actual transport. Thread the session transport into resolvedSessionCommand (or selectACPCommandString()when needed) and pass it through to BuildProviderLaunchCommand.
func resolvedSessionCommand(cityPath string, resolved *config.ResolvedProvider, optionOverrides map[string]string) (string, error) {
if resolved == nil {
return "", fmt.Errorf("resolved provider is nil")
}
launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, optionOverrides, "")
if err != nil {
return "", fmt.Errorf("resolving provider launch command: %w", err)
}
return launchCommand.Command, nil
internal/api/session_runtime.go:141
- resolvedSessionRuntimeCommand always compares against
resolved.CommandString()and builds a launch command withtransport == "". For ACP sessions, this can incorrectly decide not to preserve the stored runtime command and regenerate a tmux command, ignoringacp_command/acp_args, which can break resume. Plumb the session transport into this function (e.g., fromresolveSessionTemplateor bead metadata) and useACPCommandString()/ passtransportthrough to BuildProviderLaunchCommand when transport is "acp".
func (s *Server) resolvedSessionRuntimeCommand(resolved *config.ResolvedProvider, storedCommand string) (string, error) {
if command := strings.TrimSpace(storedCommand); shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) {
return command, nil
}
launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, nil, "")
if err != nil {
return "", fmt.Errorf("building provider launch command: %w", err)
}
return firstNonEmptyString(launchCommand.Command, resolved.CommandString(), resolved.Name), nil
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| out.OptionDefaults = deepCopyStringMap(in.OptionDefaults) | ||
| out.OptionsSchema = deepCopyProviderOptions(in.OptionsSchema) | ||
| out.PrintArgs = append([]string(nil), in.PrintArgs...) | ||
| out.ACPArgs = append([]string(nil), in.ACPArgs...) |
There was a problem hiding this comment.
deepCopyProviderSpec uses append([]string(nil), in.ACPArgs...) which collapses a non-nil empty slice (configured acp_args = []) back to nil. That breaks the documented semantics where nil means “fall back to Args” but an explicit empty slice means “override to no args”. Preserve the nil vs empty distinction by copying ACPArgs only when in.ACPArgs != nil using a make(len) + copy (or equivalent).
| out.ACPArgs = append([]string(nil), in.ACPArgs...) | |
| if in.ACPArgs != nil { | |
| out.ACPArgs = make([]string, len(in.ACPArgs)) | |
| copy(out.ACPArgs, in.ACPArgs) | |
| } |
There was a problem hiding this comment.
Fixed in 20f7edc. Changed to make+copy guarded by in.ACPArgs != nil.
| out.ACPCommand = r.ACPCommand | ||
| } | ||
| if r.ACPArgs != nil { | ||
| out.ACPArgs = append([]string(nil), r.ACPArgs...) |
There was a problem hiding this comment.
resolvedChainToSpec copies ACPArgs with append([]string(nil), r.ACPArgs...), which turns an explicitly configured empty-but-non-nil slice into nil. That loses the “empty slice overrides to no args” behavior for acp_args = []. Copy into a make([]string, len(r.ACPArgs)) buffer when r.ACPArgs != nil to preserve empty vs nil.
| out.ACPArgs = append([]string(nil), r.ACPArgs...) | |
| out.ACPArgs = make([]string, len(r.ACPArgs)) | |
| copy(out.ACPArgs, r.ACPArgs) |
There was a problem hiding this comment.
Fixed in 20f7edc. Changed to make+copy guarded by r.ACPArgs != nil.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
internal/api/session_runtime.go:141
- Resume/reconciliation logic preserves a stored runtime command only if it matches (or has a prefix of) resolved.CommandString(). For ACP sessions where ACPCommand differs from Command (the new supported use case), storedCommand will typically match resolved.ACPCommandString() instead, so this check will fail and the code will rebuild a tmux command (transport "") and overwrite the ACP command on resume. Preserve the stored command when it matches either CommandString() or ACPCommandString() (or fetch the persisted transport from session metadata and compare/build using that transport).
func (s *Server) resolvedSessionRuntimeCommand(resolved *config.ResolvedProvider, storedCommand string) (string, error) {
if command := strings.TrimSpace(storedCommand); shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) {
return command, nil
}
launchCommand, err := config.BuildProviderLaunchCommand(s.state.CityPath(), resolved, nil, "")
if err != nil {
return "", fmt.Errorf("building provider launch command: %w", err)
}
return firstNonEmptyString(launchCommand.Command, resolved.CommandString(), resolved.Name), nil
cmd/gc/worker_handle.go:335
- This resume path decides whether to keep info.Command by comparing it only against resolved.CommandString(). If the original session was ACP with ACPCommand != Command, info.Command will match resolved.ACPCommandString() and won’t be preserved, causing this code to rebuild a non-ACP launch command (transport "") and potentially break resume. Consider preserving when the stored command matches either CommandString() or ACPCommandString(), or thread/persist transport into session.Info so you can rebuild consistently.
command := strings.TrimSpace(info.Command)
if !shouldPreserveStoredRuntimeCommand(command, resolved.CommandString()) {
launchCommand, err := config.BuildProviderLaunchCommand(cityPath, resolved, nil, "")
command = resolved.CommandString()
if err == nil {
command = launchCommand.Command
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
c9436f5 to
eef02e6
Compare
…all#799) Pool-agent sessions resumed through the control-dispatcher path dropped --dangerously-skip-permissions, --settings, and other schema-default launch flags. The previous shouldPreserveStoredRuntimeCommand treated a stored command that exactly equalled the bare provider binary ("claude") as complete and preserved it, skipping BuildProviderLaunchCommand. The resulting claude --resume <uuid> ran without the unrestricted permission mode flag and wedged on interactive tool-call prompts. Rebuild when the stored command is only the bare binary while still preserving longer stored commands that already carry flags (so existing paths that persist fully-resolved command strings continue to win). The API path (internal/api/session_runtime.go) and the CLI worker-boundary path (cmd/gc/worker_handle.go) carry parallel copies of the helper — update both and add regression tests covering each. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pool-managed and ephemeral cursor-agent sessions were timing out during create because the cursor profile had no ReadyPromptPrefix or ReadyDelayMs, so the reconciler could not detect when the TUI was ready to accept piped-in prompts. Named sessions worked because their prompt is delivered as an exec arg, bypassing readiness detection. cursor-agent 2026.04+ renders its composer input with a U+2192 (right arrow) prefix after roughly 5-10s of startup (workspace-trust gate + statsig + model load). Match the pattern used by the claude/codex/ gemini/copilot profiles: ReadyPromptPrefix: "\u2192 " ReadyDelayMs: 10000 Verified on macOS arm64 with cursor-agent 2026.04.17: ephemeral cursor workers now claim beads, execute mol-do-work, write the requested files, and run gc runtime drain-ack cleanly instead of cycling stopped -> creating -> stopped against a deadline_exceeded outcome. Made-with: Cursor
A bead-ID-shaped argument that doesn't resolve in the local store would previously flow through `gc sling` unchecked — the existing `beadExistsInStore` lookup fires only when the arg *doesn't* look like a bead ID (the inline-text auto-create path). When the arg DOES look like a bead ID, sling assumed it was a reference and dispatched. That assumption is fine for humans who copy-paste IDs, but breaks down in two cases: 1. LLM-driven dispatch that emits well-formed-looking IDs it invented (hallucinated IDs, unsubstituted template placeholders). Workers receive assignments to beads that never existed. 2. Plain human typos. `sc-qq3q0` for `sc-qq3a0` gets the same silent success — dispatch proceeds to a dead reference and workers stall. ## Historical context `looksLikeBeadID` was added in 777c9ed (Phase 3a: name-based session addressing) purely as a routing heuristic: decide whether a string is a bead ID or a session-template name. It was never validation — `resolveSessionID`, its original caller, errors out when the downstream lookup fails. efa9f33 (inline sling) reused `looksLikeBeadID` to disambiguate inline text from bead IDs. The resulting condition `!looksLikeBeadID && !beadExistsInStore` only fires the store lookup for text-shaped args; bead-shaped args went unchecked. This PR closes that gap. ## What changes Add an explicit guard after the inline-create block: if the argument matches `looksLikeBeadID` shape AND does not resolve in the store AND `--force` is not set, sling errors with a clear message pointing at `--force` as the escape hatch. - Formula mode (`--formula` / `-f`) is exempt, since the argument is a formula name, not a bead ID. - `--force` bypasses the check, consistent with the flag's existing role as the escape hatch for cross-rig routing and suspended-agent sling. Semantically: "I know the bead exists in a view the local store hasn't synced yet — dispatch anyway." - Error message names the store root so operators can tell *which* store view disagrees with the caller's assumption. Updated `--force` help text to reflect the new behavior. ## Tests - `TestCmdSlingRefusesMissingBead` — bead-shaped arg with no matching bead → exit non-zero with a "not found" message that mentions `--force`. - `TestCmdSlingForceBypassesMissingBeadCheck` — same setup with `--force` → the guard message does not appear in stderr. - `TestCmdSlingAcceptsExistingBead` — seeded real bead + matching prefix → guard does not fire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-exists fix(sling): refuse dispatch against bead IDs not in the store
Fix supervisor unregister naming, transient event provider lifetimes, bead cache consistency, authoritative bead create responses, and review follow-up docs/tooling regressions.
eb9ecff to
0d2e185
Compare
…gastownhall#1203) ## Summary The session-first migration (`dd90ac0a`, Mar 8 2026) deleted the Agent Protocol primitive (`internal/agent/agent.go` and the `Agent`/`Handle` interfaces) and moved its responsibilities into `internal/session/` (lifecycle) and `internal/runtime/` (providers). Commit `be8debd8` then renamed `agent.*` events to `session.*`. The primitive list in AGENTS.md and the architecture docs in `engdocs/architecture/` continued to describe the deleted abstraction — for ~50 days. This PR aligns the docs with HEAD. **No code changes.** ## Changes - **Renamed** `engdocs/architecture/agent-protocol.md` → `session.md` (88% similarity preserved by `git mv`). Title and summary rewritten to frame as primitive #1 (Session) with a History note pointing at `dd90ac0a`. - **AGENTS.md**: - Renamed primitive #1 from "Agent Protocol" to "Session"; reference `agent.SessionNameFor` (in the `agent` helper package, not `session`) - Fixed Messaging derivation: `Session.Nudge()` (delegating to `runtime.Provider.Nudge()`) — `SendPrompt()` doesn't exist - Fixed Health Patrol derivation language - Updated progressive capability table (Level 0-1: Agent → Session) - Replaced `agent` with `worker` in the canonical-domain list; footnoted `internal/agent/` as a small helper package - Added new **"Active migrations"** subsection documenting: - Worker boundary migration (started `12a0a848`, in progress) — names the CI test `TestGCNonTestFilesStayOnWorkerBoundary` that enforces non-test `cmd/gc/` files route through `worker.Handle` - Session-first migration (completed `dd90ac0a`) - **`engdocs/architecture/`**: updated `nine-concepts.md`, `index.md`, `glossary.md`, `messaging.md`, `dispatch.md`, `controller.md`, `prompt-templates.md` — Agent Protocol references replaced with Session, broken links fixed. - **`event-bus.md`**: rewrote the event-type constants table from `Agent*`/`agent.*` to `Session*`/`session.*` (matching `internal/events/events.go:20-38` exactly, including the new `SessionUpdated` constant the old table was missing). Renamed `AutomationFired`/`Completed`/`Failed` to `OrderFired`/`Completed`/`Failed`. Updated the storage-format example. - **`health-patrol.md`**: fixed six event names renamed by `be8debd8` (`agent.*` → `session.*`); replaced obsolete description of the removed `internal/agent.Agent` interface with current helper-package responsibilities; disambiguated the Erlang/OTP table's "Worker" row from gascity's `internal/worker/` package. - **`test/integration/E2E-PROVIDER-GAPS.md`**: three references to `agent.started` → `session.woke`. - **`examples/gastown/packs/gastown/formulas/mol-digest-generate.toml`**: runnable formula's `gc events --type=...` filter updated from broken `agent.*` event names to current `session.*` names. - Refreshed "Last verified against code" stamps on all touched docs to 2026-04-25. ## Why The spec maintenance rule (originally in `specs/architecture.md`, now distributed across the per-doc "Last verified" stamps) requires updating docs in the same commit as referenced symbol renames. That discipline wasn't honored on `dd90ac0a` or `be8debd8`, leaving the doc set describing a deleted primitive. A new contributor reading AGENTS.md was being directed to look for an abstraction that doesn't exist. ## Test plan - [x] `make build` clean - [x] `make check` clean (full `./...` suite green) - [x] `make check-docs` clean - [x] Repo-wide grep for `agent-protocol.md` returns 0 hits (no broken cross-doc links) - [x] All `Agent Protocol` references that remain are in intentional History/migration footnotes (4 files) - [x] All `session.*` event constants in docs match `internal/events/events.go:20-38` exactly - [x] `agent.*` event references remain only in `engdocs/archive/` (3 files, deliberately preserved historical analysis) ## Pre-flight review Multi-agent review pipeline (3 Anthropic reviewers + Gas City contributor checker) iterated 2 rounds before convergence. Iteration 1 caught a blocker (the worker-boundary claim was factually wrong) plus 5 majors; iteration 2 verified all fixes and approved with zero remaining findings. Codex and Copilot CLI were unresponsive in this session and skipped per the review skill's documented fallback policy. --------- Co-authored-by: sjarmak <sjarmak@users.noreply.github.com> Co-authored-by: Julian Knutsen <julianknutsen@users.noreply.github.com>
Summary
Two independent fixes for OpenCode ACP support in Gas City:
1. Include
protocolVersionin ACP initialize handshakeThe ACP spec requires
protocolVersioninInitializeParams. Without it, strict servers (e.g. OpenCode) reject the handshake. Claude Code was lenient and masked the non-compliance.2. Add
ACPCommand/ACPArgsfor transport-specific provider commandsProviders like OpenCode need different commands for ACP vs tmux transport (
opencode acpfor ACP, bareopencodefor tmux). The existingArgsfield applies to both transports with no override. AddsACPCommandandACPArgsfields that fall back field-by-field toCommand/Args.Known limitation
Resume/reconciliation paths (
session_runtime.go,cmd_session.go,worker_handle.go) passtransport=""toBuildProviderLaunchCommand. These paths use the stored command from session creation, which already contains the resolved ACP command — so they work correctly in practice. Threading transport through these paths would require adding a transport field tosession.Info, which is a larger change.Closes gastownhall#1008