@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
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
// Twins of test/unit/session-restore.test.ts, session-delete.test.ts, new-session-*.test.ts,
|
|
4
|
+
// startup-info-*.test.ts and the load-configuration cases of model-thinking-levels.test.ts.
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"os"
|
|
10
|
+
"path/filepath"
|
|
11
|
+
"reflect"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func headerLine(id, cwd string) string {
|
|
17
|
+
return fmt.Sprintf(`{"type":"session","version":3,"id":%q,"timestamp":"2026-06-16T00:00:00.000Z","cwd":%q}`, id, cwd)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func TestSessionRestore(t *testing.T) {
|
|
21
|
+
tw(t, "unit/session-restore", "PiAcpAgent: prompt auto-restores a missing session from SessionStore", func(t *testing.T) {
|
|
22
|
+
conn := newFakeConn()
|
|
23
|
+
var spawns []SpawnParams
|
|
24
|
+
var prompted []promptCall
|
|
25
|
+
store := newMemStore(StoredSession{SessionID: "stored-session", Cwd: "/tmp/store-project", SessionFile: "/tmp/store-project/session.jsonl"})
|
|
26
|
+
a, _ := testAgent(conn)
|
|
27
|
+
a.store = store
|
|
28
|
+
a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return newFakeProc(), nil }
|
|
29
|
+
a.sessions = &fakeSessions{build: func(id string, p SessionCreateParams) ActiveSession {
|
|
30
|
+
return &fakeSession{id: id, cwd: p.Cwd, proc: p.Proc, promptFn: func(m string, im []Image) TurnResult {
|
|
31
|
+
prompted = append(prompted, promptCall{m, im})
|
|
32
|
+
return TurnResult{Reason: StopEndTurn}
|
|
33
|
+
}}
|
|
34
|
+
}}
|
|
35
|
+
res, err := a.Prompt(promptText("stored-session", "hello again"))
|
|
36
|
+
if err != nil || res.StopReason != StopEndTurn {
|
|
37
|
+
t.Fatalf("res=%v err=%v", res, err)
|
|
38
|
+
}
|
|
39
|
+
if !reflect.DeepEqual(spawns, []SpawnParams{{Cwd: "/tmp/store-project", SessionPath: "/tmp/store-project/session.jsonl"}}) {
|
|
40
|
+
t.Errorf("spawns = %+v", spawns)
|
|
41
|
+
}
|
|
42
|
+
if len(prompted) != 1 || prompted[0].Message != "hello again" || len(prompted[0].Images) != 0 {
|
|
43
|
+
t.Errorf("prompted = %+v", prompted)
|
|
44
|
+
}
|
|
45
|
+
want := StoredSession{SessionID: "stored-session", Cwd: "/tmp/store-project", SessionFile: "/tmp/store-project/session.jsonl"}
|
|
46
|
+
if len(store.upserts) != 1 || store.upserts[0].SessionID != want.SessionID || store.upserts[0].Cwd != want.Cwd || store.upserts[0].SessionFile != want.SessionFile {
|
|
47
|
+
t.Errorf("upserts = %+v", store.upserts)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
tw(t, "unit/session-restore", "PiAcpAgent: setSessionConfigOption auto-restores via pi session discovery when SessionStore misses", func(t *testing.T) {
|
|
52
|
+
conn := newFakeConn()
|
|
53
|
+
root := t.TempDir()
|
|
54
|
+
sessionFile := filepath.Join(root, "sessions", "--tmp--fallback-project--", "0000_restore_fallback.jsonl")
|
|
55
|
+
writeSessionFile(t, sessionFile, headerLine("fallback-session", "/tmp/fallback-project"))
|
|
56
|
+
withAgentDir(t, root)
|
|
57
|
+
state := map[string]any{"thinkingLevel": "medium", "model": map[string]any{"provider": "test", "id": "alpha"}}
|
|
58
|
+
var setModel []map[string]string
|
|
59
|
+
var spawns []SpawnParams
|
|
60
|
+
proc := newFakeProc()
|
|
61
|
+
proc.getLevelsFn = func() ([]string, error) { return []string{"medium"}, nil }
|
|
62
|
+
proc.getModelsFn = func() (map[string]any, error) {
|
|
63
|
+
return models([3]string{"test", "alpha", "Alpha"}, [3]string{"test", "beta", "Beta"}), nil
|
|
64
|
+
}
|
|
65
|
+
proc.getStateFn = func() (map[string]any, error) { return state, nil }
|
|
66
|
+
proc.setModelFn = func(p, id string) error {
|
|
67
|
+
setModel = append(setModel, map[string]string{"provider": p, "modelId": id})
|
|
68
|
+
state["model"] = map[string]any{"provider": p, "id": id}
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
store := newMemStore()
|
|
72
|
+
a, _ := testAgent(conn)
|
|
73
|
+
a.store = store
|
|
74
|
+
a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return proc, nil }
|
|
75
|
+
a.sessions = &fakeSessions{build: func(id string, p SessionCreateParams) ActiveSession {
|
|
76
|
+
return &fakeSession{id: id, cwd: p.Cwd, proc: p.Proc}
|
|
77
|
+
}}
|
|
78
|
+
result, err := a.SetSessionConfigOption(SetSessionConfigOptionRequest{SessionID: "fallback-session", ConfigID: "model", Value: "test/beta"})
|
|
79
|
+
if err != nil {
|
|
80
|
+
t.Fatal(err)
|
|
81
|
+
}
|
|
82
|
+
if !reflect.DeepEqual(spawns, []SpawnParams{{Cwd: "/tmp/fallback-project", SessionPath: sessionFile}}) {
|
|
83
|
+
t.Errorf("spawns = %+v", spawns)
|
|
84
|
+
}
|
|
85
|
+
jsonEqual(t, setModel, []any{map[string]any{"provider": "test", "modelId": "beta"}})
|
|
86
|
+
if optionValue(result.ConfigOptions, "model") != "test/beta" {
|
|
87
|
+
t.Errorf("model = %q", optionValue(result.ConfigOptions, "model"))
|
|
88
|
+
}
|
|
89
|
+
if len(store.upserts) != 2 {
|
|
90
|
+
t.Fatalf("upserts = %+v", store.upserts)
|
|
91
|
+
}
|
|
92
|
+
for _, u := range store.upserts {
|
|
93
|
+
if u.SessionID != "fallback-session" || u.Cwd != "/tmp/fallback-project" || u.SessionFile != sessionFile {
|
|
94
|
+
t.Errorf("upsert = %+v", u)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
var got []Update
|
|
98
|
+
for _, u := range conn.all() {
|
|
99
|
+
got = append(got, u.Update)
|
|
100
|
+
}
|
|
101
|
+
jsonEqual(t, got, []any{
|
|
102
|
+
map[string]any{"sessionUpdate": "current_mode_update", "currentModeId": "medium"},
|
|
103
|
+
map[string]any{"sessionUpdate": "config_option_update", "configOptions": result.ConfigOptions},
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
tw(t, "unit/session-restore", "PiAcpAgent: cancel ignores stale session IDs without spawning a restore process", func(t *testing.T) {
|
|
108
|
+
conn := newFakeConn()
|
|
109
|
+
var spawns []SpawnParams
|
|
110
|
+
a, _ := testAgent(conn)
|
|
111
|
+
a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return newFakeProc(), nil }
|
|
112
|
+
a.sessions = &fakeSessions{build: func(string, SessionCreateParams) ActiveSession {
|
|
113
|
+
t.Error("cancel should not restore a missing session")
|
|
114
|
+
return nil
|
|
115
|
+
}}
|
|
116
|
+
if err := a.Cancel("stale-session"); err != nil {
|
|
117
|
+
t.Fatal(err)
|
|
118
|
+
}
|
|
119
|
+
if len(spawns) != 0 || len(conn.all()) != 0 {
|
|
120
|
+
t.Errorf("spawns=%v updates=%v", spawns, conn.all())
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestSessionDelete(t *testing.T) {
|
|
126
|
+
deleteAgent := func(store *memStore) *Agent {
|
|
127
|
+
a, _ := testAgent(newFakeConn())
|
|
128
|
+
a.store = store
|
|
129
|
+
return a
|
|
130
|
+
}
|
|
131
|
+
tw(t, "unit/session-delete", "PiAcpAgent: deleteSession removes stored session and session file", func(t *testing.T) {
|
|
132
|
+
root := t.TempDir()
|
|
133
|
+
sessionFile := filepath.Join(root, "sessions", "--tmp--delete-project--", "0000_delete_me.jsonl")
|
|
134
|
+
writeSessionFile(t, sessionFile, headerLine("sess-del-store", "/tmp/delete-project"))
|
|
135
|
+
withAgentDir(t, root)
|
|
136
|
+
store := newMemStore(StoredSession{SessionID: "stored-session", Cwd: "/tmp/delete-project", SessionFile: sessionFile})
|
|
137
|
+
res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "stored-session"})
|
|
138
|
+
if err != nil || len(res) != 0 {
|
|
139
|
+
t.Fatalf("res=%v err=%v", res, err)
|
|
140
|
+
}
|
|
141
|
+
if !reflect.DeepEqual(store.deletes, []string{"stored-session"}) {
|
|
142
|
+
t.Errorf("deletes = %v", store.deletes)
|
|
143
|
+
}
|
|
144
|
+
if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
|
|
145
|
+
t.Error("session file still exists")
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
tw(t, "unit/session-delete", "PiAcpAgent: deleteSession finds session via pi discovery when SessionStore misses", func(t *testing.T) {
|
|
149
|
+
root := t.TempDir()
|
|
150
|
+
sessionFile := filepath.Join(root, "sessions", "--tmp--delete-discovery--", "0000_pi_discovery.jsonl")
|
|
151
|
+
writeSessionFile(t, sessionFile, headerLine("pi-discovered-session", "/tmp/delete-discovery"))
|
|
152
|
+
withAgentDir(t, root)
|
|
153
|
+
store := newMemStore()
|
|
154
|
+
res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "pi-discovered-session"})
|
|
155
|
+
if err != nil || len(res) != 0 {
|
|
156
|
+
t.Fatalf("res=%v err=%v", res, err)
|
|
157
|
+
}
|
|
158
|
+
if !reflect.DeepEqual(store.deletes, []string{"pi-discovered-session"}) {
|
|
159
|
+
t.Errorf("deletes = %v", store.deletes)
|
|
160
|
+
}
|
|
161
|
+
if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
|
|
162
|
+
t.Error("session file still exists")
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
tw(t, "unit/session-delete", "PiAcpAgent: deleteSession succeeds idempotently for unknown sessionId", func(t *testing.T) {
|
|
166
|
+
root := t.TempDir()
|
|
167
|
+
if err := os.MkdirAll(filepath.Join(root, "sessions", "--tmp--delete-unknown--"), 0o755); err != nil {
|
|
168
|
+
t.Fatal(err)
|
|
169
|
+
}
|
|
170
|
+
withAgentDir(t, root)
|
|
171
|
+
store := newMemStore()
|
|
172
|
+
res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "non-existent-session"})
|
|
173
|
+
if err != nil || len(res) != 0 || len(store.deletes) != 0 {
|
|
174
|
+
t.Fatalf("res=%v err=%v deletes=%v", res, err, store.deletes)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
tw(t, "unit/session-delete", "PiAcpAgent: deleteSession survives missing session file", func(t *testing.T) {
|
|
178
|
+
root := t.TempDir()
|
|
179
|
+
missing := filepath.Join(root, "sessions", "--tmp--delete-missingfile--", "0000_non_existent.jsonl")
|
|
180
|
+
if err := os.MkdirAll(filepath.Dir(missing), 0o755); err != nil {
|
|
181
|
+
t.Fatal(err)
|
|
182
|
+
}
|
|
183
|
+
withAgentDir(t, root)
|
|
184
|
+
store := newMemStore(StoredSession{SessionID: "missing-file-session", Cwd: "/tmp/delete-missingfile", SessionFile: missing})
|
|
185
|
+
res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "missing-file-session"})
|
|
186
|
+
if err != nil || len(res) != 0 {
|
|
187
|
+
t.Fatalf("res=%v err=%v", res, err)
|
|
188
|
+
}
|
|
189
|
+
if !reflect.DeepEqual(store.deletes, []string{"missing-file-session"}) {
|
|
190
|
+
t.Errorf("deletes = %v", store.deletes)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func TestNewSessionFailures(t *testing.T) {
|
|
196
|
+
failing := func(models func() (map[string]any, error), state map[string]any) *fakeProc {
|
|
197
|
+
p := newFakeProc()
|
|
198
|
+
p.getModelsFn = models
|
|
199
|
+
p.getStateFn = func() (map[string]any, error) { return state, nil }
|
|
200
|
+
return p
|
|
201
|
+
}
|
|
202
|
+
errCode := func(err error) int {
|
|
203
|
+
if re, ok := err.(*RequestError); ok {
|
|
204
|
+
return re.Code
|
|
205
|
+
}
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
tw(t, "unit/new-session-auth-required-when-no-models", "PiAcpAgent: newSession throws AUTH_REQUIRED when pi reports zero available models", func(t *testing.T) {
|
|
210
|
+
proc := failing(func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }, map[string]any{"thinkingLevel": "medium", "model": nil})
|
|
211
|
+
sessions := &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
|
|
212
|
+
a, _ := testAgent(newFakeConn())
|
|
213
|
+
a.sessions = sessions
|
|
214
|
+
_, err := a.NewSession(newSessionReq(cwdNow(t)))
|
|
215
|
+
if errCode(err) != -32000 || !strings.Contains(strings.ToLower(err.Error()), "configure an api key or log in with an oauth provider") {
|
|
216
|
+
t.Fatalf("err = %v", err)
|
|
217
|
+
}
|
|
218
|
+
if !reflect.DeepEqual(sessions.closeCalls, []string{"s1"}) {
|
|
219
|
+
t.Errorf("closeCalls = %v", sessions.closeCalls)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
tw(t, "unit/new-session-pi-not-found", "PiAcpAgent: newSession returns a helpful Internal error when pi is not installed", func(t *testing.T) {
|
|
224
|
+
t.Setenv("PI_ACP_PI_COMMAND", "pi-does-not-exist-12345")
|
|
225
|
+
a, _ := testAgent(newFakeConn())
|
|
226
|
+
a.sessions = NewSessionManager(nil, newMemStore())
|
|
227
|
+
_, err := a.NewSession(newSessionReq(cwdNow(t)))
|
|
228
|
+
if errCode(err) != -32603 || !strings.Contains(strings.ToLower(err.Error()), "executable not found") {
|
|
229
|
+
t.Fatalf("err = %v", err)
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: newSession returns AUTH_REQUIRED when pi reports an auth error after spawn", func(t *testing.T) {
|
|
234
|
+
root := t.TempDir()
|
|
235
|
+
sessionFile := filepath.Join(root, "sessions", "failed.jsonl")
|
|
236
|
+
writeSessionFile(t, sessionFile, headerLine("s-auth", cwdNow(t)))
|
|
237
|
+
proc := failing(func() (map[string]any, error) { return nil, errors.New("Authentication required: missing key") },
|
|
238
|
+
map[string]any{"thinkingLevel": "medium", "model": nil, "sessionFile": sessionFile})
|
|
239
|
+
sessions := &fakeSessions{session: &fakeSession{id: "s-auth", cwd: cwdNow(t), proc: proc}}
|
|
240
|
+
store := NewFileStore(filepath.Join(root, "session-map.json"))
|
|
241
|
+
store.Upsert(StoredSession{SessionID: "s-auth", Cwd: cwdNow(t), SessionFile: sessionFile})
|
|
242
|
+
a, _ := testAgent(newFakeConn())
|
|
243
|
+
a.sessions, a.store = sessions, store
|
|
244
|
+
_, err := a.NewSession(newSessionReq(cwdNow(t)))
|
|
245
|
+
if errCode(err) != -32000 {
|
|
246
|
+
t.Fatalf("err = %v", err)
|
|
247
|
+
}
|
|
248
|
+
if !reflect.DeepEqual(sessions.closeCalls, []string{"s-auth"}) {
|
|
249
|
+
t.Errorf("closeCalls = %v", sessions.closeCalls)
|
|
250
|
+
}
|
|
251
|
+
if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
|
|
252
|
+
t.Error("the failed session file was not removed")
|
|
253
|
+
}
|
|
254
|
+
if store.Get("s-auth") != nil {
|
|
255
|
+
t.Error("the failed session is still in the store")
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: newSession returns Internal error on non-auth model probe failures after spawn", func(t *testing.T) {
|
|
260
|
+
proc := failing(func() (map[string]any, error) { return nil, errors.New("socket hang up") }, map[string]any{"thinkingLevel": "medium", "model": nil})
|
|
261
|
+
sessions := &fakeSessions{session: &fakeSession{id: "s-internal", cwd: cwdNow(t), proc: proc}}
|
|
262
|
+
a, _ := testAgent(newFakeConn())
|
|
263
|
+
a.sessions = sessions
|
|
264
|
+
_, err := a.NewSession(newSessionReq(cwdNow(t)))
|
|
265
|
+
if errCode(err) != -32603 || !strings.Contains(err.Error(), "socket hang up") {
|
|
266
|
+
t.Fatalf("err = %v", err)
|
|
267
|
+
}
|
|
268
|
+
if !reflect.DeepEqual(sessions.closeCalls, []string{"s-internal"}) {
|
|
269
|
+
t.Errorf("closeCalls = %v", sessions.closeCalls)
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
for _, failure := range []string{"discovery", "auth", "invalid-current", "inconsistent-current", "state"} {
|
|
274
|
+
tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: cleans up only the new session after ${failure} configuration failure", func(t *testing.T) {
|
|
275
|
+
t.Run(fmt.Sprintf("PiAcpAgent: cleans up only the new session after %s configuration failure", failure), func(t *testing.T) {
|
|
276
|
+
root := t.TempDir()
|
|
277
|
+
sessionFile, existingFile := filepath.Join(root, "failed.jsonl"), filepath.Join(root, "existing.jsonl")
|
|
278
|
+
write(t, sessionFile, "new session\n")
|
|
279
|
+
write(t, existingFile, "existing session\n")
|
|
280
|
+
store := NewFileStore(filepath.Join(root, "map.json"))
|
|
281
|
+
store.Upsert(StoredSession{SessionID: "failed", Cwd: root, SessionFile: sessionFile})
|
|
282
|
+
store.Upsert(StoredSession{SessionID: "existing", Cwd: root, SessionFile: existingFile})
|
|
283
|
+
proc := newFakeProc()
|
|
284
|
+
proc.getModelsFn = func() (map[string]any, error) { return models([3]string{"test", "model", ""}), nil }
|
|
285
|
+
proc.getStateFn = func() (map[string]any, error) {
|
|
286
|
+
if failure == "state" {
|
|
287
|
+
return nil, errors.New("state read failed")
|
|
288
|
+
}
|
|
289
|
+
level := "max"
|
|
290
|
+
if failure == "invalid-current" {
|
|
291
|
+
level = ""
|
|
292
|
+
} else if failure == "inconsistent-current" {
|
|
293
|
+
level = "medium"
|
|
294
|
+
}
|
|
295
|
+
return map[string]any{"sessionFile": sessionFile, "thinkingLevel": level}, nil
|
|
296
|
+
}
|
|
297
|
+
proc.getLevelsFn = func() ([]string, error) {
|
|
298
|
+
if failure == "discovery" {
|
|
299
|
+
return nil, errors.New("discovery failed")
|
|
300
|
+
}
|
|
301
|
+
if failure == "auth" {
|
|
302
|
+
return nil, errors.New("Authentication required: missing key")
|
|
303
|
+
}
|
|
304
|
+
return []string{"low", "high", "max"}, nil
|
|
305
|
+
}
|
|
306
|
+
sessions := &fakeSessions{session: &fakeSession{id: "failed", cwd: root, proc: proc}}
|
|
307
|
+
conn := newFakeConn()
|
|
308
|
+
a, _ := testAgent(conn)
|
|
309
|
+
a.sessions, a.store = sessions, store
|
|
310
|
+
_, err := a.NewSession(newSessionReq(root))
|
|
311
|
+
want := -32603
|
|
312
|
+
if failure == "auth" {
|
|
313
|
+
want = -32000
|
|
314
|
+
}
|
|
315
|
+
if errCode(err) != want {
|
|
316
|
+
t.Fatalf("err = %v (code %d), want code %d", err, errCode(err), want)
|
|
317
|
+
}
|
|
318
|
+
if !reflect.DeepEqual(sessions.closeCalls, []string{"failed"}) {
|
|
319
|
+
t.Errorf("closeCalls = %v", sessions.closeCalls)
|
|
320
|
+
}
|
|
321
|
+
if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
|
|
322
|
+
t.Error("the failed session file was not removed")
|
|
323
|
+
}
|
|
324
|
+
if store.Get("failed") != nil {
|
|
325
|
+
t.Error("failed still in the store")
|
|
326
|
+
}
|
|
327
|
+
if _, err := os.Stat(existingFile); err != nil {
|
|
328
|
+
t.Error("the existing session file was removed")
|
|
329
|
+
}
|
|
330
|
+
if e := store.Get("existing"); e == nil || e.SessionFile != existingFile {
|
|
331
|
+
t.Errorf("existing = %+v", e)
|
|
332
|
+
}
|
|
333
|
+
if len(conn.all()) != 0 {
|
|
334
|
+
t.Error("updates sent for a failed session")
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestLoadConfigurationFailure(t *testing.T) {
|
|
342
|
+
for _, failure := range []string{"discovery", "missing-current", "inconsistent-current", "state"} {
|
|
343
|
+
tw(t, "unit/model-thinking-levels", "load configuration ${failure} failure closes restored child and preserves history for retry", func(t *testing.T) {
|
|
344
|
+
t.Run(fmt.Sprintf("load configuration %s failure closes restored child and preserves history for retry", failure), func(t *testing.T) {
|
|
345
|
+
root := t.TempDir()
|
|
346
|
+
sessionFile := filepath.Join(root, "history.jsonl")
|
|
347
|
+
write(t, sessionFile, "persisted history\n")
|
|
348
|
+
store := NewFileStore(filepath.Join(root, "map.json"))
|
|
349
|
+
store.Upsert(StoredSession{SessionID: "s1", Cwd: root, SessionFile: sessionFile})
|
|
350
|
+
f := newLevelsFixture(t)
|
|
351
|
+
conn := f.conn
|
|
352
|
+
sch := f.sch
|
|
353
|
+
mgr := NewSessionManager(nil, store)
|
|
354
|
+
f.agent.sessions, f.agent.store = mgr, store
|
|
355
|
+
existing := newFakeProc()
|
|
356
|
+
mgr.GetOrCreate("existing", SessionCreateParams{Cwd: root, McpServers: []any{}, Conn: conn, Proc: existing})
|
|
357
|
+
defer mgr.DisposeAll()
|
|
358
|
+
restoredDisposed, historyReads, spawns := 0, 0, 0
|
|
359
|
+
shouldFail := true
|
|
360
|
+
f.agent.spawn = func(SpawnParams) (Proc, error) {
|
|
361
|
+
spawns++
|
|
362
|
+
p := newFakeProc()
|
|
363
|
+
p.getModelsFn = f.proc.getModelsFn
|
|
364
|
+
p.getStateFn = func() (map[string]any, error) {
|
|
365
|
+
if shouldFail && failure == "state" {
|
|
366
|
+
return nil, errors.New("state unavailable")
|
|
367
|
+
}
|
|
368
|
+
if shouldFail && failure == "missing-current" {
|
|
369
|
+
return map[string]any{}, nil
|
|
370
|
+
}
|
|
371
|
+
if shouldFail && failure == "inconsistent-current" {
|
|
372
|
+
return map[string]any{"thinkingLevel": "medium"}, nil
|
|
373
|
+
}
|
|
374
|
+
return f.proc.GetState()
|
|
375
|
+
}
|
|
376
|
+
p.getLevelsFn = func() ([]string, error) {
|
|
377
|
+
if shouldFail && failure == "discovery" {
|
|
378
|
+
return nil, errors.New("discovery unavailable")
|
|
379
|
+
}
|
|
380
|
+
return f.proc.GetAvailableThinkingLevels()
|
|
381
|
+
}
|
|
382
|
+
p.getMessagesFn = func() (map[string]any, error) {
|
|
383
|
+
historyReads++
|
|
384
|
+
return map[string]any{"messages": []any{map[string]any{"role": "user", "content": "persisted prompt"}}}, nil
|
|
385
|
+
}
|
|
386
|
+
p.disposeHook = func() { restoredDisposed++ }
|
|
387
|
+
return p, nil
|
|
388
|
+
}
|
|
389
|
+
mgr.spawn = f.agent.spawn
|
|
390
|
+
if _, err := f.agent.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: root, McpServers: []any{}}); err == nil {
|
|
391
|
+
t.Fatal("expected the load to fail")
|
|
392
|
+
}
|
|
393
|
+
if restoredDisposed != 1 {
|
|
394
|
+
t.Errorf("restored child disposed %d times", restoredDisposed)
|
|
395
|
+
}
|
|
396
|
+
if mgr.MaybeGet("s1") != nil {
|
|
397
|
+
t.Error("s1 is still registered")
|
|
398
|
+
}
|
|
399
|
+
if mgr.MaybeGet("existing") == nil || existing.disposeCount() != 0 {
|
|
400
|
+
t.Error("the existing session was disturbed")
|
|
401
|
+
}
|
|
402
|
+
if historyReads != 0 || len(conn.all()) != 0 {
|
|
403
|
+
t.Errorf("historyReads=%d updates=%d", historyReads, len(conn.all()))
|
|
404
|
+
}
|
|
405
|
+
if b, _ := os.ReadFile(sessionFile); string(b) != "persisted history\n" {
|
|
406
|
+
t.Errorf("history file = %q", b)
|
|
407
|
+
}
|
|
408
|
+
if e := store.Get("s1"); e == nil || e.SessionFile != sessionFile {
|
|
409
|
+
t.Errorf("store entry = %+v", e)
|
|
410
|
+
}
|
|
411
|
+
shouldFail = false
|
|
412
|
+
r, err := f.agent.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: root, McpServers: []any{}})
|
|
413
|
+
if err != nil {
|
|
414
|
+
t.Fatal(err)
|
|
415
|
+
}
|
|
416
|
+
if r.Modes.CurrentModeID != "max" || spawns != 2 || restoredDisposed != 1 || historyReads != 1 {
|
|
417
|
+
t.Errorf("mode=%q spawns=%d disposed=%d reads=%d", r.Modes.CurrentModeID, spawns, restoredDisposed, historyReads)
|
|
418
|
+
}
|
|
419
|
+
if len(conn.ofKind("user_message_chunk")) == 0 {
|
|
420
|
+
t.Error("history was not replayed on the retry")
|
|
421
|
+
}
|
|
422
|
+
if b, _ := os.ReadFile(sessionFile); string(b) != "persisted history\n" {
|
|
423
|
+
t.Errorf("history file = %q", b)
|
|
424
|
+
}
|
|
425
|
+
sch.drain()
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"strings"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// PiSetupMethodID is the id of the terminal login method.
|
|
9
|
+
const PiSetupMethodID = "pi_terminal_login"
|
|
10
|
+
|
|
11
|
+
// TerminalLoginArgs are the arguments that make this executable start pig interactively. main
|
|
12
|
+
// adds --pig (and friends) so the login uses the same pig the adapter drives.
|
|
13
|
+
var TerminalLoginArgs = []string{"--terminal-login"}
|
|
14
|
+
|
|
15
|
+
// AuthMethods returns the advertised auth methods. Zed renders a banner from
|
|
16
|
+
// _meta["terminal-auth"]; the type/args/env shape is what registries require. Both are sent.
|
|
17
|
+
func AuthMethods(supportsTerminalAuthMeta bool) []map[string]any {
|
|
18
|
+
args := append([]any{}, anySlice(TerminalLoginArgs)...)
|
|
19
|
+
method := map[string]any{
|
|
20
|
+
"id": PiSetupMethodID,
|
|
21
|
+
"name": "Launch pig in the terminal",
|
|
22
|
+
"description": "Start pig in an interactive terminal to configure API keys or login",
|
|
23
|
+
"type": "terminal",
|
|
24
|
+
"args": args,
|
|
25
|
+
"env": map[string]any{},
|
|
26
|
+
}
|
|
27
|
+
if supportsTerminalAuthMeta {
|
|
28
|
+
cmd := "pig-acp"
|
|
29
|
+
if exe, err := os.Executable(); err == nil && exe != "" {
|
|
30
|
+
cmd = exe
|
|
31
|
+
}
|
|
32
|
+
method["_meta"] = map[string]any{"terminal-auth": map[string]any{
|
|
33
|
+
"command": cmd, "args": append([]any{}, anySlice(TerminalLoginArgs)...), "label": "Launch pig",
|
|
34
|
+
}}
|
|
35
|
+
}
|
|
36
|
+
return []map[string]any{method}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func anySlice(s []string) []any {
|
|
40
|
+
out := make([]any, len(s))
|
|
41
|
+
for i, v := range s {
|
|
42
|
+
out[i] = v
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
var authPatterns = []string{"api key", "apikey", "missing key", "no key", "not configured", "unauthorized", "authentication", "permission denied", "forbidden", "401", "403"}
|
|
48
|
+
|
|
49
|
+
// MaybeAuthRequiredError turns a missing-credentials message into an AUTH_REQUIRED error.
|
|
50
|
+
func MaybeAuthRequiredError(err error) *RequestError {
|
|
51
|
+
if err == nil {
|
|
52
|
+
return nil
|
|
53
|
+
}
|
|
54
|
+
s := strings.ToLower(err.Error())
|
|
55
|
+
for _, p := range authPatterns {
|
|
56
|
+
if strings.Contains(s, p) {
|
|
57
|
+
return ErrAuthRequired(map[string]any{"authMethods": AuthMethods(true)}, "Configure an API key or log in with an OAuth provider.")
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return nil
|
|
61
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strconv"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// pig-acp translates pi's events into ACP updates. Pi sends the whole accumulated bash output with every update,
|
|
10
|
+
// so the translation is O(output) per update by the input's own shape; this measures that cost, per update.
|
|
11
|
+
//
|
|
12
|
+
// go test -run xxx -bench . -benchmem
|
|
13
|
+
func BenchmarkBashOutputUpdate(b *testing.B) {
|
|
14
|
+
for _, size := range []int{4 << 10, 256 << 10} {
|
|
15
|
+
prev := strings.Repeat("compiling package line of output\n", size/33)
|
|
16
|
+
result := map[string]any{"content": []any{map[string]any{"type": "text", "text": prev + "one more line\n"}}}
|
|
17
|
+
b.Run(strconv.Itoa(size>>10)+"KiB", func(b *testing.B) {
|
|
18
|
+
b.SetBytes(int64(size))
|
|
19
|
+
b.ReportAllocs()
|
|
20
|
+
for i := 0; i < b.N; i++ {
|
|
21
|
+
text := BashResultText(result)
|
|
22
|
+
delta := BashOutputDelta(prev, text)
|
|
23
|
+
_ = BashTerminalOutputMeta("call1", delta)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func BenchmarkPromptToPiMessage(b *testing.B) {
|
|
30
|
+
blocks := []ContentBlock{
|
|
31
|
+
{"type": "text", "text": "Please look at this file and fix the failing test."},
|
|
32
|
+
{"type": "resource_link", "uri": "file:///work/repo/internal/module/file.go", "name": "file.go"},
|
|
33
|
+
{"type": "text", "text": strings.Repeat("More context about the change. ", 40)},
|
|
34
|
+
}
|
|
35
|
+
b.ReportAllocs()
|
|
36
|
+
for i := 0; i < b.N; i++ {
|
|
37
|
+
PromptToPiMessage(blocks)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func BenchmarkToolResultToText(b *testing.B) {
|
|
42
|
+
result := map[string]any{"content": []any{map[string]any{"type": "text", "text": strings.Repeat("file contents line\n", 200)}}, "details": map[string]any{"exitCode": 0}}
|
|
43
|
+
b.ReportAllocs()
|
|
44
|
+
for i := 0; i < b.N; i++ {
|
|
45
|
+
ToolResultToText(result)
|
|
46
|
+
}
|
|
47
|
+
}
|