@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,536 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
// Layer-2 cases: every branch of the adapter-side slash commands in agent.ts that the original's
|
|
4
|
+
// tests do not cover (/compact, /session, /name failure, /export guards, /autocompact, /changelog).
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"errors"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func builtinAgent(t *testing.T, proc *fakeProc, cwd string) (*Agent, *fakeConn) {
|
|
15
|
+
conn := newFakeConn()
|
|
16
|
+
a, _ := testAgent(conn)
|
|
17
|
+
a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwd, proc: proc}, anyID: true}
|
|
18
|
+
return a, conn
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
func runCmd(t *testing.T, a *Agent, text string) PromptResponse {
|
|
22
|
+
t.Helper()
|
|
23
|
+
res, err := a.Prompt(promptText("s1", text))
|
|
24
|
+
if err != nil {
|
|
25
|
+
t.Fatalf("%s: %v", text, err)
|
|
26
|
+
}
|
|
27
|
+
return res
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func TestBuiltinCompact(t *testing.T) {
|
|
31
|
+
t.Run("/compact reports tokens before and the summary", func(t *testing.T) {
|
|
32
|
+
proc := newFakeProc()
|
|
33
|
+
proc.compactFn = func(string) (map[string]any, error) {
|
|
34
|
+
return map[string]any{"tokensBefore": 1234, "summary": "Short summary"}, nil
|
|
35
|
+
}
|
|
36
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
37
|
+
if res := runCmd(t, a, "/compact"); res.StopReason != StopEndTurn {
|
|
38
|
+
t.Fatalf("res = %v", res)
|
|
39
|
+
}
|
|
40
|
+
if got, want := lastChunkText(t, conn), "Compaction completed.\nTokens before: 1234\n\nShort summary"; got != want {
|
|
41
|
+
t.Errorf("text = %q, want %q", got, want)
|
|
42
|
+
}
|
|
43
|
+
if n := len(proc.promptList()); n != 0 {
|
|
44
|
+
t.Errorf("%d prompts reached pi", n)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
t.Run("/compact passes custom instructions and says so", func(t *testing.T) {
|
|
48
|
+
proc := newFakeProc()
|
|
49
|
+
var got string
|
|
50
|
+
proc.compactFn = func(instr string) (map[string]any, error) { got = instr; return map[string]any{}, nil }
|
|
51
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
52
|
+
runCmd(t, a, "/compact keep file names")
|
|
53
|
+
if got != "keep file names" {
|
|
54
|
+
t.Errorf("instructions = %q", got)
|
|
55
|
+
}
|
|
56
|
+
if text := lastChunkText(t, conn); text != "Compaction completed. (custom instructions applied)" {
|
|
57
|
+
t.Errorf("text = %q", text)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
t.Run("/compact failure is a prompt error, not a chunk", func(t *testing.T) {
|
|
61
|
+
proc := newFakeProc()
|
|
62
|
+
proc.compactFn = func(string) (map[string]any, error) { return nil, errors.New("pi compact failed: nothing to compact") }
|
|
63
|
+
a, _ := builtinAgent(t, proc, cwdNow(t))
|
|
64
|
+
if _, err := a.Prompt(promptText("s1", "/compact")); err == nil {
|
|
65
|
+
t.Fatal("expected an error")
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func TestBuiltinSession(t *testing.T) {
|
|
71
|
+
t.Run("/session lists id, file, messages, cost and tokens", func(t *testing.T) {
|
|
72
|
+
proc := newFakeProc()
|
|
73
|
+
proc.sessionStats = SessionStats{"sessionId": "sid", "sessionFile": "/x/s.jsonl", "totalMessages": 4, "cost": 0.5,
|
|
74
|
+
"tokens": map[string]any{"input": 1, "output": 2, "cacheRead": 3, "cacheWrite": 4, "total": 10}}
|
|
75
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
76
|
+
runCmd(t, a, "/session")
|
|
77
|
+
want := "Session: sid\nSession file: /x/s.jsonl\nMessages: 4\nCost: 0.5\nTokens: in 1, out 2, cache read 3, cache write 4, total 10"
|
|
78
|
+
if got := lastChunkText(t, conn); got != want {
|
|
79
|
+
t.Errorf("text = %q, want %q", got, want)
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
t.Run("/session falls back to the raw stats when the shape is unknown", func(t *testing.T) {
|
|
83
|
+
proc := newFakeProc()
|
|
84
|
+
proc.sessionStats = SessionStats{"weird": true}
|
|
85
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
86
|
+
runCmd(t, a, "/session")
|
|
87
|
+
if got := lastChunkText(t, conn); !strings.HasPrefix(got, "Session stats:\n{") || !strings.Contains(got, `"weird": true`) {
|
|
88
|
+
t.Errorf("text = %q", got)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func TestBuiltinName(t *testing.T) {
|
|
94
|
+
t.Run("/name without an argument prints usage and does not call pi", func(t *testing.T) {
|
|
95
|
+
proc := newFakeProc()
|
|
96
|
+
called := false
|
|
97
|
+
proc.setSessionNameFn = func(string) error { called = true; return nil }
|
|
98
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
99
|
+
runCmd(t, a, "/name")
|
|
100
|
+
if called || lastChunkText(t, conn) != "Usage: /name <name>" {
|
|
101
|
+
t.Errorf("called=%v text=%q", called, lastChunkText(t, conn))
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
t.Run("/name failure says why, with a hint when set_session_name is unsupported", func(t *testing.T) {
|
|
105
|
+
proc := newFakeProc()
|
|
106
|
+
proc.setSessionNameFn = func(string) error { return errors.New("pi set_session_name failed: unknown command") }
|
|
107
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
108
|
+
runCmd(t, a, "/name X")
|
|
109
|
+
got := lastChunkText(t, conn)
|
|
110
|
+
if !strings.HasPrefix(got, "Failed to set session name: pi set_session_name failed: unknown command") ||
|
|
111
|
+
!strings.Contains(got, "requires a newer pi version that supports `set_session_name`") {
|
|
112
|
+
t.Errorf("text = %q", got)
|
|
113
|
+
}
|
|
114
|
+
if n := len(conn.ofKind("session_info_update")); n != 0 {
|
|
115
|
+
t.Errorf("%d session_info_update after a failure", n)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
t.Run("/name with quotes keeps the words", func(t *testing.T) {
|
|
119
|
+
proc := newFakeProc()
|
|
120
|
+
var got string
|
|
121
|
+
proc.setSessionNameFn = func(n string) error { got = n; return nil }
|
|
122
|
+
a, _ := builtinAgent(t, proc, cwdNow(t))
|
|
123
|
+
runCmd(t, a, `/name "Fix the bug" now`)
|
|
124
|
+
if got != "Fix the bug now" {
|
|
125
|
+
t.Errorf("name = %q", got)
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestBuiltinAutocompact(t *testing.T) {
|
|
131
|
+
for in, want := range map[string]bool{"on": true, "true": true, "enable": true, "enabled": true, "off": false, "false": false, "disable": false, "disabled": false} {
|
|
132
|
+
t.Run("/autocompact "+in, func(t *testing.T) {
|
|
133
|
+
proc := newFakeProc()
|
|
134
|
+
var got *bool
|
|
135
|
+
proc.setAutoCompactFn = func(b bool) error { got = &b; return nil }
|
|
136
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
137
|
+
runCmd(t, a, "/autocompact "+in)
|
|
138
|
+
text := "Auto-compaction disabled."
|
|
139
|
+
if want {
|
|
140
|
+
text = "Auto-compaction enabled."
|
|
141
|
+
}
|
|
142
|
+
if got == nil || *got != want || lastChunkText(t, conn) != text {
|
|
143
|
+
t.Errorf("got=%v text=%q", got, lastChunkText(t, conn))
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
t.Run("/autocompact toggles the current state", func(t *testing.T) {
|
|
148
|
+
proc := newFakeProc()
|
|
149
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"autoCompactionEnabled": true}, nil }
|
|
150
|
+
var got *bool
|
|
151
|
+
proc.setAutoCompactFn = func(b bool) error { got = &b; return nil }
|
|
152
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
153
|
+
runCmd(t, a, "/autocompact")
|
|
154
|
+
if got == nil || *got || lastChunkText(t, conn) != "Auto-compaction disabled." {
|
|
155
|
+
t.Errorf("got=%v text=%q", got, lastChunkText(t, conn))
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func TestBuiltinExport(t *testing.T) {
|
|
161
|
+
notes := func(conn *fakeConn) string { return lastChunkText(t, conn) }
|
|
162
|
+
t.Run("/export with no messages says there is nothing to export", func(t *testing.T) {
|
|
163
|
+
proc := newFakeProc()
|
|
164
|
+
proc.getStateFn = func() (map[string]any, error) {
|
|
165
|
+
return map[string]any{"sessionFile": "/nope.jsonl", "messageCount": 0}, nil
|
|
166
|
+
}
|
|
167
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
168
|
+
runCmd(t, a, "/export")
|
|
169
|
+
if got := notes(conn); got != "Nothing to export yet (no session messages). Send a prompt first." {
|
|
170
|
+
t.Errorf("text = %q", got)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
t.Run("/export with an empty session file says so", func(t *testing.T) {
|
|
174
|
+
file := filepath.Join(t.TempDir(), "s.jsonl")
|
|
175
|
+
write(t, file, " \n")
|
|
176
|
+
proc := newFakeProc()
|
|
177
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"sessionFile": file, "messageCount": 2}, nil }
|
|
178
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
179
|
+
runCmd(t, a, "/export")
|
|
180
|
+
if got := notes(conn); got != "Nothing to export yet (empty session file). Send a prompt first." {
|
|
181
|
+
t.Errorf("text = %q", got)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
t.Run("/export writes into the session cwd and links the file", func(t *testing.T) {
|
|
185
|
+
dir := t.TempDir()
|
|
186
|
+
file := filepath.Join(dir, "s.jsonl")
|
|
187
|
+
write(t, file, "{}\n")
|
|
188
|
+
proc := newFakeProc()
|
|
189
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"sessionFile": file, "messageCount": 2}, nil }
|
|
190
|
+
var asked string
|
|
191
|
+
proc.exportHTMLFn = func(p string) (string, error) { asked = p; return p, nil }
|
|
192
|
+
a, conn := builtinAgent(t, proc, dir)
|
|
193
|
+
runCmd(t, a, "/export")
|
|
194
|
+
if want := filepath.Join(dir, "pi-session-s1.html"); asked != want {
|
|
195
|
+
t.Errorf("export path = %q, want %q", asked, want)
|
|
196
|
+
}
|
|
197
|
+
ups := conn.all()
|
|
198
|
+
if len(ups) != 2 {
|
|
199
|
+
t.Fatalf("%d updates", len(ups))
|
|
200
|
+
}
|
|
201
|
+
jsonEqual(t, ups[0].Update, chunk("Session exported: "))
|
|
202
|
+
jsonEqual(t, ups[1].Update, Update{"sessionUpdate": "agent_message_chunk", "content": map[string]any{
|
|
203
|
+
"type": "resource_link", "name": "pi-session-s1.html", "uri": "file://" + asked, "mimeType": "text/html", "title": "Session exported"}})
|
|
204
|
+
})
|
|
205
|
+
t.Run("/export failure and an empty returned path are reported", func(t *testing.T) {
|
|
206
|
+
dir := t.TempDir()
|
|
207
|
+
file := filepath.Join(dir, "s.jsonl")
|
|
208
|
+
write(t, file, "{}\n")
|
|
209
|
+
for _, tc := range []struct {
|
|
210
|
+
fn func(string) (string, error)
|
|
211
|
+
want string
|
|
212
|
+
}{
|
|
213
|
+
{func(string) (string, error) { return "", errors.New("disk full") }, "Export failed: disk full"},
|
|
214
|
+
{func(string) (string, error) { return "", nil }, "Export failed: no output path returned by pi."},
|
|
215
|
+
} {
|
|
216
|
+
proc := newFakeProc()
|
|
217
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"sessionFile": file, "messageCount": 2}, nil }
|
|
218
|
+
proc.exportHTMLFn = tc.fn
|
|
219
|
+
a, conn := builtinAgent(t, proc, dir)
|
|
220
|
+
runCmd(t, a, "/export")
|
|
221
|
+
if got := notes(conn); got != tc.want {
|
|
222
|
+
t.Errorf("text = %q, want %q", got, tc.want)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func TestBuiltinChangelog(t *testing.T) {
|
|
229
|
+
t.Run("/changelog says so when no installation is found", func(t *testing.T) {
|
|
230
|
+
proc := newFakeProc()
|
|
231
|
+
a, conn := builtinAgent(t, proc, cwdNow(t))
|
|
232
|
+
a.changelogPath = func() string { return "" }
|
|
233
|
+
runCmd(t, a, "/changelog")
|
|
234
|
+
if got := lastChunkText(t, conn); got != "Changelog not found (couldn't locate pig installation)." {
|
|
235
|
+
t.Errorf("text = %q", got)
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
t.Run("/changelog truncates a long file", func(t *testing.T) {
|
|
239
|
+
file := filepath.Join(t.TempDir(), "CHANGELOG.md")
|
|
240
|
+
write(t, file, strings.Repeat("a", 25000))
|
|
241
|
+
a, conn := builtinAgent(t, newFakeProc(), cwdNow(t))
|
|
242
|
+
a.changelogPath = func() string { return file }
|
|
243
|
+
runCmd(t, a, "/changelog")
|
|
244
|
+
got := lastChunkText(t, conn)
|
|
245
|
+
if !strings.HasSuffix(got, "\n\n...(truncated)...") || len(got) != 20000+len("\n\n...(truncated)...") {
|
|
246
|
+
t.Errorf("len = %d, tail = %q", len(got), got[len(got)-30:])
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
t.Run("/changelog finds a CHANGELOG.md next to the pig binary", func(t *testing.T) {
|
|
250
|
+
dir := t.TempDir()
|
|
251
|
+
bin := filepath.Join(dir, "bin", "pig")
|
|
252
|
+
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
|
253
|
+
t.Fatal(err)
|
|
254
|
+
}
|
|
255
|
+
write(t, bin, "")
|
|
256
|
+
write(t, filepath.Join(dir, "CHANGELOG.md"), "# Changes\n")
|
|
257
|
+
// FindChangelog resolves the binary's symlinks (as Pi does with realpath), so compare resolved paths:
|
|
258
|
+
// macOS's TMPDIR is a symlink (/var -> /private/var).
|
|
259
|
+
realDir, err := filepath.EvalSymlinks(dir)
|
|
260
|
+
if err != nil {
|
|
261
|
+
t.Fatal(err)
|
|
262
|
+
}
|
|
263
|
+
if got := FindChangelog(bin); got != filepath.Join(realDir, "CHANGELOG.md") {
|
|
264
|
+
t.Errorf("found %q, want %q", got, filepath.Join(realDir, "CHANGELOG.md"))
|
|
265
|
+
}
|
|
266
|
+
if got := FindChangelog(filepath.Join(dir, "elsewhere", "pig")); got != "" {
|
|
267
|
+
t.Errorf("found %q for a missing installation", got)
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestPromptRouting(t *testing.T) {
|
|
273
|
+
t.Run("a slash command with an image goes to pi as a prompt", func(t *testing.T) {
|
|
274
|
+
proc := newFakeProc()
|
|
275
|
+
sess := &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}
|
|
276
|
+
a, _ := testAgent(newFakeConn())
|
|
277
|
+
a.sessions = &fakeSessions{session: sess, anyID: true}
|
|
278
|
+
_, err := a.Prompt(PromptRequest{SessionID: "s1", Prompt: []ContentBlock{
|
|
279
|
+
{"type": "text", "text": "/steering"}, {"type": "image", "mimeType": "image/png", "data": "AA=="}}})
|
|
280
|
+
if err != nil {
|
|
281
|
+
t.Fatal(err)
|
|
282
|
+
}
|
|
283
|
+
if len(sess.prompts) != 1 || sess.prompts[0].Message != "/steering" || len(sess.prompts[0].Images) != 1 {
|
|
284
|
+
t.Errorf("prompts = %+v", sess.prompts)
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
t.Run("an unknown slash command goes to the session unchanged", func(t *testing.T) {
|
|
288
|
+
sess := &fakeSession{id: "s1", cwd: cwdNow(t), proc: newFakeProc()}
|
|
289
|
+
a, _ := testAgent(newFakeConn())
|
|
290
|
+
a.sessions = &fakeSessions{session: sess, anyID: true}
|
|
291
|
+
if _, err := a.Prompt(promptText("s1", "/unknown thing")); err != nil {
|
|
292
|
+
t.Fatal(err)
|
|
293
|
+
}
|
|
294
|
+
if len(sess.prompts) != 1 || sess.prompts[0].Message != "/unknown thing" {
|
|
295
|
+
t.Errorf("prompts = %+v", sess.prompts)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
t.Run("an error turn ends as end_turn, or cancelled when cancel was requested", func(t *testing.T) {
|
|
299
|
+
for _, cancelled := range []bool{false, true} {
|
|
300
|
+
sess := &fakeSession{id: "s1", cwd: cwdNow(t), proc: newFakeProc(), promptFn: func(string, []Image) TurnResult { return TurnResult{Reason: StopError} }}
|
|
301
|
+
cs := &cancelFlagSession{fakeSession: sess, cancelled: cancelled}
|
|
302
|
+
a, _ := testAgent(newFakeConn())
|
|
303
|
+
a.sessions = &fakeSessions{session: cs, anyID: true}
|
|
304
|
+
res, err := a.Prompt(promptText("s1", "hi"))
|
|
305
|
+
want := StopEndTurn
|
|
306
|
+
if cancelled {
|
|
307
|
+
want = StopCancelled
|
|
308
|
+
}
|
|
309
|
+
if err != nil || res.StopReason != want {
|
|
310
|
+
t.Errorf("cancelled=%v: res=%v err=%v", cancelled, res, err)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
t.Run("a turn that fails with an auth error surfaces AUTH_REQUIRED", func(t *testing.T) {
|
|
315
|
+
sess := &fakeSession{id: "s1", cwd: cwdNow(t), proc: newFakeProc(), promptFn: func(string, []Image) TurnResult {
|
|
316
|
+
return TurnResult{Err: ErrAuthRequired(map[string]any{"authMethods": AuthMethods(true)}, "Configure an API key or log in with an OAuth provider.")}
|
|
317
|
+
}}
|
|
318
|
+
a, _ := testAgent(newFakeConn())
|
|
319
|
+
a.sessions = &fakeSessions{session: sess, anyID: true}
|
|
320
|
+
_, err := a.Prompt(promptText("s1", "hi"))
|
|
321
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32000 {
|
|
322
|
+
t.Errorf("err = %v", err)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
type cancelFlagSession struct {
|
|
328
|
+
*fakeSession
|
|
329
|
+
cancelled bool
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func (c *cancelFlagSession) WasCancelRequested() bool { return c.cancelled }
|
|
333
|
+
|
|
334
|
+
func TestAgentCommandsAdvertised(t *testing.T) {
|
|
335
|
+
t.Run("session/new advertises pi commands merged with the built-ins, extension commands hidden", func(t *testing.T) {
|
|
336
|
+
conn := newFakeConn()
|
|
337
|
+
proc := newFakeProc()
|
|
338
|
+
proc.getStateFn = func() (map[string]any, error) {
|
|
339
|
+
return map[string]any{"thinkingLevel": "medium", "model": map[string]any{"provider": "test", "id": "model"}}, nil
|
|
340
|
+
}
|
|
341
|
+
proc.getCommandsFn = func() (map[string]any, error) {
|
|
342
|
+
return map[string]any{"commands": []any{
|
|
343
|
+
map[string]any{"name": "ext", "description": "E", "source": "extension"},
|
|
344
|
+
map[string]any{"name": "review", "description": "R", "source": "prompt"},
|
|
345
|
+
map[string]any{"name": "compact", "description": "dup", "source": "prompt"}}}, nil
|
|
346
|
+
}
|
|
347
|
+
a, sch := testAgent(conn)
|
|
348
|
+
a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
|
|
349
|
+
if _, err := a.NewSession(newSessionReq(cwdNow(t))); err != nil {
|
|
350
|
+
t.Fatal(err)
|
|
351
|
+
}
|
|
352
|
+
sch.drain()
|
|
353
|
+
ups := conn.ofKind("available_commands_update")
|
|
354
|
+
if len(ups) != 1 {
|
|
355
|
+
t.Fatalf("%d available_commands_update", len(ups))
|
|
356
|
+
}
|
|
357
|
+
var names []string
|
|
358
|
+
for _, c := range ups[0].Update["availableCommands"].([]AvailableCommand) {
|
|
359
|
+
names = append(names, c.Name)
|
|
360
|
+
}
|
|
361
|
+
want := "review,compact,autocompact,export,session,name,steering,follow-up,changelog"
|
|
362
|
+
if strings.Join(names, ",") != want {
|
|
363
|
+
t.Errorf("commands = %v", names)
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
t.Run("session/new falls back to the prompt template files when get_commands fails", func(t *testing.T) {
|
|
367
|
+
dir := t.TempDir()
|
|
368
|
+
write(t, filepath.Join(dir, "prompts", "hello.md"), "---\ndescription: Say hello\n---\nHello $1\n")
|
|
369
|
+
withAgentDir(t, dir)
|
|
370
|
+
conn := newFakeConn()
|
|
371
|
+
proc := newFakeProc() // get_commands is unsupported on the default fake
|
|
372
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
373
|
+
a, sch := testAgent(conn)
|
|
374
|
+
a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
|
|
375
|
+
if _, err := a.NewSession(newSessionReq(cwdNow(t))); err != nil {
|
|
376
|
+
t.Fatal(err)
|
|
377
|
+
}
|
|
378
|
+
sch.drain()
|
|
379
|
+
cmds := conn.ofKind("available_commands_update")[0].Update["availableCommands"].([]AvailableCommand)
|
|
380
|
+
if cmds[0].Name != "hello" || cmds[0].Description != "Say hello (user)" {
|
|
381
|
+
t.Errorf("commands = %+v", cmds)
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
t.Run("skill commands are hidden when enableSkillCommands is false", func(t *testing.T) {
|
|
385
|
+
dir := t.TempDir()
|
|
386
|
+
write(t, filepath.Join(dir, "settings.json"), `{"enableSkillCommands": false}`)
|
|
387
|
+
withAgentDir(t, dir)
|
|
388
|
+
conn := newFakeConn()
|
|
389
|
+
proc := newFakeProc()
|
|
390
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
391
|
+
proc.getCommandsFn = func() (map[string]any, error) {
|
|
392
|
+
return map[string]any{"commands": []any{map[string]any{"name": "skill:foo", "source": "skill"}, map[string]any{"name": "y", "source": "prompt"}}}, nil
|
|
393
|
+
}
|
|
394
|
+
a, sch := testAgent(conn)
|
|
395
|
+
a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
|
|
396
|
+
if _, err := a.NewSession(newSessionReq(cwdNow(t))); err != nil {
|
|
397
|
+
t.Fatal(err)
|
|
398
|
+
}
|
|
399
|
+
sch.drain()
|
|
400
|
+
for _, c := range conn.ofKind("available_commands_update")[0].Update["availableCommands"].([]AvailableCommand) {
|
|
401
|
+
if c.Name == "skill:foo" {
|
|
402
|
+
t.Error("skill command advertised")
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
func TestNewSessionPolicies(t *testing.T) {
|
|
409
|
+
t.Run("session/new rejects a relative cwd", func(t *testing.T) {
|
|
410
|
+
a, _ := testAgent(newFakeConn())
|
|
411
|
+
_, err := a.NewSession(NewSessionRequest{Cwd: "rel/dir"})
|
|
412
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32602 || !strings.Contains(re.Message, "cwd must be an absolute path: rel/dir") {
|
|
413
|
+
t.Errorf("err = %v", err)
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
t.Run("session/new keeps one live pi child per connection", func(t *testing.T) {
|
|
417
|
+
proc := newFakeProc()
|
|
418
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
419
|
+
sessions := &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
|
|
420
|
+
a, _ := testAgent(newFakeConn())
|
|
421
|
+
a.sessions = sessions
|
|
422
|
+
if _, err := a.NewSession(newSessionReq(cwdNow(t))); err != nil {
|
|
423
|
+
t.Fatal(err)
|
|
424
|
+
}
|
|
425
|
+
if len(sessions.closeOthers) != 1 || sessions.closeOthers[0] != "s1" {
|
|
426
|
+
t.Errorf("closeAllExcept = %v", sessions.closeOthers)
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
t.Run("session/load rejects a relative cwd and an unknown session", func(t *testing.T) {
|
|
430
|
+
a, _ := testAgent(newFakeConn())
|
|
431
|
+
a.sessions = NewSessionManager(nil, a.store)
|
|
432
|
+
_, err := a.LoadSession(LoadSessionRequest{SessionID: "x", Cwd: "rel"})
|
|
433
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32602 || !strings.Contains(re.Message, "cwd must be an absolute path: rel") {
|
|
434
|
+
t.Errorf("relative cwd: err = %v", err)
|
|
435
|
+
}
|
|
436
|
+
_, err = a.LoadSession(LoadSessionRequest{SessionID: "missing", Cwd: "/tmp/x"})
|
|
437
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32602 || !strings.Contains(re.Message, "Unknown sessionId: missing") {
|
|
438
|
+
t.Errorf("unknown session: err = %v", err)
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
t.Run("session/list pages by 50 with an opaque numeric cursor", func(t *testing.T) {
|
|
442
|
+
root := t.TempDir()
|
|
443
|
+
for i := 0; i < 120; i++ {
|
|
444
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--p--", jl(i)+".jsonl"),
|
|
445
|
+
sessionHeader("s-"+jl(i), "/cwd/p", "2026-01-01T00:00:00.000Z"))
|
|
446
|
+
}
|
|
447
|
+
withAgentDir(t, root)
|
|
448
|
+
a, _ := testAgent(newFakeConn())
|
|
449
|
+
p1, err := a.ListSessions(ListSessionsRequest{})
|
|
450
|
+
if err != nil || len(p1.Sessions) != 50 || p1.NextCursor == nil || *p1.NextCursor != "50" {
|
|
451
|
+
t.Fatalf("page 1: n=%d next=%v err=%v", len(p1.Sessions), p1.NextCursor, err)
|
|
452
|
+
}
|
|
453
|
+
p3, _ := a.ListSessions(ListSessionsRequest{Cursor: p1.NextCursor})
|
|
454
|
+
last := "100"
|
|
455
|
+
p3, _ = a.ListSessions(ListSessionsRequest{Cursor: &last})
|
|
456
|
+
if len(p3.Sessions) != 20 || p3.NextCursor != nil {
|
|
457
|
+
t.Errorf("page 3: n=%d next=%v", len(p3.Sessions), p3.NextCursor)
|
|
458
|
+
}
|
|
459
|
+
bad := "garbage"
|
|
460
|
+
p0, _ := a.ListSessions(ListSessionsRequest{Cursor: &bad})
|
|
461
|
+
if len(p0.Sessions) != 50 {
|
|
462
|
+
t.Errorf("an invalid cursor must read as offset 0, got %d sessions", len(p0.Sessions))
|
|
463
|
+
}
|
|
464
|
+
})
|
|
465
|
+
t.Run("session/list filters by the cwd param", func(t *testing.T) {
|
|
466
|
+
root := t.TempDir()
|
|
467
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--a--", "a.jsonl"), sessionHeader("sa", "/cwd/a", "2026-01-01T00:00:00.000Z"))
|
|
468
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--b--", "b.jsonl"), sessionHeader("sb", "/cwd/b", "2026-01-01T00:00:00.000Z"))
|
|
469
|
+
withAgentDir(t, root)
|
|
470
|
+
a, _ := testAgent(newFakeConn())
|
|
471
|
+
cwd := "/cwd/b"
|
|
472
|
+
r, _ := a.ListSessions(ListSessionsRequest{Cwd: &cwd})
|
|
473
|
+
if len(r.Sessions) != 1 || r.Sessions[0].SessionID != "sb" {
|
|
474
|
+
t.Errorf("sessions = %+v", r.Sessions)
|
|
475
|
+
}
|
|
476
|
+
r, _ = a.ListSessions(ListSessionsRequest{})
|
|
477
|
+
if len(r.Sessions) != 2 {
|
|
478
|
+
t.Errorf("without a cwd and no session yet, want all: %+v", r.Sessions)
|
|
479
|
+
}
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
func TestSetModelResolution(t *testing.T) {
|
|
484
|
+
setup := func() (*Agent, *fakeProc, *[]string) {
|
|
485
|
+
proc := newFakeProc()
|
|
486
|
+
var calls []string
|
|
487
|
+
proc.getStateFn = func() (map[string]any, error) {
|
|
488
|
+
return map[string]any{"thinkingLevel": "medium", "model": map[string]any{"provider": "p", "id": "a"}}, nil
|
|
489
|
+
}
|
|
490
|
+
proc.getModelsFn = func() (map[string]any, error) {
|
|
491
|
+
return models([3]string{"p", "a", ""}, [3]string{"q", "b/c", ""}), nil
|
|
492
|
+
}
|
|
493
|
+
proc.setModelFn = func(p, id string) error { calls = append(calls, p+"|"+id); return nil }
|
|
494
|
+
a, _ := testAgent(newFakeConn())
|
|
495
|
+
a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}, anyID: true}
|
|
496
|
+
return a, proc, &calls
|
|
497
|
+
}
|
|
498
|
+
t.Run("a bare model id resolves through the available models", func(t *testing.T) {
|
|
499
|
+
a, _, calls := setup()
|
|
500
|
+
if err := a.UnstableSetSessionModel(SetSessionModelRequest{SessionID: "s1", ModelID: "a"}); err != nil {
|
|
501
|
+
t.Fatal(err)
|
|
502
|
+
}
|
|
503
|
+
if strings.Join(*calls, ",") != "p|a" {
|
|
504
|
+
t.Errorf("calls = %v", *calls)
|
|
505
|
+
}
|
|
506
|
+
})
|
|
507
|
+
t.Run("a provider/model id keeps slashes inside the model id", func(t *testing.T) {
|
|
508
|
+
a, _, calls := setup()
|
|
509
|
+
if err := a.UnstableSetSessionModel(SetSessionModelRequest{SessionID: "s1", ModelID: "q/b/c"}); err != nil {
|
|
510
|
+
t.Fatal(err)
|
|
511
|
+
}
|
|
512
|
+
if strings.Join(*calls, ",") != "q|b/c" {
|
|
513
|
+
t.Errorf("calls = %v", *calls)
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
t.Run("an unknown bare model id is invalid params", func(t *testing.T) {
|
|
517
|
+
a, _, calls := setup()
|
|
518
|
+
err := a.UnstableSetSessionModel(SetSessionModelRequest{SessionID: "s1", ModelID: "zzz"})
|
|
519
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32602 || !strings.Contains(re.Message, "Unknown modelId: zzz") || len(*calls) != 0 {
|
|
520
|
+
t.Errorf("err = %v calls=%v", err, *calls)
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
t.Run("set_config_option rejects a non-string value and an unknown option", func(t *testing.T) {
|
|
524
|
+
a, _, calls := setup()
|
|
525
|
+
for _, req := range []SetSessionConfigOptionRequest{
|
|
526
|
+
{SessionID: "s1", ConfigID: "model", Value: 3}, {SessionID: "s1", ConfigID: "bogus", Value: "x"}} {
|
|
527
|
+
_, err := a.SetSessionConfigOption(req)
|
|
528
|
+
if re, ok := err.(*RequestError); !ok || re.Code != -32602 {
|
|
529
|
+
t.Errorf("%+v: err = %v", req, err)
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if len(*calls) != 0 {
|
|
533
|
+
t.Errorf("calls = %v", *calls)
|
|
534
|
+
}
|
|
535
|
+
})
|
|
536
|
+
}
|