@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.
Files changed (134) hide show
  1. package/CREDITS.md +23 -0
  2. package/LICENSE +22 -0
  3. package/README.md +110 -0
  4. package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
  5. package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
  6. package/extensions/acp/cmd/pig-acp/go.mod +6 -0
  7. package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
  8. package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
  9. package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
  10. package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
  11. package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
  12. package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
  13. package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
  14. package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
  15. package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
  16. package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
  17. package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
  18. package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
  19. package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
  20. package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
  21. package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
  22. package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
  23. package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
  24. package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
  25. package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
  26. package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
  27. package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
  28. package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
  29. package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
  30. package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
  31. package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
  32. package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
  33. package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
  34. package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
  35. package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
  36. package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
  37. package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
  38. package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
  39. package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
  40. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
  41. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
  42. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
  43. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
  44. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
  45. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
  46. package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
  47. package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
  48. package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
  49. package/extensions/acp/cmd/pig-acp/main.go +177 -0
  50. package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
  51. package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
  52. package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
  53. package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
  54. package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
  55. package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
  56. package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
  57. package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
  58. package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
  59. package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
  60. package/extensions/acp/extension.go +63 -0
  61. package/extensions/acp/extension_test.go +111 -0
  62. package/extensions/acp/fakehost_test.go +548 -0
  63. package/extensions/acp/go.mod +6 -0
  64. package/extensions/acp/go.sum +2 -0
  65. package/package.json +41 -0
  66. package/port/PORT.md +147 -0
  67. package/port/interop/package-lock.json +89 -0
  68. package/port/interop/package.json +13 -0
  69. package/port/interop/run.mjs +229 -0
  70. package/port/mutate.mjs +110 -0
  71. package/port/mutation-results.txt +107 -0
  72. package/port/mutations.json +748 -0
  73. package/port/oracle/pi-acp/LICENSE +21 -0
  74. package/port/oracle/pi-acp/README.md +209 -0
  75. package/port/oracle/pi-acp/package.json +65 -0
  76. package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
  77. package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
  78. package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
  79. package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
  80. package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
  81. package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
  82. package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
  83. package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
  84. package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
  85. package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
  86. package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
  87. package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
  88. package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
  89. package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
  90. package/port/oracle/pi-acp/src/index.ts +82 -0
  91. package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
  92. package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
  93. package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
  94. package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
  95. package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
  96. package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
  97. package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
  98. package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
  99. package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
  100. package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
  101. package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
  102. package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
  103. package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
  104. package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
  105. package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
  106. package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
  107. package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
  108. package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
  109. package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
  110. package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
  111. package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
  112. package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
  113. package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
  114. package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
  115. package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
  116. package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
  117. package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
  118. package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
  119. package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
  120. package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
  121. package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
  122. package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
  123. package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
  124. package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
  125. package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
  126. package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
  127. package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
  128. package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
  129. package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
  130. package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
  131. package/port/schema/LICENSE +191 -0
  132. package/port/schema/schema.json +8015 -0
  133. package/port/upstream-tests.json +201 -0
  134. package/provenance.json +28 -0
@@ -0,0 +1,217 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpAgent } from '../../src/acp/agent.js'
4
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
5
+
6
+ class FakeSessions {
7
+ constructor(private readonly session: any) {}
8
+
9
+ async create() {
10
+ return this.session
11
+ }
12
+
13
+ maybeGet(sessionId: string) {
14
+ if (sessionId !== this.session.sessionId) return undefined
15
+ return this.session
16
+ }
17
+
18
+ get(sessionId: string) {
19
+ if (sessionId !== this.session.sessionId) {
20
+ throw new Error(`Unknown sessionId: ${sessionId}`)
21
+ }
22
+ return this.session
23
+ }
24
+ }
25
+
26
+ test('PiAcpAgent: newSession returns configOptions for model and thinking selectors', async () => {
27
+ const realSetTimeout = globalThis.setTimeout
28
+ ;(globalThis as any).setTimeout = () => 0 as any
29
+
30
+ try {
31
+ const conn = new FakeAgentSideConnection()
32
+ const session = {
33
+ sessionId: 's1',
34
+ cwd: process.cwd(),
35
+ proc: {
36
+ async getAvailableThinkingLevels() {
37
+ return ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
38
+ },
39
+ async getAvailableModels() {
40
+ return {
41
+ models: [
42
+ { provider: 'test', id: 'alpha', name: 'Alpha' },
43
+ { provider: 'test', id: 'beta', name: 'Beta' }
44
+ ]
45
+ }
46
+ },
47
+ async getState() {
48
+ return {
49
+ thinkingLevel: 'high',
50
+ model: { provider: 'test', id: 'beta' }
51
+ }
52
+ }
53
+ },
54
+ setStartupInfo() {},
55
+ sendStartupInfoIfPending() {}
56
+ }
57
+
58
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
59
+ ;(agent as any).sessions = new FakeSessions(session) as any
60
+
61
+ const result = await agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any)
62
+
63
+ assert.equal(result.models?.currentModelId, 'test/beta')
64
+ assert.equal(result.modes?.currentModeId, 'high')
65
+ assert.deepEqual(result.configOptions, [
66
+ {
67
+ type: 'select',
68
+ id: 'model',
69
+ category: 'model',
70
+ name: 'Model',
71
+ description: 'Select the model for this session',
72
+ currentValue: 'test/beta',
73
+ options: [
74
+ { value: 'test/alpha', name: 'test/Alpha', description: null },
75
+ { value: 'test/beta', name: 'test/Beta', description: null }
76
+ ]
77
+ },
78
+ {
79
+ type: 'select',
80
+ id: 'thought_level',
81
+ category: 'thought_level',
82
+ name: 'Thinking',
83
+ description: 'Set the reasoning effort for this session',
84
+ currentValue: 'high',
85
+ options: [
86
+ { value: 'off', name: 'Thinking: off', description: null },
87
+ { value: 'minimal', name: 'Thinking: minimal', description: null },
88
+ { value: 'low', name: 'Thinking: low', description: null },
89
+ { value: 'medium', name: 'Thinking: medium', description: null },
90
+ { value: 'high', name: 'Thinking: high', description: null },
91
+ { value: 'xhigh', name: 'Thinking: xhigh', description: null },
92
+ { value: 'max', name: 'Thinking: max', description: null }
93
+ ]
94
+ }
95
+ ])
96
+ } finally {
97
+ ;(globalThis as any).setTimeout = realSetTimeout
98
+ }
99
+ })
100
+
101
+ test('PiAcpAgent: setSessionConfigOption maps model changes to pi and emits config_option_update', async () => {
102
+ const conn = new FakeAgentSideConnection()
103
+ const state = {
104
+ thinkingLevel: 'medium',
105
+ model: { provider: 'test', id: 'alpha' }
106
+ }
107
+ const setModelCalls: Array<{ provider: string; modelId: string }> = []
108
+
109
+ const session = {
110
+ sessionId: 's1',
111
+ cwd: process.cwd(),
112
+ proc: {
113
+ async getAvailableThinkingLevels() {
114
+ return ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
115
+ },
116
+ async getAvailableModels() {
117
+ return {
118
+ models: [
119
+ { provider: 'test', id: 'alpha', name: 'Alpha' },
120
+ { provider: 'test', id: 'beta', name: 'Beta' }
121
+ ]
122
+ }
123
+ },
124
+ async getState() {
125
+ return state
126
+ },
127
+ async setModel(provider: string, modelId: string) {
128
+ setModelCalls.push({ provider, modelId })
129
+ state.model = { provider, id: modelId }
130
+ }
131
+ },
132
+ async publishContextUsage() {
133
+ // Context usage publishing is covered in test/unit/context-usage.test.ts.
134
+ }
135
+ }
136
+
137
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
138
+ ;(agent as any).sessions = new FakeSessions(session) as any
139
+
140
+ const result = await agent.setSessionConfigOption({
141
+ sessionId: 's1',
142
+ configId: 'model',
143
+ value: 'test/beta'
144
+ } as any)
145
+
146
+ assert.deepEqual(setModelCalls, [{ provider: 'test', modelId: 'beta' }])
147
+ assert.equal(result.configOptions.find(option => option.id === 'model')?.currentValue, 'test/beta')
148
+ assert.deepEqual(conn.updates, [
149
+ { sessionId: 's1', update: { sessionUpdate: 'current_mode_update', currentModeId: 'medium' } },
150
+ {
151
+ sessionId: 's1',
152
+ update: {
153
+ sessionUpdate: 'config_option_update',
154
+ configOptions: result.configOptions
155
+ }
156
+ }
157
+ ])
158
+ })
159
+
160
+ test('PiAcpAgent: setSessionConfigOption maps thought level changes to pi and emits sync updates', async () => {
161
+ const conn = new FakeAgentSideConnection()
162
+ const state = {
163
+ thinkingLevel: 'medium',
164
+ model: { provider: 'test', id: 'alpha' }
165
+ }
166
+ const thinkingLevels: string[] = []
167
+
168
+ const session = {
169
+ sessionId: 's1',
170
+ cwd: process.cwd(),
171
+ proc: {
172
+ async getAvailableThinkingLevels() {
173
+ return ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
174
+ },
175
+ async getAvailableModels() {
176
+ return {
177
+ models: [{ provider: 'test', id: 'alpha', name: 'Alpha' }]
178
+ }
179
+ },
180
+ async getState() {
181
+ return state
182
+ },
183
+ async setThinkingLevel(level: string) {
184
+ thinkingLevels.push(level)
185
+ state.thinkingLevel = level
186
+ }
187
+ }
188
+ }
189
+
190
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
191
+ ;(agent as any).sessions = new FakeSessions(session) as any
192
+
193
+ const result = await agent.setSessionConfigOption({
194
+ sessionId: 's1',
195
+ configId: 'thought_level',
196
+ value: 'xhigh'
197
+ } as any)
198
+
199
+ assert.deepEqual(thinkingLevels, ['xhigh'])
200
+ assert.equal(result.configOptions.find(option => option.id === 'thought_level')?.currentValue, 'xhigh')
201
+ assert.deepEqual(conn.updates, [
202
+ {
203
+ sessionId: 's1',
204
+ update: {
205
+ sessionUpdate: 'current_mode_update',
206
+ currentModeId: 'xhigh'
207
+ }
208
+ },
209
+ {
210
+ sessionId: 's1',
211
+ update: {
212
+ sessionUpdate: 'config_option_update',
213
+ configOptions: result.configOptions
214
+ }
215
+ }
216
+ ])
217
+ })
@@ -0,0 +1,170 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { PiAcpAgent } from '../../src/acp/agent.js'
7
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
8
+
9
+ test('PiAcpAgent: deleteSession removes stored session and session file', async () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-delete-test-'))
11
+ const sessionsDir = join(root, 'sessions', '--tmp--delete-project--')
12
+ const sessionFile = join(sessionsDir, '0000_delete_me.jsonl')
13
+ mkdirSync(sessionsDir, { recursive: true })
14
+ writeFileSync(
15
+ sessionFile,
16
+ '{"type":"session","version":3,"id":"sess-del-store","timestamp":"2026-06-16T00:00:00.000Z","cwd":"/tmp/delete-project"}\n',
17
+ 'utf-8'
18
+ )
19
+
20
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
21
+ process.env.PI_CODING_AGENT_DIR = root
22
+
23
+ const conn = new FakeAgentSideConnection()
24
+ const agent = new PiAcpAgent(asAgentConn(conn))
25
+
26
+ const storedSessionId = 'stored-session'
27
+ const storeDeletes: string[] = []
28
+
29
+ // Inject a SessionStore that tracks calls.
30
+ ;(agent as any).store = {
31
+ get(sessionId: string) {
32
+ if (sessionId !== storedSessionId) return null
33
+ return { sessionId, cwd: '/tmp/delete-project', sessionFile, updatedAt: new Date().toISOString() }
34
+ },
35
+ delete(sessionId: string) {
36
+ storeDeletes.push(sessionId)
37
+ },
38
+ upsert() {}
39
+ }
40
+
41
+ try {
42
+ const response = await agent.deleteSession({ sessionId: storedSessionId } as any)
43
+ assert.deepEqual(response, {})
44
+ assert.deepEqual(storeDeletes, [storedSessionId])
45
+ assert.equal(existsSync(sessionFile), false)
46
+ } finally {
47
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
48
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
49
+ }
50
+ })
51
+
52
+ test('PiAcpAgent: deleteSession finds session via pi discovery when SessionStore misses', async () => {
53
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-delete-discovery-'))
54
+ const sessionsDir = join(root, 'sessions', '--tmp--delete-discovery--')
55
+ const sessionFile = join(sessionsDir, '0000_pi_discovery.jsonl')
56
+ mkdirSync(sessionsDir, { recursive: true })
57
+ writeFileSync(
58
+ sessionFile,
59
+ JSON.stringify({
60
+ type: 'session',
61
+ version: 3,
62
+ id: 'pi-discovered-session',
63
+ timestamp: '2026-06-16T00:00:00.000Z',
64
+ cwd: '/tmp/delete-discovery'
65
+ }) + '\n',
66
+ 'utf-8'
67
+ )
68
+
69
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
70
+ process.env.PI_CODING_AGENT_DIR = root
71
+
72
+ const conn = new FakeAgentSideConnection()
73
+ const agent = new PiAcpAgent(asAgentConn(conn))
74
+
75
+ const storeDeletes: string[] = []
76
+
77
+ ;(agent as any).store = {
78
+ get() {
79
+ return null
80
+ },
81
+ delete(sessionId: string) {
82
+ storeDeletes.push(sessionId)
83
+ },
84
+ upsert() {}
85
+ }
86
+
87
+ try {
88
+ const response = await agent.deleteSession({ sessionId: 'pi-discovered-session' } as any)
89
+ assert.deepEqual(response, {})
90
+ assert.deepEqual(storeDeletes, ['pi-discovered-session'])
91
+ assert.equal(existsSync(sessionFile), false)
92
+ } finally {
93
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
94
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
95
+ }
96
+ })
97
+
98
+ test('PiAcpAgent: deleteSession succeeds idempotently for unknown sessionId', async () => {
99
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-delete-unknown-'))
100
+ const sessionsDir = join(root, 'sessions', '--tmp--delete-unknown--')
101
+ mkdirSync(sessionsDir, { recursive: true })
102
+
103
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
104
+ process.env.PI_CODING_AGENT_DIR = root
105
+
106
+ const conn = new FakeAgentSideConnection()
107
+ const agent = new PiAcpAgent(asAgentConn(conn))
108
+
109
+ // Per ACP session/delete semantics, deleting a non-existent session
110
+ // should succeed idempotently (return {} without error).
111
+ const storeDeletes: string[] = []
112
+ ;(agent as any).store = {
113
+ get() {
114
+ return null
115
+ },
116
+ delete(sessionId: string) {
117
+ storeDeletes.push(sessionId)
118
+ },
119
+ upsert() {}
120
+ }
121
+
122
+ try {
123
+ const response = await agent.deleteSession({ sessionId: 'non-existent-session' } as any)
124
+ assert.deepEqual(response, {})
125
+ assert.deepEqual(storeDeletes, [])
126
+ } finally {
127
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
128
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
129
+ }
130
+ })
131
+
132
+ test('PiAcpAgent: deleteSession survives missing session file', async () => {
133
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-delete-missingfile-'))
134
+ const sessionsDir = join(root, 'sessions', '--tmp--delete-missingfile--')
135
+ mkdirSync(sessionsDir, { recursive: true })
136
+ const nonExistentFile = join(sessionsDir, '0000_non_existent.jsonl')
137
+
138
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
139
+ process.env.PI_CODING_AGENT_DIR = root
140
+
141
+ const conn = new FakeAgentSideConnection()
142
+ const agent = new PiAcpAgent(asAgentConn(conn))
143
+
144
+ const storeDeletes: string[] = []
145
+
146
+ ;(agent as any).store = {
147
+ get(sessionId: string) {
148
+ if (sessionId !== 'missing-file-session') return null
149
+ return {
150
+ sessionId,
151
+ cwd: '/tmp/delete-missingfile',
152
+ sessionFile: nonExistentFile,
153
+ updatedAt: new Date().toISOString()
154
+ }
155
+ },
156
+ delete(sessionId: string) {
157
+ storeDeletes.push(sessionId)
158
+ },
159
+ upsert() {}
160
+ }
161
+
162
+ try {
163
+ const response = await agent.deleteSession({ sessionId: 'missing-file-session' } as any)
164
+ assert.deepEqual(response, {})
165
+ assert.deepEqual(storeDeletes, ['missing-file-session'])
166
+ } finally {
167
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
168
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
169
+ }
170
+ })
@@ -0,0 +1,239 @@
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 { PiAcpAgent } from '../../src/acp/agent.js'
7
+ import { PiRpcProcess } from '../../src/pi-rpc/process.js'
8
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
9
+
10
+ class FakeSessions {
11
+ restoredSession: any = null
12
+
13
+ constructor(private readonly buildSession: (sessionId: string, params: any) => any) {}
14
+
15
+ maybeGet(sessionId: string) {
16
+ return this.restoredSession?.sessionId === sessionId ? this.restoredSession : undefined
17
+ }
18
+
19
+ getOrCreate(sessionId: string, params: any) {
20
+ if (!this.restoredSession) {
21
+ this.restoredSession = this.buildSession(sessionId, params)
22
+ }
23
+ return this.restoredSession
24
+ }
25
+ }
26
+
27
+ test('PiAcpAgent: prompt auto-restores a missing session from SessionStore', async () => {
28
+ const conn = new FakeAgentSideConnection()
29
+ const promptCalls: Array<{ message: string; images: unknown[] }> = []
30
+ const spawnCalls: any[] = []
31
+ const storeUpserts: any[] = []
32
+
33
+ const sessions = new FakeSessions((sessionId, params) => ({
34
+ sessionId,
35
+ cwd: params.cwd,
36
+ proc: params.proc,
37
+ async prompt(message: string, images: unknown[]) {
38
+ promptCalls.push({ message, images })
39
+ return 'end_turn'
40
+ },
41
+ async cancel() {},
42
+ wasCancelRequested() {
43
+ return false
44
+ }
45
+ }))
46
+
47
+ const originalSpawn = PiRpcProcess.spawn
48
+ ;(PiRpcProcess as any).spawn = async (params: any) => {
49
+ spawnCalls.push(params)
50
+ return {
51
+ onEvent: () => () => {}
52
+ } as any
53
+ }
54
+
55
+ try {
56
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
57
+ ;(agent as any).sessions = sessions as any
58
+ ;(agent as any).store = {
59
+ get(sessionId: string) {
60
+ if (sessionId !== 'stored-session') return null
61
+ return {
62
+ sessionId,
63
+ cwd: '/tmp/store-project',
64
+ sessionFile: '/tmp/store-project/session.jsonl',
65
+ updatedAt: new Date().toISOString()
66
+ }
67
+ },
68
+ upsert(entry: any) {
69
+ storeUpserts.push(entry)
70
+ }
71
+ }
72
+
73
+ const result = await agent.prompt({
74
+ sessionId: 'stored-session',
75
+ prompt: [{ type: 'text', text: 'hello again' }]
76
+ } as any)
77
+
78
+ assert.equal(result.stopReason, 'end_turn')
79
+ assert.deepEqual(spawnCalls, [
80
+ {
81
+ cwd: '/tmp/store-project',
82
+ sessionPath: '/tmp/store-project/session.jsonl',
83
+ piCommand: process.env.PI_ACP_PI_COMMAND
84
+ }
85
+ ])
86
+ assert.deepEqual(promptCalls, [{ message: 'hello again', images: [] }])
87
+ assert.deepEqual(storeUpserts, [
88
+ {
89
+ sessionId: 'stored-session',
90
+ cwd: '/tmp/store-project',
91
+ sessionFile: '/tmp/store-project/session.jsonl'
92
+ }
93
+ ])
94
+ } finally {
95
+ PiRpcProcess.spawn = originalSpawn
96
+ }
97
+ })
98
+
99
+ test('PiAcpAgent: setSessionConfigOption auto-restores via pi session discovery when SessionStore misses', async () => {
100
+ const conn = new FakeAgentSideConnection()
101
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-restore-fallback-'))
102
+ const sessionsDir = join(root, 'sessions', '--tmp--fallback-project--')
103
+ const sessionFile = join(sessionsDir, '0000_restore_fallback.jsonl')
104
+ const prevAgentDir = process.env.PI_CODING_AGENT_DIR
105
+
106
+ mkdirSync(sessionsDir, { recursive: true })
107
+ writeFileSync(
108
+ sessionFile,
109
+ JSON.stringify({
110
+ type: 'session',
111
+ version: 3,
112
+ id: 'fallback-session',
113
+ timestamp: '2026-06-16T00:00:00.000Z',
114
+ cwd: '/tmp/fallback-project'
115
+ }) + '\n',
116
+ 'utf-8'
117
+ )
118
+
119
+ process.env.PI_CODING_AGENT_DIR = root
120
+
121
+ const storeUpserts: any[] = []
122
+ const setModelCalls: Array<{ provider: string; modelId: string }> = []
123
+ const spawnCalls: any[] = []
124
+ const state = {
125
+ thinkingLevel: 'medium',
126
+ model: { provider: 'test', id: 'alpha' }
127
+ }
128
+
129
+ const sessions = new FakeSessions((sessionId, params) => ({
130
+ sessionId,
131
+ cwd: params.cwd,
132
+ proc: params.proc,
133
+ async publishContextUsage() {
134
+ // Context usage publishing is covered in test/unit/context-usage.test.ts.
135
+ }
136
+ }))
137
+
138
+ const originalSpawn = PiRpcProcess.spawn
139
+ ;(PiRpcProcess as any).spawn = async (params: any) => {
140
+ spawnCalls.push(params)
141
+ return {
142
+ onEvent: () => () => {},
143
+ getAvailableThinkingLevels: async () => ['medium'],
144
+ getAvailableModels: async () => ({
145
+ models: [
146
+ { provider: 'test', id: 'alpha', name: 'Alpha' },
147
+ { provider: 'test', id: 'beta', name: 'Beta' }
148
+ ]
149
+ }),
150
+ getState: async () => state,
151
+ async setModel(provider: string, modelId: string) {
152
+ setModelCalls.push({ provider, modelId })
153
+ state.model = { provider, id: modelId }
154
+ }
155
+ } as any
156
+ }
157
+
158
+ try {
159
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
160
+ ;(agent as any).sessions = sessions as any
161
+ ;(agent as any).store = {
162
+ get() {
163
+ return null
164
+ },
165
+ upsert(entry: any) {
166
+ storeUpserts.push(entry)
167
+ }
168
+ }
169
+
170
+ const result = await agent.setSessionConfigOption({
171
+ sessionId: 'fallback-session',
172
+ configId: 'model',
173
+ value: 'test/beta'
174
+ } as any)
175
+
176
+ assert.deepEqual(spawnCalls, [
177
+ {
178
+ cwd: '/tmp/fallback-project',
179
+ sessionPath: sessionFile,
180
+ piCommand: process.env.PI_ACP_PI_COMMAND
181
+ }
182
+ ])
183
+ assert.deepEqual(setModelCalls, [{ provider: 'test', modelId: 'beta' }])
184
+ assert.equal(result.configOptions.find(option => option.id === 'model')?.currentValue, 'test/beta')
185
+ assert.deepEqual(storeUpserts, [
186
+ {
187
+ sessionId: 'fallback-session',
188
+ cwd: '/tmp/fallback-project',
189
+ sessionFile
190
+ },
191
+ {
192
+ sessionId: 'fallback-session',
193
+ cwd: '/tmp/fallback-project',
194
+ sessionFile
195
+ }
196
+ ])
197
+ assert.deepEqual(conn.updates, [
198
+ { sessionId: 'fallback-session', update: { sessionUpdate: 'current_mode_update', currentModeId: 'medium' } },
199
+ {
200
+ sessionId: 'fallback-session',
201
+ update: {
202
+ sessionUpdate: 'config_option_update',
203
+ configOptions: result.configOptions
204
+ }
205
+ }
206
+ ])
207
+ } finally {
208
+ PiRpcProcess.spawn = originalSpawn
209
+ if (prevAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR
210
+ else process.env.PI_CODING_AGENT_DIR = prevAgentDir
211
+ }
212
+ })
213
+
214
+ test('PiAcpAgent: cancel ignores stale session IDs without spawning a restore process', async () => {
215
+ const conn = new FakeAgentSideConnection()
216
+ const spawnCalls: any[] = []
217
+
218
+ const originalSpawn = PiRpcProcess.spawn
219
+ ;(PiRpcProcess as any).spawn = async (params: any) => {
220
+ spawnCalls.push(params)
221
+ return {
222
+ onEvent: () => () => {}
223
+ } as any
224
+ }
225
+
226
+ try {
227
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
228
+ ;(agent as any).sessions = new FakeSessions(() => {
229
+ throw new Error('cancel should not restore a missing session')
230
+ }) as any
231
+
232
+ await agent.cancel({ sessionId: 'stale-session' } as any)
233
+
234
+ assert.deepEqual(spawnCalls, [])
235
+ assert.deepEqual(conn.updates, [])
236
+ } finally {
237
+ PiRpcProcess.spawn = originalSpawn
238
+ }
239
+ })
@@ -0,0 +1,36 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ expandSlashCommand,
5
+ parseCommandArgs,
6
+ substituteArgs,
7
+ toAvailableCommands
8
+ } from '../../src/acp/slash-commands.js'
9
+
10
+ test('parseCommandArgs: handles quotes', () => {
11
+ assert.deepEqual(parseCommandArgs('a b'), ['a', 'b'])
12
+ assert.deepEqual(parseCommandArgs("'a b' c"), ['a b', 'c'])
13
+ assert.deepEqual(parseCommandArgs('"a b" c'), ['a b', 'c'])
14
+ })
15
+
16
+ test('substituteArgs: replaces $1.. and $@', () => {
17
+ assert.equal(substituteArgs('x=$1 y=$2 all=$@', ['one', 'two']).trim(), 'x=one y=two all=one two')
18
+ assert.equal(substituteArgs('$3', ['one']).trim(), '')
19
+ })
20
+
21
+ test('expandSlashCommand: expands known command', () => {
22
+ const cmds = [{ name: 'hello', description: '(user)', content: 'Say hi to $1', source: '(user)' }]
23
+
24
+ assert.equal(expandSlashCommand('/hello world', cmds as any), 'Say hi to world')
25
+ assert.equal(expandSlashCommand('/unknown world', cmds as any), '/unknown world')
26
+ assert.equal(expandSlashCommand('not a command', cmds as any), 'not a command')
27
+ })
28
+
29
+ test('toAvailableCommands: de-dupes by name (first wins)', () => {
30
+ const cmds = [
31
+ { name: 'x', description: 'first', content: '1', source: '(user)' },
32
+ { name: 'x', description: 'second', content: '2', source: '(project)' }
33
+ ]
34
+
35
+ assert.deepEqual(toAvailableCommands(cmds as any), [{ name: 'x', description: 'first' }])
36
+ })