@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,466 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
// Layer-2 cases for the adapter's own storage, PiG directory resolution, settings, prompt
|
|
4
|
+
// template loading, auth-required detection, bash helpers and the startup info block.
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"errors"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"reflect"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
func TestFileStore(t *testing.T) {
|
|
16
|
+
t.Run("stores, refreshes and deletes entries in a version 1 JSON file", func(t *testing.T) {
|
|
17
|
+
path := filepath.Join(t.TempDir(), "deep", "session-map.json")
|
|
18
|
+
s := NewFileStore(path)
|
|
19
|
+
s.Upsert(StoredSession{SessionID: "a", Cwd: "/w", SessionFile: "/f1"})
|
|
20
|
+
s.Upsert(StoredSession{SessionID: "a", Cwd: "/w", SessionFile: "/f2"})
|
|
21
|
+
s.Upsert(StoredSession{SessionID: "b", Cwd: "/w", SessionFile: "/g"})
|
|
22
|
+
got := s.Get("a")
|
|
23
|
+
if got == nil || got.SessionFile != "/f2" || got.UpdatedAt == "" {
|
|
24
|
+
t.Fatalf("a = %+v", got)
|
|
25
|
+
}
|
|
26
|
+
raw, _ := os.ReadFile(path)
|
|
27
|
+
if !strings.Contains(string(raw), `"version": 1`) || !strings.HasSuffix(string(raw), "\n") {
|
|
28
|
+
t.Errorf("file = %s", raw)
|
|
29
|
+
}
|
|
30
|
+
s.Delete("a")
|
|
31
|
+
s.Delete("never-there")
|
|
32
|
+
if s.Get("a") != nil || s.Get("b") == nil {
|
|
33
|
+
t.Error("delete removed the wrong entry")
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
t.Run("a missing, corrupt or wrong-version file reads as empty", func(t *testing.T) {
|
|
37
|
+
dir := t.TempDir()
|
|
38
|
+
for name, body := range map[string]string{"corrupt": "{nope", "wrong": `{"version":2,"sessions":{"a":{"sessionId":"a"}}}`, "null": `{"version":1,"sessions":null}`} {
|
|
39
|
+
p := filepath.Join(dir, name+".json")
|
|
40
|
+
write(t, p, body)
|
|
41
|
+
if NewFileStore(p).Get("a") != nil {
|
|
42
|
+
t.Errorf("%s: entry found", name)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if NewFileStore(filepath.Join(dir, "absent.json")).Get("a") != nil {
|
|
46
|
+
t.Error("absent: entry found")
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
t.Run("the default location is under PIG_HOME, not ~/.pi", func(t *testing.T) {
|
|
50
|
+
home := t.TempDir()
|
|
51
|
+
t.Setenv("PIG_HOME", home)
|
|
52
|
+
if got, want := SessionMapPath(), filepath.Join(home, "pig-acp", "session-map.json"); got != want {
|
|
53
|
+
t.Errorf("path = %q, want %q", got, want)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestPigDirectories(t *testing.T) {
|
|
59
|
+
clear := func(t *testing.T) {
|
|
60
|
+
for _, k := range []string{"PIG_HOME", "PIG_CODING_AGENT_DIR", "PI_CODING_AGENT_DIR", "PIG_USE_PI_DIRS", "XDG_CONFIG_HOME", "PIG_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_SESSION_DIR"} {
|
|
61
|
+
t.Setenv(k, "")
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Found by review. PiG keeps its own state under its config root (PIG_HOME, else
|
|
65
|
+
// XDG_CONFIG_HOME/pig, else ~/.pig) even when PIG_USE_PI_DIRS=1 shares the agent and project
|
|
66
|
+
// directories with Pi (internal/codingagent/paths.go ConfigRoot); the adapter's session map is
|
|
67
|
+
// PiG state, so shared mode must not write it into ~/.pi.
|
|
68
|
+
t.Run("the session map stays under PiG's config root in shared mode", func(t *testing.T) {
|
|
69
|
+
clear(t)
|
|
70
|
+
t.Setenv("HOME", "/home/u")
|
|
71
|
+
t.Setenv("PIG_USE_PI_DIRS", "1")
|
|
72
|
+
if got := SessionMapPath(); got != "/home/u/.pig/pig-acp/session-map.json" {
|
|
73
|
+
t.Errorf("shared mode: session map = %q", got)
|
|
74
|
+
}
|
|
75
|
+
t.Setenv("XDG_CONFIG_HOME", "/x")
|
|
76
|
+
if got := SessionMapPath(); got != "/x/pig/pig-acp/session-map.json" {
|
|
77
|
+
t.Errorf("shared mode with XDG_CONFIG_HOME: session map = %q", got)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
// pig expands a leading ~ in PIG_HOME, the agent directory variables and the session directory
|
|
81
|
+
// variable (ExpandTildePath). An editor passes env values unexpanded, so the adapter must expand
|
|
82
|
+
// them the same way or it looks for sessions somewhere pig never wrote them.
|
|
83
|
+
t.Run("a leading ~ in the directory variables is the home directory, as pig reads it", func(t *testing.T) {
|
|
84
|
+
clear(t)
|
|
85
|
+
t.Setenv("HOME", "/home/u")
|
|
86
|
+
t.Setenv("PIG_HOME", "~/ph")
|
|
87
|
+
if got := SessionMapPath(); got != "/home/u/ph/pig-acp/session-map.json" {
|
|
88
|
+
t.Errorf("PIG_HOME=~/ph: session map = %q", got)
|
|
89
|
+
}
|
|
90
|
+
if got := AgentDir(); got != "/home/u/ph/agent" {
|
|
91
|
+
t.Errorf("PIG_HOME=~/ph: AgentDir = %q", got)
|
|
92
|
+
}
|
|
93
|
+
t.Setenv("PIG_CODING_AGENT_DIR", "~/a")
|
|
94
|
+
if got := AgentDir(); got != "/home/u/a" {
|
|
95
|
+
t.Errorf("PIG_CODING_AGENT_DIR=~/a: AgentDir = %q", got)
|
|
96
|
+
}
|
|
97
|
+
t.Setenv("PIG_CODING_AGENT_SESSION_DIR", "~/s")
|
|
98
|
+
if got := PiSessionsDir(); got != "/home/u/s" {
|
|
99
|
+
t.Errorf("PIG_CODING_AGENT_SESSION_DIR=~/s: PiSessionsDir = %q", got)
|
|
100
|
+
}
|
|
101
|
+
t.Setenv("PIG_USE_PI_DIRS", "1")
|
|
102
|
+
t.Setenv("PI_CODING_AGENT_DIR", "~/pa")
|
|
103
|
+
if got := AgentDir(); got != "/home/u/pa" {
|
|
104
|
+
t.Errorf("shared, PI_CODING_AGENT_DIR=~/pa: AgentDir = %q", got)
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
// In shared mode pig reads PI_CODING_AGENT_SESSION_DIR, not PiG's variable (cmd/pig/main.go
|
|
108
|
+
// resolveSessionDir).
|
|
109
|
+
t.Run("shared mode reads PI_CODING_AGENT_SESSION_DIR", func(t *testing.T) {
|
|
110
|
+
clear(t)
|
|
111
|
+
t.Setenv("HOME", "/home/u")
|
|
112
|
+
t.Setenv("PIG_USE_PI_DIRS", "1")
|
|
113
|
+
t.Setenv("PIG_CODING_AGENT_SESSION_DIR", "/pig-sessions")
|
|
114
|
+
t.Setenv("PI_CODING_AGENT_SESSION_DIR", "/pi-sessions")
|
|
115
|
+
if got := PiSessionsDir(); got != "/pi-sessions" {
|
|
116
|
+
t.Errorf("PiSessionsDir = %q", got)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
t.Run("default mode: PIG_CODING_AGENT_DIR wins, then PIG_HOME/agent", func(t *testing.T) {
|
|
120
|
+
clear(t)
|
|
121
|
+
t.Setenv("PIG_HOME", "/h")
|
|
122
|
+
if AgentDir() != "/h/agent" {
|
|
123
|
+
t.Errorf("AgentDir = %q", AgentDir())
|
|
124
|
+
}
|
|
125
|
+
t.Setenv("PIG_CODING_AGENT_DIR", "/a")
|
|
126
|
+
if AgentDir() != "/a" {
|
|
127
|
+
t.Errorf("AgentDir = %q", AgentDir())
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
t.Run("default mode without PIG_HOME uses XDG_CONFIG_HOME/pig, then ~/.pig", func(t *testing.T) {
|
|
131
|
+
clear(t)
|
|
132
|
+
t.Setenv("XDG_CONFIG_HOME", "/x")
|
|
133
|
+
if AgentDir() != "/x/pig/agent" {
|
|
134
|
+
t.Errorf("AgentDir = %q", AgentDir())
|
|
135
|
+
}
|
|
136
|
+
t.Setenv("XDG_CONFIG_HOME", "")
|
|
137
|
+
t.Setenv("HOME", "/home/u")
|
|
138
|
+
if AgentDir() != "/home/u/.pig/agent" {
|
|
139
|
+
t.Errorf("AgentDir = %q", AgentDir())
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
t.Run("PIG_USE_PI_DIRS=1 selects Pi's directories and project .pi", func(t *testing.T) {
|
|
143
|
+
clear(t)
|
|
144
|
+
t.Setenv("PIG_USE_PI_DIRS", "1")
|
|
145
|
+
t.Setenv("HOME", "/home/u")
|
|
146
|
+
t.Setenv("PIG_CODING_AGENT_DIR", "/ignored")
|
|
147
|
+
if AgentDir() != "/home/u/.pi/agent" || ProjectDirName() != ".pi" {
|
|
148
|
+
t.Errorf("AgentDir=%q project=%q", AgentDir(), ProjectDirName())
|
|
149
|
+
}
|
|
150
|
+
t.Setenv("PI_CODING_AGENT_DIR", "/pi")
|
|
151
|
+
if AgentDir() != "/pi" {
|
|
152
|
+
t.Errorf("AgentDir = %q", AgentDir())
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
t.Run("default project directory is .pig; only the exact value 1 selects shared mode", func(t *testing.T) {
|
|
156
|
+
clear(t)
|
|
157
|
+
if ProjectDirName() != ".pig" {
|
|
158
|
+
t.Errorf("project = %q", ProjectDirName())
|
|
159
|
+
}
|
|
160
|
+
t.Setenv("PIG_USE_PI_DIRS", "true")
|
|
161
|
+
if ProjectDirName() != ".pig" {
|
|
162
|
+
t.Errorf("project = %q for PIG_USE_PI_DIRS=true", ProjectDirName())
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
t.Run("PiSessionsDir is <agent>/sessions unless settings.json says otherwise", func(t *testing.T) {
|
|
166
|
+
dir := t.TempDir()
|
|
167
|
+
withAgentDir(t, dir)
|
|
168
|
+
if PiSessionsDir() != filepath.Join(dir, "sessions") {
|
|
169
|
+
t.Errorf("dir = %q", PiSessionsDir())
|
|
170
|
+
}
|
|
171
|
+
write(t, filepath.Join(dir, "settings.json"), `{"sessionDir":"rel/sessions"}`)
|
|
172
|
+
if PiSessionsDir() != filepath.Join(dir, "rel", "sessions") {
|
|
173
|
+
t.Errorf("relative sessionDir: %q", PiSessionsDir())
|
|
174
|
+
}
|
|
175
|
+
write(t, filepath.Join(dir, "settings.json"), `{"sessionDir":" "}`)
|
|
176
|
+
if PiSessionsDir() != filepath.Join(dir, "sessions") {
|
|
177
|
+
t.Errorf("blank sessionDir: %q", PiSessionsDir())
|
|
178
|
+
}
|
|
179
|
+
t.Setenv("PIG_CODING_AGENT_SESSION_DIR", "/env/sessions")
|
|
180
|
+
if PiSessionsDir() != "/env/sessions" {
|
|
181
|
+
t.Errorf("env sessions dir: %q", PiSessionsDir())
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
func TestSettings(t *testing.T) {
|
|
187
|
+
t.Run("project settings override global, objects merge deeply", func(t *testing.T) {
|
|
188
|
+
agent, project := t.TempDir(), t.TempDir()
|
|
189
|
+
withAgentDir(t, agent)
|
|
190
|
+
write(t, filepath.Join(agent, "settings.json"), `{"quietStartup":false,"skills":{"enableSkillCommands":true}}`)
|
|
191
|
+
write(t, filepath.Join(project, ".pig", "settings.json"), `{"quietStartup":true,"skills":{"other":1}}`)
|
|
192
|
+
if !GetQuietStartup(project) {
|
|
193
|
+
t.Error("project quietStartup did not win")
|
|
194
|
+
}
|
|
195
|
+
if !GetEnableSkillCommands(project) {
|
|
196
|
+
t.Error("the nested global enableSkillCommands was lost in the merge")
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
t.Run("enableSkillCommands: direct, nested, default true", func(t *testing.T) {
|
|
200
|
+
agent := t.TempDir()
|
|
201
|
+
withAgentDir(t, agent)
|
|
202
|
+
if !GetEnableSkillCommands("/nowhere") {
|
|
203
|
+
t.Error("default should be true")
|
|
204
|
+
}
|
|
205
|
+
write(t, filepath.Join(agent, "settings.json"), `{"skills":{"enableSkillCommands":false}}`)
|
|
206
|
+
if GetEnableSkillCommands("/nowhere") {
|
|
207
|
+
t.Error("nested false ignored")
|
|
208
|
+
}
|
|
209
|
+
write(t, filepath.Join(agent, "settings.json"), `{"enableSkillCommands":true,"skills":{"enableSkillCommands":false}}`)
|
|
210
|
+
if !GetEnableSkillCommands("/nowhere") {
|
|
211
|
+
t.Error("direct value must win")
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
t.Run("quietStartup: direct, legacy quietStart, default false, junk ignored", func(t *testing.T) {
|
|
215
|
+
agent := t.TempDir()
|
|
216
|
+
withAgentDir(t, agent)
|
|
217
|
+
if GetQuietStartup("/nowhere") {
|
|
218
|
+
t.Error("default should be false")
|
|
219
|
+
}
|
|
220
|
+
write(t, filepath.Join(agent, "settings.json"), `{"quietStart":true}`)
|
|
221
|
+
if !GetQuietStartup("/nowhere") {
|
|
222
|
+
t.Error("legacy quietStart ignored")
|
|
223
|
+
}
|
|
224
|
+
write(t, filepath.Join(agent, "settings.json"), `not json`)
|
|
225
|
+
if GetQuietStartup("/nowhere") {
|
|
226
|
+
t.Error("a corrupt settings file must read as empty")
|
|
227
|
+
}
|
|
228
|
+
write(t, filepath.Join(agent, "settings.json"), `["array"]`)
|
|
229
|
+
if GetQuietStartup("/nowhere") {
|
|
230
|
+
t.Error("a non-object settings file must read as empty")
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
t.Run("shared mode reads .pi/settings.json in the project", func(t *testing.T) {
|
|
234
|
+
agent, project := t.TempDir(), t.TempDir()
|
|
235
|
+
t.Setenv("PIG_USE_PI_DIRS", "1")
|
|
236
|
+
t.Setenv("PI_CODING_AGENT_DIR", agent)
|
|
237
|
+
write(t, filepath.Join(project, ".pi", "settings.json"), `{"quietStartup":true}`)
|
|
238
|
+
if !GetQuietStartup(project) {
|
|
239
|
+
t.Error(".pi/settings.json ignored in shared mode")
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func TestLoadSlashCommands(t *testing.T) {
|
|
245
|
+
t.Run("loads user then project templates with frontmatter, subdirectories and fallback descriptions", func(t *testing.T) {
|
|
246
|
+
agent, project := t.TempDir(), t.TempDir()
|
|
247
|
+
withAgentDir(t, agent)
|
|
248
|
+
write(t, filepath.Join(agent, "prompts", "review.md"), "---\ndescription: Review code\n---\nReview $1\n")
|
|
249
|
+
write(t, filepath.Join(agent, "prompts", "nodesc.md"), "\n\nFirst line of the body that is definitely longer than sixty characters in total\nmore\n")
|
|
250
|
+
write(t, filepath.Join(agent, "prompts", "ignored.txt"), "x")
|
|
251
|
+
write(t, filepath.Join(project, ".pig", "prompts", "team", "deploy.md"), "Deploy now\n")
|
|
252
|
+
got := LoadSlashCommands(project)
|
|
253
|
+
var names, descs []string
|
|
254
|
+
for _, c := range got {
|
|
255
|
+
names = append(names, c.Name)
|
|
256
|
+
descs = append(descs, c.Description)
|
|
257
|
+
}
|
|
258
|
+
if !reflect.DeepEqual(names, []string{"nodesc", "review", "deploy"}) && !reflect.DeepEqual(names, []string{"review", "nodesc", "deploy"}) {
|
|
259
|
+
t.Fatalf("names = %v", names)
|
|
260
|
+
}
|
|
261
|
+
byName := map[string]FileSlashCommand{}
|
|
262
|
+
for _, c := range got {
|
|
263
|
+
byName[c.Name] = c
|
|
264
|
+
}
|
|
265
|
+
if c := byName["review"]; c.Description != "Review code (user)" || c.Content != "Review $1" || c.Source != "(user)" {
|
|
266
|
+
t.Errorf("review = %+v", c)
|
|
267
|
+
}
|
|
268
|
+
if c := byName["deploy"]; c.Description != "Deploy now (project:team)" || c.Source != "(project:team)" {
|
|
269
|
+
t.Errorf("deploy = %+v", c)
|
|
270
|
+
}
|
|
271
|
+
if c := byName["nodesc"]; !strings.HasPrefix(c.Description, "First line of the body that is definitely longer than sixty ...") || !strings.HasSuffix(c.Description, " (user)") {
|
|
272
|
+
t.Errorf("nodesc = %q", c.Description)
|
|
273
|
+
}
|
|
274
|
+
if got[len(got)-1].Name != "deploy" {
|
|
275
|
+
t.Errorf("project commands must come after user commands: %v", descs)
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
t.Run("a missing directory yields no commands", func(t *testing.T) {
|
|
279
|
+
withAgentDir(t, t.TempDir())
|
|
280
|
+
if got := LoadSlashCommands(t.TempDir()); len(got) != 0 {
|
|
281
|
+
t.Errorf("got %+v", got)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func TestAuthRequiredError(t *testing.T) {
|
|
287
|
+
for _, msg := range []string{"No API key found", "apikey missing", "missing key for x", "no key", "provider not configured", "Unauthorized", "authentication failed", "permission denied", "Forbidden", "HTTP 401", "status 403"} {
|
|
288
|
+
t.Run("detects "+msg, func(t *testing.T) {
|
|
289
|
+
re := MaybeAuthRequiredError(errors.New(msg))
|
|
290
|
+
if re == nil || re.Code != -32000 || !strings.Contains(re.Message, "Configure an API key or log in with an OAuth provider.") {
|
|
291
|
+
t.Fatalf("re = %+v", re)
|
|
292
|
+
}
|
|
293
|
+
methods := re.Data.(map[string]any)["authMethods"].([]map[string]any)
|
|
294
|
+
if len(methods) != 1 || methods[0]["id"] != PiSetupMethodID {
|
|
295
|
+
t.Errorf("data = %v", re.Data)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
t.Run("ignores other errors and nil", func(t *testing.T) {
|
|
300
|
+
if MaybeAuthRequiredError(errors.New("socket hang up")) != nil || MaybeAuthRequiredError(nil) != nil {
|
|
301
|
+
t.Error("false positive")
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
func TestBashHelpers(t *testing.T) {
|
|
307
|
+
t.Run("BashCommand searches the nested shapes and rejects blank commands", func(t *testing.T) {
|
|
308
|
+
for _, in := range []any{
|
|
309
|
+
map[string]any{"command": "ls"}, map[string]any{"cmd": "ls"}, map[string]any{"args": map[string]any{"command": "ls"}},
|
|
310
|
+
map[string]any{"input": map[string]any{"cmd": "ls"}}, map[string]any{"rawInput": map[string]any{"command": "ls"}},
|
|
311
|
+
map[string]any{"toolInput": map[string]any{"command": "ls"}}, map[string]any{"details": map[string]any{"command": "ls"}}} {
|
|
312
|
+
if c, ok := BashCommand(in); !ok || c != "ls" {
|
|
313
|
+
t.Errorf("%v: %q %v", in, c, ok)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for _, in := range []any{nil, map[string]any{}, map[string]any{"command": " "}, map[string]any{"command": 5}} {
|
|
317
|
+
if _, ok := BashCommand(in); ok {
|
|
318
|
+
t.Errorf("%v: found a command", in)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
t.Run("BashResultText prefers content, then details/stdout/output joined with stderr", func(t *testing.T) {
|
|
323
|
+
if got := BashResultText(map[string]any{"content": []any{map[string]any{"type": "text", "text": "a"}, map[string]any{"type": "text", "text": "b"}}}); got != "ab" {
|
|
324
|
+
t.Errorf("content: %q", got)
|
|
325
|
+
}
|
|
326
|
+
if got := BashResultText(map[string]any{"details": map[string]any{"stdout": "out", "stderr": "err"}}); got != "out\nerr" {
|
|
327
|
+
t.Errorf("details: %q", got)
|
|
328
|
+
}
|
|
329
|
+
if got := BashResultText(map[string]any{"output": "o"}); got != "o" {
|
|
330
|
+
t.Errorf("output: %q", got)
|
|
331
|
+
}
|
|
332
|
+
if got := BashResultText(nil); got != "" {
|
|
333
|
+
t.Errorf("nil: %q", got)
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
t.Run("BashExitCode reads details.exitCode, exitCode, code, else 0 or 1", func(t *testing.T) {
|
|
337
|
+
cases := []struct {
|
|
338
|
+
in any
|
|
339
|
+
isError bool
|
|
340
|
+
want int
|
|
341
|
+
}{
|
|
342
|
+
{map[string]any{"details": map[string]any{"exitCode": 3}}, false, 3},
|
|
343
|
+
{map[string]any{"exitCode": 4}, false, 4},
|
|
344
|
+
{map[string]any{"code": 5}, false, 5},
|
|
345
|
+
{map[string]any{}, false, 0},
|
|
346
|
+
{map[string]any{}, true, 1},
|
|
347
|
+
{nil, true, 1},
|
|
348
|
+
}
|
|
349
|
+
for _, c := range cases {
|
|
350
|
+
if got := BashExitCode(c.in, c.isError); got != c.want {
|
|
351
|
+
t.Errorf("%v: got %d want %d", c.in, got, c.want)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
t.Run("BashOutputDelta appends, or resends when the output was replaced", func(t *testing.T) {
|
|
356
|
+
if BashOutputDelta("ab", "abcd") != "cd" || BashOutputDelta("ab", "xy") != "xy" || BashOutputDelta("", "z") != "z" {
|
|
357
|
+
t.Error("delta")
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
t.Run("IsBashTool is case-insensitive", func(t *testing.T) {
|
|
361
|
+
if !IsBashTool("Bash") || IsBashTool("bashful") {
|
|
362
|
+
t.Error("IsBashTool")
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
func TestToolResultTextEdges(t *testing.T) {
|
|
368
|
+
t.Run("an empty result is empty text", func(t *testing.T) {
|
|
369
|
+
if ToolResultToText(nil) != "" {
|
|
370
|
+
t.Error("nil")
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
t.Run("stdout, stderr and exit code from the top level", func(t *testing.T) {
|
|
374
|
+
got := ToolResultToText(map[string]any{"stdout": "o", "exitCode": 2})
|
|
375
|
+
if got != "o\n\nexit code: 2" {
|
|
376
|
+
t.Errorf("got %q", got)
|
|
377
|
+
}
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func TestBuildStartupInfo(t *testing.T) {
|
|
382
|
+
t.Run("lists context, skills, prompts and extensions", func(t *testing.T) {
|
|
383
|
+
agent, project := t.TempDir(), t.TempDir()
|
|
384
|
+
withAgentDir(t, agent)
|
|
385
|
+
home := t.TempDir()
|
|
386
|
+
t.Setenv("HOME", home)
|
|
387
|
+
write(t, filepath.Join(project, "AGENTS.md"), "x")
|
|
388
|
+
write(t, filepath.Join(agent, "skills", "one", "SKILL.md"), "x")
|
|
389
|
+
write(t, filepath.Join(agent, "skills", "flat.md"), "x")
|
|
390
|
+
write(t, filepath.Join(project, ".pig", "skills", "two", "SKILL.md"), "x")
|
|
391
|
+
write(t, filepath.Join(home, ".agents", "skills", "three", "SKILL.md"), "x")
|
|
392
|
+
write(t, filepath.Join(agent, "prompts", "review.md"), "x")
|
|
393
|
+
write(t, filepath.Join(agent, "extensions", "mine.ts"), "x")
|
|
394
|
+
write(t, filepath.Join(agent, "settings.json"), `{"packages":["npm:pkg","/local/pkg"]}`)
|
|
395
|
+
got := BuildStartupInfo(project, "no-such-pig-binary")
|
|
396
|
+
for _, want := range []string{
|
|
397
|
+
"## Context\n- " + filepath.Join(project, "AGENTS.md"),
|
|
398
|
+
"## Skills", filepath.Join(agent, "skills", "one", "SKILL.md"), filepath.Join(agent, "skills", "flat.md"),
|
|
399
|
+
filepath.Join(project, ".pig", "skills", "two", "SKILL.md"), filepath.Join(home, ".agents", "skills", "three", "SKILL.md"),
|
|
400
|
+
"## Prompts\n- /review", "## Extensions", filepath.Join(agent, "extensions", "mine.ts"), "- npm:pkg\n - index.ts", "- /local/pkg",
|
|
401
|
+
} {
|
|
402
|
+
if !strings.Contains(got, want) {
|
|
403
|
+
t.Errorf("startup info lacks %q:\n%s", want, got)
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if strings.Contains(got, "node_modules") {
|
|
407
|
+
t.Error("noise directories must be skipped")
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
t.Run("is a single newline when there is nothing to report", func(t *testing.T) {
|
|
411
|
+
withAgentDir(t, t.TempDir())
|
|
412
|
+
t.Setenv("HOME", t.TempDir())
|
|
413
|
+
if got := BuildStartupInfo(t.TempDir(), "no-such-pig-binary"); got != "\n" {
|
|
414
|
+
t.Errorf("got %q", got)
|
|
415
|
+
}
|
|
416
|
+
})
|
|
417
|
+
t.Run("starts with the pig version header when the executable answers --version", func(t *testing.T) {
|
|
418
|
+
withAgentDir(t, t.TempDir())
|
|
419
|
+
t.Setenv("HOME", t.TempDir())
|
|
420
|
+
script := filepath.Join(t.TempDir(), "fakepig")
|
|
421
|
+
write(t, script, "#!/bin/sh\necho v1.2.3\n")
|
|
422
|
+
if err := os.Chmod(script, 0o755); err != nil {
|
|
423
|
+
t.Fatal(err)
|
|
424
|
+
}
|
|
425
|
+
if got := BuildStartupInfo(t.TempDir(), script); !strings.HasPrefix(got, "pig v1.2.3\n---\n") {
|
|
426
|
+
t.Errorf("got %q", got)
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
func TestSessionEditHelpers(t *testing.T) {
|
|
432
|
+
t.Run("findUniqueLineNumber: unique match gives its line, duplicates and misses give none", func(t *testing.T) {
|
|
433
|
+
if n, ok := findUniqueLineNumber("a\nb\nneedle\n", "needle"); !ok || n != 3 {
|
|
434
|
+
t.Errorf("unique: %d %v", n, ok)
|
|
435
|
+
}
|
|
436
|
+
if _, ok := findUniqueLineNumber("needle needle", "needle"); ok {
|
|
437
|
+
t.Error("duplicate matched")
|
|
438
|
+
}
|
|
439
|
+
if _, ok := findUniqueLineNumber("abc", "zzz"); ok {
|
|
440
|
+
t.Error("miss matched")
|
|
441
|
+
}
|
|
442
|
+
if _, ok := findUniqueLineNumber("abc", ""); ok {
|
|
443
|
+
t.Error("empty needle matched")
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
t.Run("tool paths accept path and file_path; relative paths resolve against the cwd", func(t *testing.T) {
|
|
447
|
+
if p, ok := getToolPath(map[string]any{"file_path": "x"}); !ok || p != "x" {
|
|
448
|
+
t.Errorf("file_path: %q %v", p, ok)
|
|
449
|
+
}
|
|
450
|
+
locs := toToolCallLocations(map[string]any{"path": "a/b"}, "/w", 7)
|
|
451
|
+
jsonEqual(t, locs, []any{map[string]any{"path": "/w/a/b", "line": 7}})
|
|
452
|
+
jsonEqual(t, toToolCallLocations(map[string]any{"path": "/abs"}, "/w", 0), []any{map[string]any{"path": "/abs"}})
|
|
453
|
+
if toToolCallLocations(map[string]any{}, "/w", 0) != nil {
|
|
454
|
+
t.Error("locations for a call without a path")
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
t.Run("edit args accept top-level oldText, an edits array and a stringified array, without duplicates", func(t *testing.T) {
|
|
458
|
+
olds := getEditOldTexts(map[string]any{"oldText": "a", "edits": []any{map[string]any{"oldText": "a"}, map[string]any{"oldText": "b"}}})
|
|
459
|
+
if !reflect.DeepEqual(olds, []string{"a", "b"}) {
|
|
460
|
+
t.Errorf("olds = %v", olds)
|
|
461
|
+
}
|
|
462
|
+
if got := getEditOldTexts(map[string]any{"edits": "not json"}); len(got) != 0 {
|
|
463
|
+
t.Errorf("got %v", got)
|
|
464
|
+
}
|
|
465
|
+
})
|
|
466
|
+
}
|