@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,1102 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { PiAcpSession } from '../../src/acp/session.js'
|
|
7
|
+
import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
|
|
8
|
+
|
|
9
|
+
test('PiAcpSession: emits agent_message_chunk for text_delta', async () => {
|
|
10
|
+
const conn = new FakeAgentSideConnection()
|
|
11
|
+
const proc = new FakePiRpcProcess()
|
|
12
|
+
|
|
13
|
+
new PiAcpSession({
|
|
14
|
+
sessionId: 's1',
|
|
15
|
+
cwd: process.cwd(),
|
|
16
|
+
mcpServers: [],
|
|
17
|
+
proc: proc as any,
|
|
18
|
+
conn: asAgentConn(conn),
|
|
19
|
+
fileCommands: []
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
proc.emit({
|
|
23
|
+
type: 'message_update',
|
|
24
|
+
assistantMessageEvent: { type: 'text_delta', delta: 'hi' }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
await new Promise(r => setTimeout(r, 0))
|
|
28
|
+
|
|
29
|
+
assert.equal(conn.updates.length, 1)
|
|
30
|
+
assert.equal(conn.updates[0]!.sessionId, 's1')
|
|
31
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
32
|
+
sessionUpdate: 'agent_message_chunk',
|
|
33
|
+
content: { type: 'text', text: 'hi' }
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('PiAcpSession: emits agent_thought_chunk for thinking_delta', async () => {
|
|
38
|
+
const conn = new FakeAgentSideConnection()
|
|
39
|
+
const proc = new FakePiRpcProcess()
|
|
40
|
+
|
|
41
|
+
new PiAcpSession({
|
|
42
|
+
sessionId: 's1',
|
|
43
|
+
cwd: process.cwd(),
|
|
44
|
+
mcpServers: [],
|
|
45
|
+
proc: proc as any,
|
|
46
|
+
conn: asAgentConn(conn),
|
|
47
|
+
fileCommands: []
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
proc.emit({
|
|
51
|
+
type: 'message_update',
|
|
52
|
+
assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking...' }
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
await new Promise(r => setTimeout(r, 0))
|
|
56
|
+
|
|
57
|
+
assert.equal(conn.updates.length, 1)
|
|
58
|
+
assert.equal(conn.updates[0]!.sessionId, 's1')
|
|
59
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
60
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
61
|
+
content: { type: 'text', text: 'thinking...' }
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('PiAcpSession: emits tool_call + tool_call_update + completes', async () => {
|
|
66
|
+
const conn = new FakeAgentSideConnection()
|
|
67
|
+
const proc = new FakePiRpcProcess()
|
|
68
|
+
|
|
69
|
+
new PiAcpSession({
|
|
70
|
+
sessionId: 's1',
|
|
71
|
+
cwd: process.cwd(),
|
|
72
|
+
mcpServers: [],
|
|
73
|
+
proc: proc as any,
|
|
74
|
+
conn: asAgentConn(conn),
|
|
75
|
+
fileCommands: []
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
proc.emit({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'bash', args: { command: 'ls' } })
|
|
79
|
+
proc.emit({
|
|
80
|
+
type: 'tool_execution_update',
|
|
81
|
+
toolCallId: 't1',
|
|
82
|
+
partialResult: { content: [{ type: 'text', text: 'running' }] }
|
|
83
|
+
})
|
|
84
|
+
proc.emit({
|
|
85
|
+
type: 'tool_execution_end',
|
|
86
|
+
toolCallId: 't1',
|
|
87
|
+
isError: false,
|
|
88
|
+
result: { content: [{ type: 'text', text: 'done' }] }
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
await new Promise(r => setTimeout(r, 0))
|
|
92
|
+
|
|
93
|
+
assert.equal(conn.updates.length, 3)
|
|
94
|
+
|
|
95
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
96
|
+
assert.equal((conn.updates[0]!.update as any).toolCallId, 't1')
|
|
97
|
+
assert.equal((conn.updates[0]!.update as any).title, 'ls')
|
|
98
|
+
assert.equal((conn.updates[0]!.update as any).kind, 'execute')
|
|
99
|
+
assert.equal((conn.updates[0]!.update as any).status, 'in_progress')
|
|
100
|
+
assert.equal((conn.updates[0]!.update as any).locations, undefined)
|
|
101
|
+
assert.deepEqual((conn.updates[0]!.update as any).content, [{ type: 'terminal', terminalId: 't1' }])
|
|
102
|
+
assert.deepEqual((conn.updates[0]!.update as any)._meta, {
|
|
103
|
+
terminal_info: { terminal_id: 't1', cwd: process.cwd() }
|
|
104
|
+
})
|
|
105
|
+
assert.equal((conn.updates[0]!.update as any).rawInput, undefined)
|
|
106
|
+
|
|
107
|
+
assert.equal(conn.updates[1]!.update.sessionUpdate, 'tool_call_update')
|
|
108
|
+
assert.equal((conn.updates[1]!.update as any).toolCallId, 't1')
|
|
109
|
+
assert.equal((conn.updates[1]!.update as any).status, 'in_progress')
|
|
110
|
+
assert.equal((conn.updates[1]!.update as any).content, undefined)
|
|
111
|
+
assert.deepEqual((conn.updates[1]!.update as any)._meta, {
|
|
112
|
+
terminal_output: { terminal_id: 't1', data: 'running' }
|
|
113
|
+
})
|
|
114
|
+
assert.equal((conn.updates[1]!.update as any).rawOutput, undefined)
|
|
115
|
+
|
|
116
|
+
assert.equal(conn.updates[2]!.update.sessionUpdate, 'tool_call_update')
|
|
117
|
+
assert.equal((conn.updates[2]!.update as any).toolCallId, 't1')
|
|
118
|
+
assert.equal((conn.updates[2]!.update as any).status, 'completed')
|
|
119
|
+
assert.equal((conn.updates[2]!.update as any).content, undefined)
|
|
120
|
+
assert.deepEqual((conn.updates[2]!.update as any)._meta, {
|
|
121
|
+
terminal_output: { terminal_id: 't1', data: 'done' },
|
|
122
|
+
terminal_exit: { terminal_id: 't1', exit_code: 0, signal: null }
|
|
123
|
+
})
|
|
124
|
+
assert.equal((conn.updates[2]!.update as any).rawOutput, undefined)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('PiAcpSession: emits tool locations from pi path args', async () => {
|
|
128
|
+
const conn = new FakeAgentSideConnection()
|
|
129
|
+
const proc = new FakePiRpcProcess()
|
|
130
|
+
|
|
131
|
+
new PiAcpSession({
|
|
132
|
+
sessionId: 's1',
|
|
133
|
+
cwd: process.cwd(),
|
|
134
|
+
mcpServers: [],
|
|
135
|
+
proc: proc as any,
|
|
136
|
+
conn: asAgentConn(conn),
|
|
137
|
+
fileCommands: []
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
proc.emit({ type: 'tool_execution_start', toolCallId: 't1', toolName: 'read', args: { path: 'src/acp/session.ts' } })
|
|
141
|
+
|
|
142
|
+
await new Promise(r => setTimeout(r, 0))
|
|
143
|
+
|
|
144
|
+
assert.equal(conn.updates.length, 1)
|
|
145
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
146
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: `${process.cwd()}/src/acp/session.ts` }])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('PiAcpSession: handles extension select via ACP permission request', async () => {
|
|
150
|
+
const conn = new FakeAgentSideConnection()
|
|
151
|
+
conn.nextPermissionResponse = { outcome: { outcome: 'selected', optionId: 'choice-1' } }
|
|
152
|
+
const proc = new FakePiRpcProcess()
|
|
153
|
+
|
|
154
|
+
new PiAcpSession({
|
|
155
|
+
sessionId: 's1',
|
|
156
|
+
cwd: process.cwd(),
|
|
157
|
+
mcpServers: [],
|
|
158
|
+
proc: proc as any,
|
|
159
|
+
conn: asAgentConn(conn),
|
|
160
|
+
fileCommands: []
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
proc.emit({
|
|
164
|
+
type: 'extension_ui_request',
|
|
165
|
+
id: 'ui-1',
|
|
166
|
+
method: 'select',
|
|
167
|
+
title: 'Pick one',
|
|
168
|
+
options: ['Alpha', 'Beta']
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
await new Promise(r => setTimeout(r, 0))
|
|
172
|
+
|
|
173
|
+
assert.equal(conn.permissionRequests.length, 1)
|
|
174
|
+
assert.deepEqual(conn.permissionRequests[0], {
|
|
175
|
+
sessionId: 's1',
|
|
176
|
+
toolCall: {
|
|
177
|
+
toolCallId: 'pi-ui-ui-1',
|
|
178
|
+
title: 'Pick one',
|
|
179
|
+
kind: 'other',
|
|
180
|
+
status: 'pending',
|
|
181
|
+
rawInput: { method: 'select', title: 'Pick one', options: ['Alpha', 'Beta'] }
|
|
182
|
+
},
|
|
183
|
+
options: [
|
|
184
|
+
{ optionId: 'choice-0', name: 'Alpha', kind: 'allow_once' },
|
|
185
|
+
{ optionId: 'choice-1', name: 'Beta', kind: 'allow_once' }
|
|
186
|
+
]
|
|
187
|
+
})
|
|
188
|
+
assert.deepEqual(proc.extensionUiResponses, [{ id: 'ui-1', value: 'Beta' }])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('PiAcpSession: handles extension confirm via ACP permission request', async () => {
|
|
192
|
+
const conn = new FakeAgentSideConnection()
|
|
193
|
+
conn.nextPermissionResponse = { outcome: { outcome: 'selected', optionId: 'no' } }
|
|
194
|
+
const proc = new FakePiRpcProcess()
|
|
195
|
+
|
|
196
|
+
new PiAcpSession({
|
|
197
|
+
sessionId: 's1',
|
|
198
|
+
cwd: process.cwd(),
|
|
199
|
+
mcpServers: [],
|
|
200
|
+
proc: proc as any,
|
|
201
|
+
conn: asAgentConn(conn),
|
|
202
|
+
fileCommands: []
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
proc.emit({
|
|
206
|
+
type: 'extension_ui_request',
|
|
207
|
+
id: 'ui-2',
|
|
208
|
+
method: 'confirm',
|
|
209
|
+
title: 'Clear session?',
|
|
210
|
+
message: 'All messages will be lost.'
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
await new Promise(r => setTimeout(r, 0))
|
|
214
|
+
|
|
215
|
+
assert.equal(conn.permissionRequests.length, 1)
|
|
216
|
+
assert.deepEqual((conn.permissionRequests[0] as any).options, [
|
|
217
|
+
{ optionId: 'yes', name: 'Yes', kind: 'allow_once' },
|
|
218
|
+
{ optionId: 'no', name: 'No', kind: 'reject_once' }
|
|
219
|
+
])
|
|
220
|
+
assert.deepEqual(proc.extensionUiResponses, [{ id: 'ui-2', confirmed: false }])
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('PiAcpSession: sends cancelled response when ACP confirm is cancelled', async () => {
|
|
224
|
+
const conn = new FakeAgentSideConnection()
|
|
225
|
+
conn.nextPermissionResponse = { outcome: { outcome: 'cancelled' } }
|
|
226
|
+
const proc = new FakePiRpcProcess()
|
|
227
|
+
|
|
228
|
+
new PiAcpSession({
|
|
229
|
+
sessionId: 's1',
|
|
230
|
+
cwd: process.cwd(),
|
|
231
|
+
mcpServers: [],
|
|
232
|
+
proc: proc as any,
|
|
233
|
+
conn: asAgentConn(conn),
|
|
234
|
+
fileCommands: []
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
proc.emit({ type: 'extension_ui_request', id: 'ui-5', method: 'confirm', title: 'Continue?' })
|
|
238
|
+
|
|
239
|
+
await new Promise(r => setTimeout(r, 0))
|
|
240
|
+
|
|
241
|
+
assert.deepEqual(proc.extensionUiResponses, [{ id: 'ui-5', cancelled: true }])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('PiAcpSession: cancels unsupported input and editor extension UI requests with visible fallback', async () => {
|
|
245
|
+
const conn = new FakeAgentSideConnection()
|
|
246
|
+
const proc = new FakePiRpcProcess()
|
|
247
|
+
|
|
248
|
+
new PiAcpSession({
|
|
249
|
+
sessionId: 's1',
|
|
250
|
+
cwd: process.cwd(),
|
|
251
|
+
mcpServers: [],
|
|
252
|
+
proc: proc as any,
|
|
253
|
+
conn: asAgentConn(conn),
|
|
254
|
+
fileCommands: []
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
proc.emit({ type: 'extension_ui_request', id: 'ui-3', method: 'input', title: 'Enter name' })
|
|
258
|
+
proc.emit({ type: 'extension_ui_request', id: 'ui-4', method: 'editor', title: 'Edit text' })
|
|
259
|
+
|
|
260
|
+
await new Promise(r => setTimeout(r, 0))
|
|
261
|
+
|
|
262
|
+
assert.deepEqual(proc.extensionUiResponses, [
|
|
263
|
+
{ id: 'ui-3', cancelled: true },
|
|
264
|
+
{ id: 'ui-4', cancelled: true }
|
|
265
|
+
])
|
|
266
|
+
assert.equal(conn.updates.length, 2)
|
|
267
|
+
assert.match((conn.updates[0]!.update as any).content.text, /input UI request is not supported/)
|
|
268
|
+
assert.match((conn.updates[1]!.update as any).content.text, /editor UI request is not supported/)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('PiAcpSession: emits agent_message_chunk for auto_retry_start with attempt/maxAttempts and rounded delay', async () => {
|
|
272
|
+
const conn = new FakeAgentSideConnection()
|
|
273
|
+
const proc = new FakePiRpcProcess()
|
|
274
|
+
|
|
275
|
+
new PiAcpSession({
|
|
276
|
+
sessionId: 's1',
|
|
277
|
+
cwd: process.cwd(),
|
|
278
|
+
mcpServers: [],
|
|
279
|
+
proc: proc as any,
|
|
280
|
+
conn: asAgentConn(conn),
|
|
281
|
+
fileCommands: []
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
proc.emit({ type: 'auto_retry_start', attempt: 2, maxAttempts: 5, delayMs: 2400 })
|
|
285
|
+
|
|
286
|
+
await new Promise(r => setTimeout(r, 0))
|
|
287
|
+
|
|
288
|
+
assert.equal(conn.updates.length, 1)
|
|
289
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
290
|
+
sessionUpdate: 'agent_message_chunk',
|
|
291
|
+
content: { type: 'text', text: 'Retrying (attempt 2/5, waiting 2s)...' }
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('PiAcpSession: formats a positive sub-second auto_retry_start delay as waiting 1s', async () => {
|
|
296
|
+
const conn = new FakeAgentSideConnection()
|
|
297
|
+
const proc = new FakePiRpcProcess()
|
|
298
|
+
|
|
299
|
+
new PiAcpSession({
|
|
300
|
+
sessionId: 's1',
|
|
301
|
+
cwd: process.cwd(),
|
|
302
|
+
mcpServers: [],
|
|
303
|
+
proc: proc as any,
|
|
304
|
+
conn: asAgentConn(conn),
|
|
305
|
+
fileCommands: []
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
proc.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 1 })
|
|
309
|
+
|
|
310
|
+
await new Promise(r => setTimeout(r, 0))
|
|
311
|
+
|
|
312
|
+
assert.equal(conn.updates.length, 1)
|
|
313
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
314
|
+
sessionUpdate: 'agent_message_chunk',
|
|
315
|
+
content: { type: 'text', text: 'Retrying (attempt 1/3, waiting 1s)...' }
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('PiAcpSession: falls back to a generic retry message when auto_retry_start fields are missing or malformed', async () => {
|
|
320
|
+
const conn = new FakeAgentSideConnection()
|
|
321
|
+
const proc = new FakePiRpcProcess()
|
|
322
|
+
|
|
323
|
+
new PiAcpSession({
|
|
324
|
+
sessionId: 's1',
|
|
325
|
+
cwd: process.cwd(),
|
|
326
|
+
mcpServers: [],
|
|
327
|
+
proc: proc as any,
|
|
328
|
+
conn: asAgentConn(conn),
|
|
329
|
+
fileCommands: []
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
proc.emit({ type: 'auto_retry_start', attempt: 'oops', maxAttempts: null, delayMs: 'bad' } as any)
|
|
333
|
+
|
|
334
|
+
await new Promise(r => setTimeout(r, 0))
|
|
335
|
+
|
|
336
|
+
assert.equal(conn.updates.length, 1)
|
|
337
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
338
|
+
sessionUpdate: 'agent_message_chunk',
|
|
339
|
+
content: { type: 'text', text: 'Retrying...' }
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('PiAcpSession: omits raw errorMessage content from surfaced auto_retry_start status text', async () => {
|
|
344
|
+
const conn = new FakeAgentSideConnection()
|
|
345
|
+
const proc = new FakePiRpcProcess()
|
|
346
|
+
|
|
347
|
+
new PiAcpSession({
|
|
348
|
+
sessionId: 's1',
|
|
349
|
+
cwd: process.cwd(),
|
|
350
|
+
mcpServers: [],
|
|
351
|
+
proc: proc as any,
|
|
352
|
+
conn: asAgentConn(conn),
|
|
353
|
+
fileCommands: []
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
proc.emit({
|
|
357
|
+
type: 'auto_retry_start',
|
|
358
|
+
attempt: 1,
|
|
359
|
+
maxAttempts: 4,
|
|
360
|
+
delayMs: 1500,
|
|
361
|
+
errorMessage: 'provider overloaded: 529'
|
|
362
|
+
} as any)
|
|
363
|
+
|
|
364
|
+
await new Promise(r => setTimeout(r, 0))
|
|
365
|
+
|
|
366
|
+
assert.equal(conn.updates.length, 1)
|
|
367
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'agent_message_chunk')
|
|
368
|
+
assert.equal((conn.updates[0]!.update as any).content.text, 'Retrying (attempt 1/4, waiting 2s)...')
|
|
369
|
+
assert.equal((conn.updates[0]!.update as any).content.text.includes('provider overloaded'), false)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('PiAcpSession: emits agent_message_chunk for auto_retry_end', async () => {
|
|
373
|
+
const conn = new FakeAgentSideConnection()
|
|
374
|
+
const proc = new FakePiRpcProcess()
|
|
375
|
+
|
|
376
|
+
new PiAcpSession({
|
|
377
|
+
sessionId: 's1',
|
|
378
|
+
cwd: process.cwd(),
|
|
379
|
+
mcpServers: [],
|
|
380
|
+
proc: proc as any,
|
|
381
|
+
conn: asAgentConn(conn),
|
|
382
|
+
fileCommands: []
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
proc.emit({ type: 'auto_retry_end' } as any)
|
|
386
|
+
|
|
387
|
+
await new Promise(r => setTimeout(r, 0))
|
|
388
|
+
|
|
389
|
+
assert.equal(conn.updates.length, 1)
|
|
390
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
391
|
+
sessionUpdate: 'agent_message_chunk',
|
|
392
|
+
content: { type: 'text', text: 'Retry finished, resuming.' }
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('PiAcpSession: emits agent_message_chunk for auto_compaction_start', async () => {
|
|
397
|
+
const conn = new FakeAgentSideConnection()
|
|
398
|
+
const proc = new FakePiRpcProcess()
|
|
399
|
+
|
|
400
|
+
new PiAcpSession({
|
|
401
|
+
sessionId: 's1',
|
|
402
|
+
cwd: process.cwd(),
|
|
403
|
+
mcpServers: [],
|
|
404
|
+
proc: proc as any,
|
|
405
|
+
conn: asAgentConn(conn),
|
|
406
|
+
fileCommands: []
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
proc.emit({ type: 'auto_compaction_start' } as any)
|
|
410
|
+
|
|
411
|
+
await new Promise(r => setTimeout(r, 0))
|
|
412
|
+
|
|
413
|
+
assert.equal(conn.updates.length, 1)
|
|
414
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
415
|
+
sessionUpdate: 'agent_message_chunk',
|
|
416
|
+
content: { type: 'text', text: 'Context nearing limit, running automatic compaction...' }
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('PiAcpSession: emits agent_message_chunk for auto_compaction_end', async () => {
|
|
421
|
+
const conn = new FakeAgentSideConnection()
|
|
422
|
+
const proc = new FakePiRpcProcess()
|
|
423
|
+
|
|
424
|
+
new PiAcpSession({
|
|
425
|
+
sessionId: 's1',
|
|
426
|
+
cwd: process.cwd(),
|
|
427
|
+
mcpServers: [],
|
|
428
|
+
proc: proc as any,
|
|
429
|
+
conn: asAgentConn(conn),
|
|
430
|
+
fileCommands: []
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
proc.emit({ type: 'auto_compaction_end' } as any)
|
|
434
|
+
|
|
435
|
+
await new Promise(r => setTimeout(r, 0))
|
|
436
|
+
|
|
437
|
+
assert.equal(conn.updates.length, 1)
|
|
438
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
439
|
+
sessionUpdate: 'agent_message_chunk',
|
|
440
|
+
content: {
|
|
441
|
+
type: 'text',
|
|
442
|
+
text: 'Automatic compaction finished; context was summarized to continue the session.'
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('PiAcpSession: preserves ordering when auto_retry_start is interleaved with text_delta events', async () => {
|
|
448
|
+
const conn = new FakeAgentSideConnection()
|
|
449
|
+
const proc = new FakePiRpcProcess()
|
|
450
|
+
|
|
451
|
+
new PiAcpSession({
|
|
452
|
+
sessionId: 's1',
|
|
453
|
+
cwd: process.cwd(),
|
|
454
|
+
mcpServers: [],
|
|
455
|
+
proc: proc as any,
|
|
456
|
+
conn: asAgentConn(conn),
|
|
457
|
+
fileCommands: []
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
proc.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'before ' } })
|
|
461
|
+
proc.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 2, delayMs: 2000 } as any)
|
|
462
|
+
proc.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'after' } })
|
|
463
|
+
|
|
464
|
+
await new Promise(r => setTimeout(r, 0))
|
|
465
|
+
|
|
466
|
+
assert.deepEqual(
|
|
467
|
+
conn.updates.map(u => u.update),
|
|
468
|
+
[
|
|
469
|
+
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before ' } },
|
|
470
|
+
{
|
|
471
|
+
sessionUpdate: 'agent_message_chunk',
|
|
472
|
+
content: { type: 'text', text: 'Retrying (attempt 1/2, waiting 2s)...' }
|
|
473
|
+
},
|
|
474
|
+
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } }
|
|
475
|
+
]
|
|
476
|
+
)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
test('PiAcpSession: emits streamed tool locations from pi path args', async () => {
|
|
480
|
+
const conn = new FakeAgentSideConnection()
|
|
481
|
+
const proc = new FakePiRpcProcess()
|
|
482
|
+
|
|
483
|
+
new PiAcpSession({
|
|
484
|
+
sessionId: 's1',
|
|
485
|
+
cwd: process.cwd(),
|
|
486
|
+
mcpServers: [],
|
|
487
|
+
proc: proc as any,
|
|
488
|
+
conn: asAgentConn(conn),
|
|
489
|
+
fileCommands: []
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
proc.emit({
|
|
493
|
+
type: 'message_update',
|
|
494
|
+
assistantMessageEvent: {
|
|
495
|
+
type: 'toolcall_start',
|
|
496
|
+
toolCall: {
|
|
497
|
+
id: 't1',
|
|
498
|
+
name: 'write',
|
|
499
|
+
arguments: { path: '/tmp/test.txt', content: 'hello' }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
await new Promise(r => setTimeout(r, 0))
|
|
505
|
+
|
|
506
|
+
assert.equal(conn.updates.length, 1)
|
|
507
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
508
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: '/tmp/test.txt' }])
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test('PiAcpSession: emits edit tool line when oldText matches uniquely', async () => {
|
|
512
|
+
const conn = new FakeAgentSideConnection()
|
|
513
|
+
const proc = new FakePiRpcProcess()
|
|
514
|
+
const cwd = mkdtempSync(join(tmpdir(), 'pi-acp-lines-'))
|
|
515
|
+
const filePath = join(cwd, 'a.txt')
|
|
516
|
+
|
|
517
|
+
mkdirSync(cwd, { recursive: true })
|
|
518
|
+
writeFileSync(filePath, 'one\ntwo\nneedle\nthree\n', 'utf8')
|
|
519
|
+
|
|
520
|
+
new PiAcpSession({
|
|
521
|
+
sessionId: 's1',
|
|
522
|
+
cwd,
|
|
523
|
+
mcpServers: [],
|
|
524
|
+
proc: proc as any,
|
|
525
|
+
conn: asAgentConn(conn),
|
|
526
|
+
fileCommands: []
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
proc.emit({
|
|
530
|
+
type: 'tool_execution_start',
|
|
531
|
+
toolCallId: 't1',
|
|
532
|
+
toolName: 'edit',
|
|
533
|
+
args: { path: 'a.txt', oldText: 'needle' }
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
await new Promise(r => setTimeout(r, 0))
|
|
537
|
+
|
|
538
|
+
assert.equal(conn.updates.length, 1)
|
|
539
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
540
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: filePath, line: 3 }])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test('PiAcpSession: emits edit tool line from edits array when oldText matches uniquely', async () => {
|
|
544
|
+
const conn = new FakeAgentSideConnection()
|
|
545
|
+
const proc = new FakePiRpcProcess()
|
|
546
|
+
const cwd = mkdtempSync(join(tmpdir(), 'pi-acp-lines-edits-'))
|
|
547
|
+
const filePath = join(cwd, 'a.txt')
|
|
548
|
+
|
|
549
|
+
mkdirSync(cwd, { recursive: true })
|
|
550
|
+
writeFileSync(filePath, 'one\ntwo\nneedle\nthree\n', 'utf8')
|
|
551
|
+
|
|
552
|
+
new PiAcpSession({
|
|
553
|
+
sessionId: 's1',
|
|
554
|
+
cwd,
|
|
555
|
+
mcpServers: [],
|
|
556
|
+
proc: proc as any,
|
|
557
|
+
conn: asAgentConn(conn),
|
|
558
|
+
fileCommands: []
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
proc.emit({
|
|
562
|
+
type: 'tool_execution_start',
|
|
563
|
+
toolCallId: 't1',
|
|
564
|
+
toolName: 'edit',
|
|
565
|
+
args: { path: 'a.txt', edits: [{ oldText: 'needle', newText: 'replacement' }] }
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
await new Promise(r => setTimeout(r, 0))
|
|
569
|
+
|
|
570
|
+
assert.equal(conn.updates.length, 1)
|
|
571
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
572
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: filePath, line: 3 }])
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
test('PiAcpSession: emits edit tool line from stringified edits array', async () => {
|
|
576
|
+
const conn = new FakeAgentSideConnection()
|
|
577
|
+
const proc = new FakePiRpcProcess()
|
|
578
|
+
const cwd = mkdtempSync(join(tmpdir(), 'pi-acp-lines-edits-string-'))
|
|
579
|
+
const filePath = join(cwd, 'a.txt')
|
|
580
|
+
|
|
581
|
+
mkdirSync(cwd, { recursive: true })
|
|
582
|
+
writeFileSync(filePath, 'one\ntwo\nneedle\nthree\n', 'utf8')
|
|
583
|
+
|
|
584
|
+
new PiAcpSession({
|
|
585
|
+
sessionId: 's1',
|
|
586
|
+
cwd,
|
|
587
|
+
mcpServers: [],
|
|
588
|
+
proc: proc as any,
|
|
589
|
+
conn: asAgentConn(conn),
|
|
590
|
+
fileCommands: []
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
proc.emit({
|
|
594
|
+
type: 'tool_execution_start',
|
|
595
|
+
toolCallId: 't1',
|
|
596
|
+
toolName: 'edit',
|
|
597
|
+
args: { path: 'a.txt', edits: JSON.stringify([{ oldText: 'needle', newText: 'replacement' }]) }
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
await new Promise(r => setTimeout(r, 0))
|
|
601
|
+
|
|
602
|
+
assert.equal(conn.updates.length, 1)
|
|
603
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
604
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: filePath, line: 3 }])
|
|
605
|
+
})
|
|
606
|
+
|
|
607
|
+
test('PiAcpSession: omits edit tool line when oldText matches multiple times', async () => {
|
|
608
|
+
const conn = new FakeAgentSideConnection()
|
|
609
|
+
const proc = new FakePiRpcProcess()
|
|
610
|
+
const cwd = mkdtempSync(join(tmpdir(), 'pi-acp-lines-dup-'))
|
|
611
|
+
const filePath = join(cwd, 'a.txt')
|
|
612
|
+
|
|
613
|
+
mkdirSync(cwd, { recursive: true })
|
|
614
|
+
writeFileSync(filePath, 'one\nneedle\ntwo\nneedle\n', 'utf8')
|
|
615
|
+
|
|
616
|
+
new PiAcpSession({
|
|
617
|
+
sessionId: 's1',
|
|
618
|
+
cwd,
|
|
619
|
+
mcpServers: [],
|
|
620
|
+
proc: proc as any,
|
|
621
|
+
conn: asAgentConn(conn),
|
|
622
|
+
fileCommands: []
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
proc.emit({
|
|
626
|
+
type: 'tool_execution_start',
|
|
627
|
+
toolCallId: 't2',
|
|
628
|
+
toolName: 'edit',
|
|
629
|
+
args: { path: 'a.txt', oldText: 'needle' }
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
await new Promise(r => setTimeout(r, 0))
|
|
633
|
+
|
|
634
|
+
assert.equal(conn.updates.length, 1)
|
|
635
|
+
assert.equal(conn.updates[0]!.update.sessionUpdate, 'tool_call')
|
|
636
|
+
assert.deepEqual((conn.updates[0]!.update as any).locations, [{ path: filePath }])
|
|
637
|
+
})
|
|
638
|
+
|
|
639
|
+
test('PiAcpSession: prompt stays open through retry runs until agent_settled', async () => {
|
|
640
|
+
const conn = new FakeAgentSideConnection()
|
|
641
|
+
const proc = new FakePiRpcProcess()
|
|
642
|
+
|
|
643
|
+
const session = new PiAcpSession({
|
|
644
|
+
sessionId: 's1',
|
|
645
|
+
cwd: process.cwd(),
|
|
646
|
+
mcpServers: [],
|
|
647
|
+
proc: proc as any,
|
|
648
|
+
conn: asAgentConn(conn),
|
|
649
|
+
fileCommands: []
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
let resolved = false
|
|
653
|
+
const p = session.prompt('hello').then(reason => {
|
|
654
|
+
resolved = true
|
|
655
|
+
return reason
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
proc.emit({ type: 'agent_start' })
|
|
659
|
+
proc.emit({ type: 'auto_retry_start', attempt: 1, maxAttempts: 3, delayMs: 2000 })
|
|
660
|
+
proc.emit({ type: 'agent_end', willRetry: true })
|
|
661
|
+
await new Promise(r => setTimeout(r, 0))
|
|
662
|
+
assert.equal(resolved, false)
|
|
663
|
+
|
|
664
|
+
proc.emit({ type: 'agent_start' })
|
|
665
|
+
proc.emit({ type: 'turn_end' })
|
|
666
|
+
proc.emit({ type: 'agent_end', willRetry: false })
|
|
667
|
+
await new Promise(r => setTimeout(r, 0))
|
|
668
|
+
assert.equal(resolved, false)
|
|
669
|
+
|
|
670
|
+
proc.emit({ type: 'agent_settled' })
|
|
671
|
+
const reason = await p
|
|
672
|
+
assert.equal(reason, 'end_turn')
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
test('PiAcpSession: does not re-emit startup info on first prompt after it was already sent', async () => {
|
|
676
|
+
const conn = new FakeAgentSideConnection()
|
|
677
|
+
const proc = new FakePiRpcProcess()
|
|
678
|
+
|
|
679
|
+
const session = new PiAcpSession({
|
|
680
|
+
sessionId: 's1',
|
|
681
|
+
cwd: process.cwd(),
|
|
682
|
+
mcpServers: [],
|
|
683
|
+
proc: proc as any,
|
|
684
|
+
conn: asAgentConn(conn),
|
|
685
|
+
fileCommands: []
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
const notice = 'New version available: v0.74.0 (installed v0.73.1).'
|
|
689
|
+
|
|
690
|
+
session.setStartupInfo(notice)
|
|
691
|
+
session.sendStartupInfoIfPending()
|
|
692
|
+
await new Promise(r => setTimeout(r, 0))
|
|
693
|
+
|
|
694
|
+
const p = session.prompt('hello')
|
|
695
|
+
await new Promise(r => setTimeout(r, 0))
|
|
696
|
+
|
|
697
|
+
assert.equal(proc.prompts.length, 1)
|
|
698
|
+
assert.equal(proc.prompts[0]!.message, 'hello')
|
|
699
|
+
const startupUpdates = conn.updates.filter(
|
|
700
|
+
entry =>
|
|
701
|
+
entry.update.sessionUpdate === 'agent_message_chunk' &&
|
|
702
|
+
(entry.update as any).content?.type === 'text' &&
|
|
703
|
+
(entry.update as any).content?.text === notice
|
|
704
|
+
)
|
|
705
|
+
assert.equal(startupUpdates.length, 1)
|
|
706
|
+
|
|
707
|
+
proc.emit({ type: 'agent_start' })
|
|
708
|
+
proc.emit({ type: 'turn_end' })
|
|
709
|
+
proc.emit({ type: 'agent_end' })
|
|
710
|
+
proc.emit({ type: 'agent_settled' })
|
|
711
|
+
|
|
712
|
+
const reason = await p
|
|
713
|
+
assert.equal(reason, 'end_turn')
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
test('PiAcpSession: cancel flips stopReason to cancelled', async () => {
|
|
717
|
+
const conn = new FakeAgentSideConnection()
|
|
718
|
+
const proc = new FakePiRpcProcess()
|
|
719
|
+
|
|
720
|
+
const session = new PiAcpSession({
|
|
721
|
+
sessionId: 's1',
|
|
722
|
+
cwd: process.cwd(),
|
|
723
|
+
mcpServers: [],
|
|
724
|
+
proc: proc as any,
|
|
725
|
+
conn: asAgentConn(conn),
|
|
726
|
+
fileCommands: []
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
const p = session.prompt('hello')
|
|
730
|
+
await session.cancel()
|
|
731
|
+
proc.emit({ type: 'agent_start' })
|
|
732
|
+
proc.emit({ type: 'turn_end' })
|
|
733
|
+
proc.emit({ type: 'agent_end' })
|
|
734
|
+
proc.emit({ type: 'agent_settled' })
|
|
735
|
+
const reason = await p
|
|
736
|
+
|
|
737
|
+
assert.equal(proc.abortCount, 1)
|
|
738
|
+
assert.equal(reason, 'cancelled')
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
test('PiAcpSession: queues concurrent prompt and starts it after agent_settled', async () => {
|
|
742
|
+
const conn = new FakeAgentSideConnection()
|
|
743
|
+
const proc = new FakePiRpcProcess()
|
|
744
|
+
|
|
745
|
+
const session = new PiAcpSession({
|
|
746
|
+
sessionId: 's1',
|
|
747
|
+
cwd: process.cwd(),
|
|
748
|
+
mcpServers: [],
|
|
749
|
+
proc: proc as any,
|
|
750
|
+
conn: asAgentConn(conn),
|
|
751
|
+
fileCommands: []
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
const first = session.prompt('one')
|
|
755
|
+
const second = session.prompt('two')
|
|
756
|
+
|
|
757
|
+
assert.equal(proc.prompts.length, 1)
|
|
758
|
+
assert.equal(proc.prompts[0]!.message, 'one')
|
|
759
|
+
|
|
760
|
+
proc.emit({ type: 'agent_start' })
|
|
761
|
+
proc.emit({ type: 'turn_end' })
|
|
762
|
+
proc.emit({ type: 'agent_end' })
|
|
763
|
+
proc.emit({ type: 'agent_settled' })
|
|
764
|
+
|
|
765
|
+
const r1 = await first
|
|
766
|
+
assert.equal(r1, 'end_turn')
|
|
767
|
+
|
|
768
|
+
assert.equal(proc.prompts.length, 2)
|
|
769
|
+
assert.equal(proc.prompts[1]!.message, 'two')
|
|
770
|
+
|
|
771
|
+
proc.emit({ type: 'agent_start' })
|
|
772
|
+
proc.emit({ type: 'turn_end' })
|
|
773
|
+
proc.emit({ type: 'agent_end' })
|
|
774
|
+
proc.emit({ type: 'agent_settled' })
|
|
775
|
+
|
|
776
|
+
const r2 = await second
|
|
777
|
+
assert.equal(r2, 'end_turn')
|
|
778
|
+
})
|
|
779
|
+
|
|
780
|
+
test('PiAcpSession: cancel clears queued prompts', async () => {
|
|
781
|
+
const conn = new FakeAgentSideConnection()
|
|
782
|
+
const proc = new FakePiRpcProcess()
|
|
783
|
+
|
|
784
|
+
const session = new PiAcpSession({
|
|
785
|
+
sessionId: 's1',
|
|
786
|
+
cwd: process.cwd(),
|
|
787
|
+
mcpServers: [],
|
|
788
|
+
proc: proc as any,
|
|
789
|
+
conn: asAgentConn(conn),
|
|
790
|
+
fileCommands: []
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
const first = session.prompt('one')
|
|
794
|
+
const second = session.prompt('two')
|
|
795
|
+
|
|
796
|
+
assert.equal(proc.prompts.length, 1)
|
|
797
|
+
|
|
798
|
+
await session.cancel()
|
|
799
|
+
proc.emit({ type: 'agent_start' })
|
|
800
|
+
proc.emit({ type: 'turn_end' })
|
|
801
|
+
proc.emit({ type: 'agent_end' })
|
|
802
|
+
proc.emit({ type: 'agent_settled' })
|
|
803
|
+
|
|
804
|
+
const r1 = await first
|
|
805
|
+
const r2 = await second
|
|
806
|
+
|
|
807
|
+
assert.equal(r1, 'cancelled')
|
|
808
|
+
assert.equal(r2, 'cancelled')
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
test('PiAcpSession: expands /command before sending to pi', async () => {
|
|
812
|
+
const conn = new FakeAgentSideConnection()
|
|
813
|
+
const proc = new FakePiRpcProcess()
|
|
814
|
+
|
|
815
|
+
const session = new PiAcpSession({
|
|
816
|
+
sessionId: 's1',
|
|
817
|
+
cwd: process.cwd(),
|
|
818
|
+
mcpServers: [],
|
|
819
|
+
proc: proc as any,
|
|
820
|
+
conn: asAgentConn(conn),
|
|
821
|
+
fileCommands: [
|
|
822
|
+
{
|
|
823
|
+
name: 'hello',
|
|
824
|
+
description: 'test',
|
|
825
|
+
content: 'Say hello to $1',
|
|
826
|
+
source: '(project)'
|
|
827
|
+
}
|
|
828
|
+
]
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
const p = session.prompt('/hello world')
|
|
832
|
+
assert.equal(proc.prompts.length, 1)
|
|
833
|
+
assert.equal(proc.prompts[0]!.message, 'Say hello to world')
|
|
834
|
+
|
|
835
|
+
proc.emit({ type: 'agent_start' })
|
|
836
|
+
proc.emit({ type: 'turn_end' })
|
|
837
|
+
proc.emit({ type: 'agent_end' })
|
|
838
|
+
proc.emit({ type: 'agent_settled' })
|
|
839
|
+
|
|
840
|
+
const reason = await p
|
|
841
|
+
assert.equal(reason, 'end_turn')
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test('PiAcpSession: tags extension notify chunks with severity in _meta', async () => {
|
|
845
|
+
const conn = new FakeAgentSideConnection()
|
|
846
|
+
const proc = new FakePiRpcProcess()
|
|
847
|
+
|
|
848
|
+
new PiAcpSession({
|
|
849
|
+
sessionId: 's1',
|
|
850
|
+
cwd: process.cwd(),
|
|
851
|
+
mcpServers: [],
|
|
852
|
+
proc: proc as any,
|
|
853
|
+
conn: asAgentConn(conn),
|
|
854
|
+
fileCommands: []
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
proc.emit({
|
|
858
|
+
type: 'extension_ui_request',
|
|
859
|
+
id: 'n1',
|
|
860
|
+
method: 'notify',
|
|
861
|
+
message: 'MCP: connection failed',
|
|
862
|
+
notifyType: 'error'
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
await new Promise(r => setTimeout(r, 0))
|
|
866
|
+
|
|
867
|
+
assert.equal(conn.updates.length, 1)
|
|
868
|
+
assert.deepEqual(conn.updates[0]!.update, {
|
|
869
|
+
sessionUpdate: 'agent_message_chunk',
|
|
870
|
+
content: { type: 'text', text: 'MCP: connection failed' },
|
|
871
|
+
_meta: { piAcp: { notify: { level: 'error' } } }
|
|
872
|
+
})
|
|
873
|
+
assert.deepEqual(proc.extensionUiResponses[0], { id: 'n1', cancelled: true })
|
|
874
|
+
})
|
|
875
|
+
|
|
876
|
+
test('PiAcpSession: defaults notify severity to info when notifyType is absent', async () => {
|
|
877
|
+
const conn = new FakeAgentSideConnection()
|
|
878
|
+
const proc = new FakePiRpcProcess()
|
|
879
|
+
|
|
880
|
+
new PiAcpSession({
|
|
881
|
+
sessionId: 's1',
|
|
882
|
+
cwd: process.cwd(),
|
|
883
|
+
mcpServers: [],
|
|
884
|
+
proc: proc as any,
|
|
885
|
+
conn: asAgentConn(conn),
|
|
886
|
+
fileCommands: []
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
proc.emit({
|
|
890
|
+
type: 'extension_ui_request',
|
|
891
|
+
id: 'n2',
|
|
892
|
+
method: 'notify',
|
|
893
|
+
message: 'heads up'
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
await new Promise(r => setTimeout(r, 0))
|
|
897
|
+
|
|
898
|
+
assert.equal(conn.updates.length, 1)
|
|
899
|
+
assert.deepEqual((conn.updates[0]!.update as any)._meta, {
|
|
900
|
+
piAcp: { notify: { level: 'info' } }
|
|
901
|
+
})
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
test('PiAcpSession: emits usage_update from contextUsage before resolving prompt on agent_settled', async () => {
|
|
905
|
+
const conn = new FakeAgentSideConnection()
|
|
906
|
+
const proc = new FakePiRpcProcess()
|
|
907
|
+
proc.sessionStats = {
|
|
908
|
+
tokens: { total: 999_999 },
|
|
909
|
+
contextUsage: { tokens: 12_345, contextWindow: 200_000 }
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const session = new PiAcpSession({
|
|
913
|
+
sessionId: 's1',
|
|
914
|
+
cwd: process.cwd(),
|
|
915
|
+
mcpServers: [],
|
|
916
|
+
proc: proc as any,
|
|
917
|
+
conn: asAgentConn(conn),
|
|
918
|
+
fileCommands: []
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
// Block delivery of usage_update to prove the prompt only resolves once it landed.
|
|
922
|
+
let usageDeliveryStarted: () => void
|
|
923
|
+
const deliveryStarted = new Promise<void>(resolve => {
|
|
924
|
+
usageDeliveryStarted = resolve
|
|
925
|
+
})
|
|
926
|
+
let releaseDelivery: () => void
|
|
927
|
+
const deliveryBlocked = new Promise<void>(resolve => {
|
|
928
|
+
releaseDelivery = resolve
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
const originalSessionUpdate = conn.sessionUpdate.bind(conn)
|
|
932
|
+
conn.sessionUpdate = async msg => {
|
|
933
|
+
if (msg.update.sessionUpdate === 'usage_update') {
|
|
934
|
+
usageDeliveryStarted()
|
|
935
|
+
await deliveryBlocked
|
|
936
|
+
}
|
|
937
|
+
await originalSessionUpdate(msg)
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
let resolved = false
|
|
941
|
+
const p = session.prompt('hello').then(reason => {
|
|
942
|
+
resolved = true
|
|
943
|
+
return reason
|
|
944
|
+
})
|
|
945
|
+
proc.emit({ type: 'agent_start' })
|
|
946
|
+
proc.emit({ type: 'agent_end' })
|
|
947
|
+
proc.emit({ type: 'agent_settled' })
|
|
948
|
+
|
|
949
|
+
await deliveryStarted
|
|
950
|
+
assert.equal(resolved, false)
|
|
951
|
+
releaseDelivery!()
|
|
952
|
+
|
|
953
|
+
assert.equal(await p, 'end_turn')
|
|
954
|
+
assert.equal(proc.getSessionStatsCount, 1)
|
|
955
|
+
assert.deepEqual(
|
|
956
|
+
conn.updates.filter(u => u.update.sessionUpdate === 'usage_update').map(u => u.update),
|
|
957
|
+
[{ sessionUpdate: 'usage_update', used: 12_345, size: 200_000 }]
|
|
958
|
+
)
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
test('PiAcpSession: skips usage_update when contextUsage tokens are null', async () => {
|
|
962
|
+
const conn = new FakeAgentSideConnection()
|
|
963
|
+
const proc = new FakePiRpcProcess()
|
|
964
|
+
proc.sessionStats = {
|
|
965
|
+
tokens: { total: 4_000 },
|
|
966
|
+
contextUsage: { tokens: null, contextWindow: 200_000 }
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const session = new PiAcpSession({
|
|
970
|
+
sessionId: 's1',
|
|
971
|
+
cwd: process.cwd(),
|
|
972
|
+
mcpServers: [],
|
|
973
|
+
proc: proc as any,
|
|
974
|
+
conn: asAgentConn(conn),
|
|
975
|
+
fileCommands: []
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
const p = session.prompt('hello')
|
|
979
|
+
proc.emit({ type: 'agent_settled' })
|
|
980
|
+
|
|
981
|
+
assert.equal(await p, 'end_turn')
|
|
982
|
+
assert.equal(
|
|
983
|
+
conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
|
|
984
|
+
false
|
|
985
|
+
)
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
test('PiAcpSession: skips usage_update for invalid contextUsage values', async () => {
|
|
989
|
+
const invalid = [
|
|
990
|
+
{ name: 'missing contextUsage', contextUsage: undefined },
|
|
991
|
+
{ name: 'negative tokens', contextUsage: { tokens: -1, contextWindow: 100 } },
|
|
992
|
+
{ name: 'fractional tokens', contextUsage: { tokens: 1.5, contextWindow: 100 } },
|
|
993
|
+
{ name: 'NaN tokens', contextUsage: { tokens: Number.NaN, contextWindow: 100 } },
|
|
994
|
+
{ name: 'zero contextWindow', contextUsage: { tokens: 10, contextWindow: 0 } },
|
|
995
|
+
{ name: 'negative contextWindow', contextUsage: { tokens: 10, contextWindow: -1 } },
|
|
996
|
+
{ name: 'fractional contextWindow', contextUsage: { tokens: 10, contextWindow: 100.5 } },
|
|
997
|
+
{ name: 'null contextWindow', contextUsage: { tokens: 10, contextWindow: null } },
|
|
998
|
+
{ name: 'infinite contextWindow', contextUsage: { tokens: 10, contextWindow: Number.POSITIVE_INFINITY } }
|
|
999
|
+
]
|
|
1000
|
+
|
|
1001
|
+
for (const { name, contextUsage } of invalid) {
|
|
1002
|
+
const conn = new FakeAgentSideConnection()
|
|
1003
|
+
const proc = new FakePiRpcProcess()
|
|
1004
|
+
proc.sessionStats = { contextUsage } as any
|
|
1005
|
+
|
|
1006
|
+
const session = new PiAcpSession({
|
|
1007
|
+
sessionId: 's1',
|
|
1008
|
+
cwd: process.cwd(),
|
|
1009
|
+
mcpServers: [],
|
|
1010
|
+
proc: proc as any,
|
|
1011
|
+
conn: asAgentConn(conn),
|
|
1012
|
+
fileCommands: []
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
const p = session.prompt('hello')
|
|
1016
|
+
proc.emit({ type: 'agent_settled' })
|
|
1017
|
+
|
|
1018
|
+
assert.equal(await p, 'end_turn', name)
|
|
1019
|
+
assert.equal(
|
|
1020
|
+
conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
|
|
1021
|
+
false,
|
|
1022
|
+
name
|
|
1023
|
+
)
|
|
1024
|
+
}
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
test('PiAcpSession: get_session_stats rejection does not break the prompt', async () => {
|
|
1028
|
+
const conn = new FakeAgentSideConnection()
|
|
1029
|
+
const proc = new FakePiRpcProcess()
|
|
1030
|
+
proc.sessionStatsError = new Error('pi get_session_stats failed: unsupported')
|
|
1031
|
+
|
|
1032
|
+
const session = new PiAcpSession({
|
|
1033
|
+
sessionId: 's1',
|
|
1034
|
+
cwd: process.cwd(),
|
|
1035
|
+
mcpServers: [],
|
|
1036
|
+
proc: proc as any,
|
|
1037
|
+
conn: asAgentConn(conn),
|
|
1038
|
+
fileCommands: []
|
|
1039
|
+
})
|
|
1040
|
+
|
|
1041
|
+
const p = session.prompt('hello')
|
|
1042
|
+
proc.emit({ type: 'agent_settled' })
|
|
1043
|
+
|
|
1044
|
+
assert.equal(await p, 'end_turn')
|
|
1045
|
+
assert.equal(proc.getSessionStatsCount, 1)
|
|
1046
|
+
assert.equal(
|
|
1047
|
+
conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
|
|
1048
|
+
false
|
|
1049
|
+
)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
test('PiAcpSession: get_session_stats timeout does not block the prompt', async () => {
|
|
1053
|
+
const conn = new FakeAgentSideConnection()
|
|
1054
|
+
const proc = new FakePiRpcProcess()
|
|
1055
|
+
// The timeout lives in PiRpcProcess.request (see test/unit/pi-rpc-request-timeout.test.ts);
|
|
1056
|
+
// from the session's point of view it surfaces as a rejection.
|
|
1057
|
+
proc.sessionStatsError = new Error('pi get_session_stats timed out after 1000ms')
|
|
1058
|
+
|
|
1059
|
+
const session = new PiAcpSession({
|
|
1060
|
+
sessionId: 's1',
|
|
1061
|
+
cwd: process.cwd(),
|
|
1062
|
+
mcpServers: [],
|
|
1063
|
+
proc: proc as any,
|
|
1064
|
+
conn: asAgentConn(conn),
|
|
1065
|
+
fileCommands: []
|
|
1066
|
+
})
|
|
1067
|
+
|
|
1068
|
+
const p = session.prompt('hello')
|
|
1069
|
+
proc.emit({ type: 'agent_settled' })
|
|
1070
|
+
|
|
1071
|
+
assert.equal(await p, 'end_turn')
|
|
1072
|
+
assert.equal(proc.getSessionStatsCount, 1)
|
|
1073
|
+
assert.equal(
|
|
1074
|
+
conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
|
|
1075
|
+
false
|
|
1076
|
+
)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
test('PiAcpSession: cancelled turn still reports cancelled after usage publish', async () => {
|
|
1080
|
+
const conn = new FakeAgentSideConnection()
|
|
1081
|
+
const proc = new FakePiRpcProcess()
|
|
1082
|
+
proc.sessionStats = { contextUsage: { tokens: 42, contextWindow: 100 } }
|
|
1083
|
+
|
|
1084
|
+
const session = new PiAcpSession({
|
|
1085
|
+
sessionId: 's1',
|
|
1086
|
+
cwd: process.cwd(),
|
|
1087
|
+
mcpServers: [],
|
|
1088
|
+
proc: proc as any,
|
|
1089
|
+
conn: asAgentConn(conn),
|
|
1090
|
+
fileCommands: []
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
const p = session.prompt('hello')
|
|
1094
|
+
await session.cancel()
|
|
1095
|
+
proc.emit({ type: 'agent_settled' })
|
|
1096
|
+
|
|
1097
|
+
assert.equal(await p, 'cancelled')
|
|
1098
|
+
assert.deepEqual(
|
|
1099
|
+
conn.updates.filter(u => u.update.sessionUpdate === 'usage_update').map(u => u.update),
|
|
1100
|
+
[{ sessionUpdate: 'usage_update', used: 42, size: 100 }]
|
|
1101
|
+
)
|
|
1102
|
+
})
|