@pi-in-go/pigpen-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- package/provenance.json +28 -0
package/port/PORT.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Port record: pi-acp
|
|
2
|
+
|
|
3
|
+
| Input | Identity |
|
|
4
|
+
|---|---|
|
|
5
|
+
| Original | `svkozak/pi-acp` 0.0.34, commit `b0581c9c1d675e634234674484247008b03d69b4` (2026-09-24), MIT (Sergii Kozak) |
|
|
6
|
+
| Original's ACP layer | `@agentclientprotocol/sdk` 0.26.0 (Zed Industries, Apache-2.0), protocol version 1, `schema/schema.json` |
|
|
7
|
+
| Oracle | the original's own test suite (125 test titles in 37 files, `port/upstream-tests.json`) as twins, and the reference SDK client (`ClientSideConnection` 0.26.0) plus the pinned schema for the wire |
|
|
8
|
+
| Target | PiG 0.3.0+0.87.1 from a 0.3.0 pre-release build (`63c6ba456bf5edd35c8042ce420ad700dd59a4ad`), go1.27.1 |
|
|
9
|
+
|
|
10
|
+
## What kind of port this is
|
|
11
|
+
|
|
12
|
+
pi-acp is **not a Pi extension**. It is a Node executable that speaks ACP JSON-RPC on its own stdio and
|
|
13
|
+
starts `pi --mode rpc` as a child. A PiG extension cannot be an ACP server: a fused extension must not write
|
|
14
|
+
protocol bytes to the host's stdout (roadmap, "Packaging boundary"). So the port has two parts:
|
|
15
|
+
|
|
16
|
+
- **`extensions/acp/cmd/pig-acp`**: the companion executable, plain Go with no dependency (not even the PiG SDK),
|
|
17
|
+
its own module (PiG rejects a factory and a `package main` in one module). It is the explicit protocol
|
|
18
|
+
entrypoint, separate from the TUI: `pig-acp --pig <pig or Piglet Binary>`.
|
|
19
|
+
- **`extensions/acp`**: a small Go extension on the public SDK. `/acp` prints the editor configuration for the
|
|
20
|
+
running executable and the declared capability limits. A Piglet needs at least one extension to build a Binary,
|
|
21
|
+
and this one belongs to the feature.
|
|
22
|
+
|
|
23
|
+
Because the source has no Pi extension API, the Skill's differential harness (`pigeq run/record/check`) has nothing to
|
|
24
|
+
host: the original and the port are compared by **ported tests** (exact twins), a **scripted ACP client against the
|
|
25
|
+
protocol schema**, and the **ACP reference client** (`@agentclientprotocol/sdk` `ClientSideConnection`) against a real
|
|
26
|
+
built Piglet Binary. See "Results" below.
|
|
27
|
+
|
|
28
|
+
## Contract table
|
|
29
|
+
|
|
30
|
+
| ID | Upstream file and symbol | Behavior | Go | Test | Disposition |
|
|
31
|
+
|---|---|---|---|---|---|
|
|
32
|
+
| A1 | `index.ts` stdio wiring | ndjson JSON-RPC 2.0 on stdin/stdout, one message per line, serialized writes | `internal/jsonrpc` | `jsonrpc_test.go`, `main_test.go` | mapped |
|
|
33
|
+
| A2 | `index.ts` stdout writer | a destroyed stdout never crashes; the run ends with exit 0 | `jsonrpc.Conn`, `main.run` | "stdout writer: resolves even if stdout is destroyed" (twin), `TestServing` | mapped |
|
|
34
|
+
| A3 | `index.ts` shutdown | stdin `end`/`close`, SIGINT, SIGTERM dispose every pi child, exit 0 | `Serve`, `main` | `TestServeSessionFlow` "closing stdin disposes every pi child", `TestE2EClosingStdinStopsThePigChild` | mapped |
|
|
35
|
+
| A4 | `index.ts` `--terminal-login` | starts the pi executable with inherited stdio, returns its status; ENOENT prints how to install and exits 1 | `main.run` | `TestTerminalLogin` | mapped (text says PiG) |
|
|
36
|
+
| A5 | `agent.ts` `initialize` | protocol version 1 only; `agentInfo`; capabilities: `loadSession`, `mcpCapabilities {http:false, sse:false}`, `promptCapabilities {image, audio:false, embeddedContext:<env>}`, `sessionCapabilities {list, delete}` | `Agent.Initialize` | `TestServeInitialize`, embedded-context twins | mapped; env `PIG_ACP_ENABLE_EMBEDDED_CONTEXT`, `PI_ACP_ENABLE_EMBEDDED_CONTEXT` still accepted |
|
|
37
|
+
| A6 | `auth.ts` | one terminal auth method; `_meta["terminal-auth"]` only when the client asks | `AuthMethods` | auth-methods twins | mapped (label `Launch pig`) |
|
|
38
|
+
| A7 | `auth-required.ts` | 11 substrings turn an error into AUTH_REQUIRED (-32000) with the auth methods | `MaybeAuthRequiredError` | `TestAuthRequiredError` | mapped |
|
|
39
|
+
| A8 | `agent.ts` `newSession` | absolute cwd; spawn; `get_state` + `get_available_models` in parallel; zero models, auth errors and config failures clean up the new session only (file, map entry, child); models/modes/configOptions; startup info; one live child per connection; commands and usage after the response | `Agent.NewSession` | new-session twins, `TestSessionConfigOptions`, `TestNewSessionPolicies` | mapped |
|
|
40
|
+
| A9 | `agent.ts` `loadSession` | absolute cwd; close a live copy; stored or discovered session; restore with `--session`; configuration must succeed before history is replayed; failure closes the restored child and leaves history and map entry alone | `Agent.LoadSession` | load twins, `TestLoadConfigurationFailure` | mapped |
|
|
41
|
+
| A10 | `agent.ts` history replay + `pi-messages.ts` | user/assistant text, tool results as synthetic tool calls, bash as terminal | `LoadSession`, `Normalize*` | "loadSession replays toolResult", pi-messages twins | mapped |
|
|
42
|
+
| A11 | `agent.ts` `listSessions` | scoped to the last session cwd when none is given; cursor paging by 50; invalid cursor reads as 0 | `Agent.ListSessions` | list twins, `TestNewSessionPolicies` | mapped |
|
|
43
|
+
| A12 | `agent.ts` `deleteSession` | removes the file and the map entry; unknown ids succeed | `Agent.DeleteSession` | delete twins | mapped |
|
|
44
|
+
| A13 | `agent.ts` `setSessionMode`, `setSessionConfigOption`, `unstable_setSessionModel` | thinking level and model; the update pair is sent only after pi's own state was read back and is consistent; the model change refreshes usage | same | model-thinking-levels twins (about 40), context-usage twins | mapped |
|
|
45
|
+
| A14 | `agent.ts` `prompt` built-ins | `/compact`, `/session`, `/name`, `/steering`, `/follow-up`, `/changelog`, `/export`, `/autocompact` are handled in the adapter and never reach the model | `Agent.Prompt` | builtin twins, `agent_builtin_test.go` | mapped; `/changelog` looks beside the pig binary (gap, below) |
|
|
46
|
+
| A15 | `agent.ts` `prompt` result | `error` maps to `cancelled` if cancel was requested, else `end_turn` | `Agent.Prompt` | `TestPromptRouting` | mapped |
|
|
47
|
+
| A16 | `agent.ts` `cancel` | ignores unknown sessions, never restores one | `Agent.Cancel` | "cancel ignores stale session IDs" twin | mapped |
|
|
48
|
+
| A17 | `agent.ts` available commands | pi `get_commands` (extension commands hidden, skill commands per setting) merged with the built-ins; prompt files as the fallback | `ToAvailableCommandsFromPiGetCommands`, `MergeCommands` | pi-commands, merge twins, `TestAgentCommandsAdvertised` | mapped |
|
|
49
|
+
| A18 | `agent.ts` startup info / update notice | markdown block (version, context, skills, prompts, extensions); `quietStartup` suppresses it; **npm update notice** | `BuildStartupInfo` | startup-info twins | mapped except the update notice: **skipped, gap** |
|
|
50
|
+
| S1 | `session.ts` turn lifecycle | a turn ends only on `agent_settled`; retry runs and `turn_end`/`agent_end` never end it | `Session` | session-events twins | mapped |
|
|
51
|
+
| S2 | `session.ts` queue | prompts queue behind a running turn; cancel clears the queue and aborts; queue depth in `session_info_update` | `Session.Prompt/Cancel` | queue twins, `session_extra_test.go` | mapped |
|
|
52
|
+
| S3 | `session.ts` streaming | `text_delta` to `agent_message_chunk`, `thinking_delta` to `agent_thought_chunk`, tool call events, monotonic status | `Session.handlePiEvent` | session-events twins | mapped |
|
|
53
|
+
| S4 | `session.ts` tool locations and diffs | relative paths resolved against the cwd, `edit` line from a unique `oldText`, structured diff from before/after file contents | same | session-diff twins | mapped |
|
|
54
|
+
| S5 | `bash.ts` | bash as a display terminal via `_meta` (`terminal_info`, `terminal_output` deltas, `terminal_exit`) | `Bash*` | `TestBashHelpers`, tool-call twins | mapped |
|
|
55
|
+
| S6 | `session.ts` usage | `usage_update` from `get_session_stats.contextUsage` before the turn resolves; never breaks the turn | `Session.PublishContextUsage` | usage twins | mapped |
|
|
56
|
+
| S7 | `session.ts` retry notices | `auto_retry_start` / `auto_retry_end` chunks | same | retry twins | mapped |
|
|
57
|
+
| S8 | `session.ts` compaction notices | `auto_compaction_start` / `auto_compaction_end` chunks | same | compaction twins | mapped as upstream; **PiG never sends these names**, see finding F1 |
|
|
58
|
+
| S9 | `session.ts` extension UI | `select` and `confirm` become `session/request_permission`; `input` and `editor` are cancelled with a visible notice; `notify` becomes a chunk with its severity; anything else is cancelled | same | permission twins, `TestSessionExtensionUI`, `TestE2EExtensionSelectBecomesPermissionRequest` | mapped |
|
|
59
|
+
| S10 | `prompt.ts` | text, resource links, embedded resources, images, audio marker | `PromptToPiMessage` | prompt twins | mapped (marker names pig-acp) |
|
|
60
|
+
| S11 | `pi-tools.ts` | tool result text: diff, content, stdout/stderr/exit code, JSON | `ToolResultToText` | pi-tools twins | mapped |
|
|
61
|
+
| S12 | `slash-commands.ts` | prompt template files, `$1`/`$@` expansion, bash-style quotes | `LoadSlashCommands`, `ExpandSlashCommand` | slash twins | mapped; directories are PiG's |
|
|
62
|
+
| P1 | `process.ts` | spawn `pig --mode rpc --no-themes [--session file]`, ENOENT/EACCES to a spawn error, handshake `get_state`, correlate ids, drop unknown responses, per-request timeout | `internal/pirpc` | request-timeout, session-path twins | mapped |
|
|
63
|
+
| P2 | `process.ts` `getAvailableThinkingLevels` | non-empty list of non-empty strings, or an "invalid levels" error | `Process.GetAvailableThinkingLevels` | thinking-level-rpc twins | mapped |
|
|
64
|
+
| P3 | `command.ts` | Windows `.cmd`/`.bat` launchers | `pirpc.ShouldUseShell` | pi-command twins | mapped; the simulated-Windows spawn twin is skipped |
|
|
65
|
+
| P4 | `pi-sessions.ts`, `session-store.ts`, `paths.ts`, `pi-settings.ts` | session discovery, title and `updatedAt`, map file, settings merge | `pisessions.go`, `store.go`, `paths.go`, `settings.go` | list twins, `store_paths_test.go` | mapped; PiG directories (`~/.pig`, `.pig/`, `PIG_HOME`) |
|
|
66
|
+
| L1 | README "Limitations" | no `fs/*` or `terminal/*` delegation; MCP servers accepted but not started; extension slash commands not supported | absent by design | `TestServeSessionFlow` (never calls fs/terminal), `TestE2EMcp...` | approved as upstream; advertised capabilities match |
|
|
67
|
+
|
|
68
|
+
## Findings about the hosts and about the original
|
|
69
|
+
|
|
70
|
+
- **F1: compaction event names.** The original handles `auto_compaction_start` and `auto_compaction_end`. Pi 0.84 to 0.99 and
|
|
71
|
+
PiG 0.3.0 emit `compaction_start` (reason `manual`, `threshold` or `overflow`) and `compaction_end`. The original's two notices never
|
|
72
|
+
appear against these hosts. Ported as upstream (twins keep the old names). Fixing it changes observable behavior, so it needs
|
|
73
|
+
owner approval: recommended, map `compaction_start`/`compaction_end` with reason `threshold` or `overflow` to the same two texts.
|
|
74
|
+
- **F2: update notice.** See A18. PiG has no npm package; `pig update` uses a signed manifest.
|
|
75
|
+
- **F3: `/changelog`.** pi-acp reads the installed Pi package's `CHANGELOG.md`. PiG binaries carry none; the port looks for one beside the binary
|
|
76
|
+
and otherwise says it could not find one.
|
|
77
|
+
- **F4: directories.** The original reads `~/.pi/...` and `PI_CODING_AGENT_DIR`. PiG keeps everything in `~/.pig` and never reads `~/.pi`;
|
|
78
|
+
`PIG_USE_PI_DIRS=1` (shared mode) selects Pi's directories. The port follows PiG's rules, and its session map lives under `PIG_HOME`.
|
|
79
|
+
- **F5: RPC differences that mattered.** None blocked the port: `get_available_thinking_levels`, `get_session_stats.contextUsage`,
|
|
80
|
+
`set_session_name`, `export_html`, `get_commands`, `agent_settled`, `extension_ui_request` all exist in PiG 0.3.0.
|
|
81
|
+
|
|
82
|
+
## Architecture decisions
|
|
83
|
+
|
|
84
|
+
- **Kind of port:** Skill kind 4 (a protocol adapter). `pigeq gaps` says NOT AN EXTENSION for the original. There is no
|
|
85
|
+
scenario/golden equivalence lane: the proof is the twin ledger, the protocol tests, the end-to-end scenarios, the reference client
|
|
86
|
+
with schema validation, and mutations (`port/mutate.mjs`, because `pigeq mutate` replays scenarios).
|
|
87
|
+
- **Who starts whom:** the editor starts `pig-acp`, and `pig-acp` starts `pig --mode rpc` per session (as upstream). The Skill's
|
|
88
|
+
kind-4 row suggests the Piglet's extension starts and supervises the companion. That is not possible for ACP: the client, not the
|
|
89
|
+
agent, owns the process, and the protocol runs on the companion's stdio. The extension therefore only reports the
|
|
90
|
+
configuration and the limits (`/acp`); it registers no handler on the session.
|
|
91
|
+
- **No dependency:** the companion imports neither PiG nor a third-party module (the JSON-RPC layer replaces the ACP SDK's
|
|
92
|
+
`AgentSideConnection`). It is its own module because PiG rejects a factory and a `package main` in one module.
|
|
93
|
+
- **One child per session, one live child per connection**, and the child is the selected pig, so a Piglet Binary is a valid `--pig`.
|
|
94
|
+
|
|
95
|
+
## Deviations from the original (each observable, each chosen)
|
|
96
|
+
|
|
97
|
+
| ID | Original | Port | Why |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| D1 | `RequestError.invalidParams("cwd must be ...")` passes the text as `data`, so clients see only "Invalid params" | the text is in `message` (and `data` is null) | clients show the message |
|
|
100
|
+
| D2 | prompt result resolves, then the idle `session_info_update` is emitted | the idle notice is flushed before the prompt result | a client that stops listening at the result has seen the whole turn |
|
|
101
|
+
| D3 | promise chain for `session/update` | unbounded FIFO queue and one writer goroutine | never blocks the agent on a slow client |
|
|
102
|
+
| D4 | process ends when stdin ends | `Serve` first waits up to 3 s for requests in flight, then disposes the children | a one-shot `echo request \| pig-acp` still gets its answer |
|
|
103
|
+
| D5 | `JSON.stringify(x, null, 2)` keeps key order | Go sorts map keys | `/session` fallback and the tool-result JSON fallback only |
|
|
104
|
+
| D6 | UTF-16 `slice(0, 80)` for fallback titles | runes | no split surrogate pair |
|
|
105
|
+
| D7 | directories `~/.pi/...`, `PI_CODING_AGENT_DIR`, `.pi/` | PiG's (`~/.pig`, `PIG_HOME`, `XDG_CONFIG_HOME/pig`, `PIG_CODING_AGENT_DIR`, `PIG_CODING_AGENT_SESSION_DIR`, `.pig/`, a leading `~` expanded as pig does), Pi's agent, session and project directories with `PIG_USE_PI_DIRS=1`; the session map stays under PiG's config root (`<PIG_HOME>/pig-acp`) in both modes | PiG never reads `~/.pi` and keeps its own state under its config root |
|
|
106
|
+
| D8 | agent name `pi-acp`, "Launch pi", "not supported by pi-acp" | `pig-acp`, "Launch pig", "not supported by pig-acp" | identity. File names (`pi-session-<id>.html`) and the words "pi" in `/export` and `/name` messages are unchanged |
|
|
107
|
+
| D9 | `PI_ACP_PI_COMMAND`, `PI_ACP_ENABLE_EMBEDDED_CONTEXT` | `PIG_ACP_PIG_COMMAND`, `PIG_ACP_ENABLE_EMBEDDED_CONTEXT`; the `PI_ACP_*` names are still read | migration |
|
|
108
|
+
| D10 | no flags beyond `--terminal-login` | `--pig`, `--piglet`, `--pig-arg`, `--version`, `--help`; the advertised terminal auth method repeats them, so a login starts the agent the sessions use | drive a Piglet Binary; explicit entrypoint |
|
|
109
|
+
| D11 | `startupInfo` header `pi v<version>` from `pi --version` | `pig v<version>` from `pig --version` (`0.3.0+0.87.1`) | identity |
|
|
110
|
+
| D12 | npm update notice, also under `quietStartup` | none (named skip) | see F2 |
|
|
111
|
+
| D13 | `/changelog` finds the npm package's file | looks for `CHANGELOG.md` beside the executable (one level up first) | PiG binaries carry none; the answer is then "Changelog not found" |
|
|
112
|
+
|
|
113
|
+
Named gaps (skipped tests, never silent): the simulated-Windows `.cmd` spawn twin (Go cannot fake `runtime.GOOS`; the shell
|
|
114
|
+
predicate is tested with an explicit `goos`, Windows execution only with `GOOS=windows go vet`); the npm update notice; the
|
|
115
|
+
compaction event names (F1). Not verified in a real Windows or macOS run; `go vet` passes for both.
|
|
116
|
+
|
|
117
|
+
## Results (this revision, PiG 0.3.0+0.87.1 `63c6ba456`, go1.27.1)
|
|
118
|
+
|
|
119
|
+
| Check | Result |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `pigeq twins check --ledger port/upstream-tests.json --go extensions/acp/cmd/pig-acp` | 124 exact twins, 1 named skip |
|
|
122
|
+
| `go test -race -timeout 100s ./...` (companion, all packages) and the extension module | pass; `-race -count=15` on `internal/acp` pass |
|
|
123
|
+
| End to end against real `pig` 0.3.0 | 11 of 11 |
|
|
124
|
+
| End to end against the built `acp` Piglet Binary (57.6 MB, `pig piglet build ... --format binary`, fused Go extension, 44 s) | 11 of 11 |
|
|
125
|
+
| Reference client run (`port/interop/run.mjs`) against `pig-acp` and the Piglet Binary | 12 checks, 30 messages schema-valid, exit 0 |
|
|
126
|
+
| Mutations (`port/mutate.mjs`) | 105 mutants (100 from the port, 5 from its review): 104 killed, 1 equivalent (`state-auth-error-ignored`: the same error is caught again by the configuration branch below), 0 survived, 0 invalid |
|
|
127
|
+
| `GOOS=windows|darwin|linux go vet ./...` | pass |
|
|
128
|
+
|
|
129
|
+
The first mutation run left 10 mutants alive; six needed a new test (`internal/acp/mutation_extra_test.go`), two needed a corrected
|
|
130
|
+
mutant, and one exposed racy twins (a test read `proc.prompts` before the session's goroutine had made the call; fixed with a wait).
|
|
131
|
+
|
|
132
|
+
## Under PiG 0.4.0 (Pi 0.99.1)
|
|
133
|
+
|
|
134
|
+
- The adapter relies on these RPC commands: `prompt`, `abort`, `get_state`, `get_available_models`, `set_model`,
|
|
135
|
+
`get_available_thinking_levels`, `set_thinking_level`, `set_follow_up_mode`, `set_steering_mode`, `compact`,
|
|
136
|
+
`set_auto_compaction`, `get_session_stats` (`contextUsage`), `set_session_name`, `export_html`, `get_messages`, `get_commands`,
|
|
137
|
+
`extension_ui_response`; and these events: `message_update` (`text_delta`, `thinking_delta`, `toolcall_*`),
|
|
138
|
+
`tool_execution_start|update|end`, `extension_ui_request` (`select`, `confirm`, `input`, `editor`, `notify`), `auto_retry_*`,
|
|
139
|
+
`agent_start|end|settled`, `turn_end`. Re-run the end-to-end scenarios against the 0.4.0 `pig` to see a change.
|
|
140
|
+
- F1: the compaction events are already `compaction_start|end` in Pi 0.84+; nothing changes with 0.99.1.
|
|
141
|
+
- The extension uses only the public SDK: `sdk.New`, `Command`, `Context.Notify`. A 0.4.0 SDK that renames these breaks the
|
|
142
|
+
extension's build, not the companion.
|
|
143
|
+
- Installed as a Package (not fused), the extension runs in its own process, so `os.Executable()` is PiG's extension cell runner.
|
|
144
|
+
`/acp` then names the agent from `PIG_HARNESS_BINARY`, the variable PiG 0.3.0 sets for every extension process (its harness
|
|
145
|
+
identity). It is not part of the public SDK; a public accessor for the host executable would remove this dependency.
|
|
146
|
+
- A PiG feature that would give a one-command experience: a Piglet Binary mode that serves ACP on stdio (`pig --mode acp`), or
|
|
147
|
+
a Binary that carries a companion executable. Neither exists in 0.3.0.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pigpen-acp-interop",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "pigpen-acp-interop",
|
|
9
|
+
"version": "0.0.0",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@agentclientprotocol/sdk": "0.26.0",
|
|
13
|
+
"ajv": "8.17.1"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"node_modules/@agentclientprotocol/sdk": {
|
|
17
|
+
"version": "0.26.0",
|
|
18
|
+
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.26.0.tgz",
|
|
19
|
+
"integrity": "sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q==",
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"zod": "^3.25.0 || ^4.0.0"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"node_modules/ajv": {
|
|
26
|
+
"version": "8.17.1",
|
|
27
|
+
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
|
28
|
+
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"fast-deep-equal": "^3.1.3",
|
|
32
|
+
"fast-uri": "^3.0.1",
|
|
33
|
+
"json-schema-traverse": "^1.0.0",
|
|
34
|
+
"require-from-string": "^2.0.2"
|
|
35
|
+
},
|
|
36
|
+
"funding": {
|
|
37
|
+
"type": "github",
|
|
38
|
+
"url": "https://github.com/sponsors/epoberezkin"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"node_modules/fast-deep-equal": {
|
|
42
|
+
"version": "3.1.3",
|
|
43
|
+
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
|
44
|
+
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
},
|
|
47
|
+
"node_modules/fast-uri": {
|
|
48
|
+
"version": "3.1.8",
|
|
49
|
+
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
|
50
|
+
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
|
51
|
+
"funding": [
|
|
52
|
+
{
|
|
53
|
+
"type": "github",
|
|
54
|
+
"url": "https://github.com/sponsors/fastify"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"type": "opencollective",
|
|
58
|
+
"url": "https://opencollective.com/fastify"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
"license": "BSD-3-Clause"
|
|
62
|
+
},
|
|
63
|
+
"node_modules/json-schema-traverse": {
|
|
64
|
+
"version": "1.0.0",
|
|
65
|
+
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
66
|
+
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
67
|
+
"license": "MIT"
|
|
68
|
+
},
|
|
69
|
+
"node_modules/require-from-string": {
|
|
70
|
+
"version": "2.0.2",
|
|
71
|
+
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
72
|
+
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
|
73
|
+
"license": "MIT",
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=0.10.0"
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"node_modules/zod": {
|
|
79
|
+
"version": "4.6.5",
|
|
80
|
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
|
81
|
+
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
|
82
|
+
"license": "MIT",
|
|
83
|
+
"peer": true,
|
|
84
|
+
"funding": {
|
|
85
|
+
"url": "https://github.com/sponsors/colinhacks"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pigpen-acp-interop",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"description": "Interop proof for pig-acp: the ACP reference client (@agentclientprotocol/sdk 0.26.0) drives a real pig-acp, and every message is checked against the pinned schema.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": { "test": "node run.mjs" },
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@agentclientprotocol/sdk": "0.26.0",
|
|
11
|
+
"ajv": "8.17.1"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Interop proof for pig-acp: the ACP reference client (@agentclientprotocol/sdk 0.26.0, ClientSideConnection)
|
|
3
|
+
// starts the real pig-acp executable, which starts a real pig (or a built Piglet Binary) against a scripted
|
|
4
|
+
// model. Every JSON-RPC message in both directions is then validated against the pinned schema
|
|
5
|
+
// (../schema/schema.json, schema 0.26.0, protocol version 1).
|
|
6
|
+
//
|
|
7
|
+
// PIG_ACP=<pig-acp binary> PIG=<pig or Piglet Binary> PIGEQ=<pigeq> node run.mjs
|
|
8
|
+
//
|
|
9
|
+
// Rule 17: pig runs with a temporary HOME, PIG_HOME and agent directories; nothing outside the temp directory is read or written.
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, join, resolve } from 'node:path';
|
|
14
|
+
import { Readable, Writable } from 'node:stream';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
18
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const need = (n) => {
|
|
22
|
+
if (!process.env[n]) throw new Error(`set ${n}`);
|
|
23
|
+
return process.env[n];
|
|
24
|
+
};
|
|
25
|
+
const ADAPTER = resolve(need('PIG_ACP'));
|
|
26
|
+
const PIG = resolve(need('PIG'));
|
|
27
|
+
const PIGEQ = need('PIGEQ');
|
|
28
|
+
const timeout = (ms, what) => new Promise((_, rej) => setTimeout(() => rej(new Error(`timed out: ${what}`)), ms).unref());
|
|
29
|
+
const within = (p, ms, what) => Promise.race([p, timeout(ms, what)]);
|
|
30
|
+
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), 'acp-interop-'));
|
|
32
|
+
const env = {
|
|
33
|
+
PATH: process.env.PATH,
|
|
34
|
+
HOME: join(root, 'home'),
|
|
35
|
+
PIG_HOME: join(root, 'pighome'),
|
|
36
|
+
PIG_CODING_AGENT_DIR: join(root, 'agent'),
|
|
37
|
+
PI_CODING_AGENT_DIR: join(root, 'agent'),
|
|
38
|
+
PIG_OFFLINE: '1',
|
|
39
|
+
PI_SKIP_VERSION_CHECK: '1',
|
|
40
|
+
PI_TELEMETRY: '0',
|
|
41
|
+
NO_COLOR: '1',
|
|
42
|
+
};
|
|
43
|
+
const work = join(root, 'work');
|
|
44
|
+
for (const d of [env.HOME, env.PIG_HOME, env.PIG_CODING_AGENT_DIR, work]) mkdirSync(d, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// 1. The scripted model (pigeq llm serves the harness's OpenAI-compatible model).
|
|
47
|
+
const turns = [{ text: 'Hello from the scripted model' }, { toolCalls: [{ name: 'bash', arguments: { command: 'echo interop-ok' } }] }, { text: 'all done' }];
|
|
48
|
+
writeFileSync(join(root, 'turns.json'), JSON.stringify(turns));
|
|
49
|
+
const llm = spawn(PIGEQ, ['llm', '--script', join(root, 'turns.json'), '--log', join(root, 'llm.log')], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
50
|
+
const baseURL = await within(new Promise((res) => llm.stdout.once('data', (d) => res(String(d).trim()))), 15000, 'pigeq llm address');
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(env.PIG_CODING_AGENT_DIR, 'models.json'),
|
|
53
|
+
JSON.stringify({ providers: { 'acp-llm': { baseUrl: baseURL, api: 'openai-completions', apiKey: 'acp-key', models: [{ id: 'acp-1', name: 'ACP One', reasoning: false, input: ['text'], contextWindow: 100000, maxTokens: 4096, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] } } }),
|
|
54
|
+
);
|
|
55
|
+
writeFileSync(join(env.PIG_CODING_AGENT_DIR, 'settings.json'), JSON.stringify({ defaultProvider: 'acp-llm', defaultModel: 'acp-1', quietStartup: true }));
|
|
56
|
+
|
|
57
|
+
// 2. pig-acp, with a tap on both directions of the wire.
|
|
58
|
+
const wire = [];
|
|
59
|
+
const adapter = spawn(ADAPTER, ['--pig', PIG], { env, cwd: work, stdio: ['pipe', 'pipe', 'inherit'] });
|
|
60
|
+
const exited = new Promise((res) => adapter.on('exit', (code, signal) => res({ code, signal })));
|
|
61
|
+
function tap(direction) {
|
|
62
|
+
let buf = '';
|
|
63
|
+
return new TransformStream({
|
|
64
|
+
transform(chunk, controller) {
|
|
65
|
+
buf += new TextDecoder().decode(chunk, { stream: true });
|
|
66
|
+
let i;
|
|
67
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
68
|
+
const line = buf.slice(0, i);
|
|
69
|
+
buf = buf.slice(i + 1);
|
|
70
|
+
if (line.trim()) wire.push({ direction, message: JSON.parse(line) });
|
|
71
|
+
}
|
|
72
|
+
controller.enqueue(chunk);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const toAgent = Writable.toWeb(adapter.stdin);
|
|
77
|
+
const tappedOut = new TransformStream();
|
|
78
|
+
tappedOut.readable.pipeThrough(tap('client->agent')).pipeTo(toAgent);
|
|
79
|
+
const fromAgent = Readable.toWeb(adapter.stdout).pipeThrough(tap('agent->client'));
|
|
80
|
+
|
|
81
|
+
// 3. The reference client.
|
|
82
|
+
const updates = [];
|
|
83
|
+
const calls = [];
|
|
84
|
+
const client = {
|
|
85
|
+
async requestPermission(params) {
|
|
86
|
+
calls.push('session/request_permission');
|
|
87
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
88
|
+
},
|
|
89
|
+
async sessionUpdate(params) {
|
|
90
|
+
updates.push(params.update);
|
|
91
|
+
},
|
|
92
|
+
async readTextFile() {
|
|
93
|
+
calls.push('fs/read_text_file');
|
|
94
|
+
throw new Error('the adapter must not delegate file reads');
|
|
95
|
+
},
|
|
96
|
+
async writeTextFile() {
|
|
97
|
+
calls.push('fs/write_text_file');
|
|
98
|
+
throw new Error('the adapter must not delegate file writes');
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const conn = new acp.ClientSideConnection(() => client, acp.ndJsonStream(tappedOut.writable, fromAgent));
|
|
102
|
+
|
|
103
|
+
const failures = [];
|
|
104
|
+
const check = (what, fn) => {
|
|
105
|
+
try {
|
|
106
|
+
fn();
|
|
107
|
+
console.log(`ok ${what}`);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
failures.push(what);
|
|
110
|
+
console.log(`FAIL ${what}: ${e.message}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const init = await within(
|
|
116
|
+
conn.initialize({ protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true } }),
|
|
117
|
+
30000,
|
|
118
|
+
'initialize',
|
|
119
|
+
);
|
|
120
|
+
check('initialize answers the pinned protocol version and only implemented capabilities', () => {
|
|
121
|
+
assert.equal(acp.PROTOCOL_VERSION, 1);
|
|
122
|
+
assert.equal(init.protocolVersion, 1);
|
|
123
|
+
assert.equal(init.agentCapabilities.loadSession, true);
|
|
124
|
+
assert.deepEqual(init.agentCapabilities.mcpCapabilities, { http: false, sse: false });
|
|
125
|
+
assert.equal(init.agentCapabilities.promptCapabilities.audio, false);
|
|
126
|
+
assert.equal(init.agentInfo.name, 'pig-acp');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const session = await within(conn.newSession({ cwd: work, mcpServers: [] }), 60000, 'session/new');
|
|
130
|
+
check('session/new returns models, modes and config options from pig', () => {
|
|
131
|
+
assert.ok(session.sessionId);
|
|
132
|
+
assert.equal(session.models.currentModelId, 'acp-llm/acp-1');
|
|
133
|
+
assert.ok(session.modes.availableModes.length > 0);
|
|
134
|
+
assert.ok(session.configOptions.some((o) => o.id === 'model'));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const p1 = await within(conn.prompt({ sessionId: session.sessionId, prompt: [{ type: 'text', text: 'say hello' }] }), 60000, 'prompt 1');
|
|
138
|
+
check('a prompt streams text and ends with end_turn', () => {
|
|
139
|
+
assert.equal(p1.stopReason, 'end_turn');
|
|
140
|
+
const text = updates.filter((u) => u.sessionUpdate === 'agent_message_chunk').map((u) => u.content.text).join('');
|
|
141
|
+
assert.match(text, /Hello from the scripted model/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const p2 = await within(conn.prompt({ sessionId: session.sessionId, prompt: [{ type: 'text', text: 'run a command' }] }), 60000, 'prompt 2');
|
|
145
|
+
check('a bash tool call is reported as an execute call with terminal output', () => {
|
|
146
|
+
assert.equal(p2.stopReason, 'end_turn');
|
|
147
|
+
const call = updates.find((u) => u.sessionUpdate === 'tool_call');
|
|
148
|
+
assert.equal(call.kind, 'execute');
|
|
149
|
+
assert.equal(call.title, 'echo interop-ok');
|
|
150
|
+
const output = updates.filter((u) => u.sessionUpdate === 'tool_call_update').map((u) => u._meta?.terminal_output?.data ?? '').join('');
|
|
151
|
+
assert.match(output, /interop-ok/);
|
|
152
|
+
const last = updates.filter((u) => u.sessionUpdate === 'tool_call_update').at(-1);
|
|
153
|
+
assert.equal(last.status, 'completed');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const listed = await within(conn.listSessions({ cwd: work }), 30000, 'session/list');
|
|
157
|
+
check('session/list finds the session', () => assert.ok(listed.sessions.some((s) => s.sessionId === session.sessionId)));
|
|
158
|
+
|
|
159
|
+
const cfg = await within(conn.setSessionConfigOption({ sessionId: session.sessionId, configId: 'model', value: 'acp-llm/acp-1' }), 30000, 'set_config_option');
|
|
160
|
+
check('session/set_config_option returns the config options', () => assert.ok(cfg.configOptions.length >= 2));
|
|
161
|
+
|
|
162
|
+
check('nothing was delegated to the client (no fs/* or terminal/* calls)', () => assert.deepEqual(calls, []));
|
|
163
|
+
await within(
|
|
164
|
+
conn.extMethod('session/fork', { sessionId: session.sessionId }).then(
|
|
165
|
+
() => failures.push('session/fork succeeded'),
|
|
166
|
+
(e) => check('session/fork is rejected with -32601', () => assert.equal(e.code, -32601)),
|
|
167
|
+
),
|
|
168
|
+
15000,
|
|
169
|
+
'session/fork',
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Schema conformance of everything on the wire.
|
|
173
|
+
const schema = JSON.parse(readFileSync(join(here, '..', 'schema', 'schema.json'), 'utf8'));
|
|
174
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
175
|
+
ajv.addSchema(schema, 'acp');
|
|
176
|
+
const def = (name) => {
|
|
177
|
+
const v = ajv.getSchema(`acp#/$defs/${name}`);
|
|
178
|
+
if (!v) throw new Error(`the schema has no ${name}`);
|
|
179
|
+
return v;
|
|
180
|
+
};
|
|
181
|
+
const byMethod = {
|
|
182
|
+
requests: { initialize: 'InitializeRequest', 'session/new': 'NewSessionRequest', 'session/prompt': 'PromptRequest', 'session/list': 'ListSessionsRequest', 'session/set_config_option': 'SetSessionConfigOptionRequest' },
|
|
183
|
+
responses: { initialize: 'InitializeResponse', 'session/new': 'NewSessionResponse', 'session/prompt': 'PromptResponse', 'session/list': 'ListSessionsResponse', 'session/set_config_option': 'SetSessionConfigOptionResponse' },
|
|
184
|
+
};
|
|
185
|
+
const methodOfId = new Map();
|
|
186
|
+
let validated = 0;
|
|
187
|
+
const problems = [];
|
|
188
|
+
const validate = (name, value, where) => {
|
|
189
|
+
const v = def(name);
|
|
190
|
+
validated++;
|
|
191
|
+
if (!v(value)) problems.push(`${where}: ${name}: ${ajv.errorsText(v.errors, { dataVar: 'msg' }).slice(0, 300)}`);
|
|
192
|
+
};
|
|
193
|
+
for (const { direction, message: m } of wire) {
|
|
194
|
+
if (m.jsonrpc !== '2.0') problems.push(`${direction}: not JSON-RPC 2.0: ${JSON.stringify(m).slice(0, 80)}`);
|
|
195
|
+
if (direction === 'client->agent' && m.method && m.id !== undefined) {
|
|
196
|
+
methodOfId.set(m.id, m.method);
|
|
197
|
+
if (byMethod.requests[m.method]) validate(byMethod.requests[m.method], m.params, `client->agent ${m.method}`);
|
|
198
|
+
} else if (direction === 'agent->client' && m.method === 'session/update') {
|
|
199
|
+
validate('SessionNotification', m.params, 'agent->client session/update');
|
|
200
|
+
} else if (direction === 'agent->client' && m.method === 'session/request_permission') {
|
|
201
|
+
validate('RequestPermissionRequest', m.params, 'agent->client session/request_permission');
|
|
202
|
+
} else if (direction === 'agent->client' && m.id !== undefined && 'result' in m) {
|
|
203
|
+
const method = methodOfId.get(m.id);
|
|
204
|
+
if (byMethod.responses[method]) validate(byMethod.responses[method], m.result, `agent->client result of ${method}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
check(`all ${validated} messages that have a schema definition validate against schema 0.26.0`, () => {
|
|
208
|
+
assert.ok(validated >= 12, `only ${validated} messages were validated`);
|
|
209
|
+
assert.deepEqual(problems, []);
|
|
210
|
+
});
|
|
211
|
+
check('nothing but JSON-RPC 2.0 messages came from the adapter', () => assert.ok(wire.filter((w) => w.direction === 'agent->client').every((w) => w.message.jsonrpc === '2.0')));
|
|
212
|
+
|
|
213
|
+
// Process exit: closing the client's output ends the adapter with status 0.
|
|
214
|
+
await tappedOut.writable.close();
|
|
215
|
+
const exit = await within(exited, 20000, 'the adapter to exit');
|
|
216
|
+
check('closing stdin ends pig-acp with exit status 0', () => assert.deepEqual(exit, { code: 0, signal: null }));
|
|
217
|
+
} catch (e) {
|
|
218
|
+
failures.push(String(e));
|
|
219
|
+
console.log(`FAIL ${e.stack || e}`);
|
|
220
|
+
} finally {
|
|
221
|
+
adapter.kill();
|
|
222
|
+
llm.kill();
|
|
223
|
+
rmSync(root, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
if (failures.length) {
|
|
226
|
+
console.log(`\n${failures.length} check(s) failed`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
console.log('\nall interop checks passed');
|
package/port/mutate.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Mutation check for the acp port (the adapter has no scenarios, so `pigeq mutate` has nothing to
|
|
3
|
+
// replay; see PORT.md). Every entry of mutations.json is one deliberate defect. It is applied to a
|
|
4
|
+
// copy of the module, the module's Go tests run (the end-to-end scenarios need PIG_ACP_E2E_PIG and
|
|
5
|
+
// are skipped here), and the mutant must be killed by a failing test. A mutant that does not build is
|
|
6
|
+
// INVALID and fails the run; an unmutated baseline that is not green refuses the whole run.
|
|
7
|
+
//
|
|
8
|
+
// A mutant marked "equivalent" (with the reason) changes no observable behavior and is reported, not failed.
|
|
9
|
+
//
|
|
10
|
+
// node components/acp/port/mutate.mjs [--only name] [--jobs N] [--sdk DIR]
|
|
11
|
+
//
|
|
12
|
+
// Each entry: { name, module: "cmd" | "ext", file, find, replace, why? }; find must occur exactly once.
|
|
13
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const modules = {
|
|
21
|
+
ext: resolve(here, '../extensions/acp'),
|
|
22
|
+
cmd: resolve(here, '../extensions/acp/cmd/pig-acp'),
|
|
23
|
+
};
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const opt = (n, d) => (args.includes(n) ? args[args.indexOf(n) + 1] : d);
|
|
26
|
+
const only = opt('--only');
|
|
27
|
+
const jobs = Number(opt('--jobs', '4'));
|
|
28
|
+
const sdk = opt('--sdk', process.env.PIG_SDK_DIR || '');
|
|
29
|
+
|
|
30
|
+
const list = JSON.parse(readFileSync(join(here, 'mutations.json'), 'utf8'));
|
|
31
|
+
const names = new Set();
|
|
32
|
+
for (const m of list) {
|
|
33
|
+
if (!m.name || !m.file || !m.find || m.find === m.replace || !modules[m.module]) throw new Error(`bad mutation ${JSON.stringify(m.name)}`);
|
|
34
|
+
if (names.has(m.name)) throw new Error(`duplicate mutation ${m.name}`);
|
|
35
|
+
names.add(m.name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyModule(kind) {
|
|
39
|
+
const parent = mkdtempSync(join(tmpdir(), 'acp-mut-'));
|
|
40
|
+
const dir = join(parent, kind === 'ext' ? 'acp' : 'pig-acp');
|
|
41
|
+
// The extension module contains the companion module beneath it; the copy of the extension leaves it out.
|
|
42
|
+
cpSync(modules[kind], dir, { recursive: true, filter: (p) => !(kind === 'ext' && p.includes('/cmd/')) && !p.includes('/testdata/') });
|
|
43
|
+
if (kind === 'cmd') cpSync(join(modules.cmd, 'testdata'), join(dir, 'testdata'), { recursive: true });
|
|
44
|
+
return { parent, dir };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function goTest(kind, dir, parent) {
|
|
48
|
+
const env = { ...process.env, GOFLAGS: '' };
|
|
49
|
+
if (kind === 'ext') {
|
|
50
|
+
if (!sdk) throw new Error('the extension module needs --sdk <PiG staged SDK dir> (or PIG_SDK_DIR)');
|
|
51
|
+
const work = join(parent, 'go.work');
|
|
52
|
+
writeFileSync(work, `go 1.26\n\nuse (\n\t${sdk}\n\t${dir}\n)\n`);
|
|
53
|
+
env.GOWORK = work;
|
|
54
|
+
} else {
|
|
55
|
+
env.GOWORK = 'off';
|
|
56
|
+
}
|
|
57
|
+
return new Promise((done) => {
|
|
58
|
+
const p = spawn('go', ['test', '-count=1', '-timeout', '120s', './...'], { cwd: dir, env });
|
|
59
|
+
let out = '';
|
|
60
|
+
p.stdout.on('data', (d) => (out += d));
|
|
61
|
+
p.stderr.on('data', (d) => (out += d));
|
|
62
|
+
p.on('close', (code) => done({ code, out }));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const build = (out) => /\[build failed\]|\[setup failed\]|cannot find|undefined:|syntax error|declared and not used|imported and not used/.test(out);
|
|
67
|
+
|
|
68
|
+
async function baseline(kind) {
|
|
69
|
+
const { parent, dir } = copyModule(kind);
|
|
70
|
+
const r = await goTest(kind, dir, parent);
|
|
71
|
+
rmSync(parent, { recursive: true, force: true });
|
|
72
|
+
if (r.code !== 0) throw new Error(`the unmutated ${kind} module does not pass its tests, so no mutant can be judged:\n${r.out.split('\n').slice(0, 15).join('\n')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runOne(m) {
|
|
76
|
+
const { parent, dir } = copyModule(m.module);
|
|
77
|
+
try {
|
|
78
|
+
const target = join(dir, m.file);
|
|
79
|
+
if (!existsSync(target)) return { m, status: 'INVALID', detail: `no file ${m.file}` };
|
|
80
|
+
const src = readFileSync(target, 'utf8');
|
|
81
|
+
const n = src.split(m.find).length - 1;
|
|
82
|
+
if (n !== 1) return { m, status: 'INVALID', detail: `find text occurs ${n} times in ${m.file}, want exactly 1` };
|
|
83
|
+
writeFileSync(target, src.replace(m.find, () => m.replace));
|
|
84
|
+
const r = await goTest(m.module, dir, parent);
|
|
85
|
+
if (r.code === 0) return { m, status: m.equivalent ? 'EQUIVALENT' : 'SURVIVED', detail: m.equivalent || '' };
|
|
86
|
+
if (build(r.out)) return { m, status: 'INVALID', detail: r.out.split('\n').slice(0, 4).join(' | ') };
|
|
87
|
+
const fail = r.out.split('\n').find((l) => l.startsWith('--- FAIL') || l.startsWith('panic:') || l.startsWith('FAIL')) || 'failed';
|
|
88
|
+
return { m, status: 'KILLED', detail: fail.trim() };
|
|
89
|
+
} finally {
|
|
90
|
+
rmSync(parent, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const todo = list.filter((m) => !only || m.name === only);
|
|
95
|
+
for (const kind of new Set(todo.map((m) => m.module))) await baseline(kind);
|
|
96
|
+
const results = [];
|
|
97
|
+
let next = 0;
|
|
98
|
+
await Promise.all(
|
|
99
|
+
Array.from({ length: Math.min(jobs, todo.length) }, async () => {
|
|
100
|
+
while (next < todo.length) {
|
|
101
|
+
const m = todo[next++];
|
|
102
|
+
const r = await runOne(m);
|
|
103
|
+
results.push(r);
|
|
104
|
+
console.log(`${r.status.padEnd(8)} ${m.name}${r.detail ? ` (${r.detail.slice(0, 110)})` : ''}`);
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
const count = (s) => results.filter((r) => r.status === s).length;
|
|
109
|
+
console.log(`\n${count('KILLED')} killed, ${count('EQUIVALENT')} equivalent, ${count('SURVIVED')} survived, ${count('INVALID')} invalid of ${results.length}`);
|
|
110
|
+
process.exit(count('SURVIVED') || count('INVALID') ? 1 : 0);
|