@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,60 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpAgent } from '../../src/acp/agent.js'
4
+ import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
5
+
6
+ class FakeSessions {
7
+ constructor(private readonly session: any) {}
8
+ maybeGet(_id: string) {
9
+ return this.session
10
+ }
11
+ get(_id: string) {
12
+ return this.session
13
+ }
14
+ }
15
+
16
+ test('PiAcpAgent: /steering is handled adapter-side', async () => {
17
+ const conn = new FakeAgentSideConnection()
18
+ const proc = new FakePiRpcProcess() as any
19
+ proc.getState = async () => ({ steeringMode: 'one-at-a-time' })
20
+
21
+ const agent = new PiAcpAgent(asAgentConn(conn))
22
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc, fileCommands: [] }) as any
23
+
24
+ const res = await agent.prompt({
25
+ sessionId: 's1',
26
+ prompt: [{ type: 'text', text: '/steering' }]
27
+ } as any)
28
+
29
+ assert.equal(res.stopReason, 'end_turn')
30
+ assert.equal(proc.prompts.length, 0)
31
+ const last = conn.updates.at(-1)
32
+ assert.match((last as any).update.content.text, /Steering mode: one-at-a-time/)
33
+ })
34
+
35
+ test('PiAcpAgent: /name sets session display name adapter-side', async () => {
36
+ const conn = new FakeAgentSideConnection()
37
+ const proc = new FakePiRpcProcess() as any
38
+
39
+ let setTo: string | null = null
40
+ proc.setSessionName = async (name: string) => {
41
+ setTo = name
42
+ }
43
+
44
+ const agent = new PiAcpAgent(asAgentConn(conn))
45
+ ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc, fileCommands: [] }) as any
46
+
47
+ const res = await agent.prompt({
48
+ sessionId: 's1',
49
+ prompt: [{ type: 'text', text: '/name My Session' }]
50
+ } as any)
51
+
52
+ assert.equal(res.stopReason, 'end_turn')
53
+ assert.equal(proc.prompts.length, 0)
54
+ assert.equal(setTo, 'My Session')
55
+ const info = conn.updates.find(u => (u as any).update?.sessionUpdate === 'session_info_update')
56
+ assert.equal((info as any)?.update?.title, 'My Session')
57
+
58
+ const last = conn.updates.at(-1)
59
+ assert.match((last as any).update.content.text, /Session name set: My Session/)
60
+ })
@@ -0,0 +1,204 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpAgent } from '../../src/acp/agent.js'
4
+ import { PiAcpSession } from '../../src/acp/session.js'
5
+ import { SESSION_STATS_TIMEOUT_MS } from '../../src/pi-rpc/process.js'
6
+ import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
7
+
8
+ class FakeSessions {
9
+ constructor(private readonly session: any) {}
10
+
11
+ async create() {
12
+ return this.session
13
+ }
14
+
15
+ maybeGet(sessionId: string) {
16
+ if (sessionId !== this.session.sessionId) return undefined
17
+ return this.session
18
+ }
19
+
20
+ get(sessionId: string) {
21
+ if (sessionId !== this.session.sessionId) {
22
+ throw new Error(`Unknown sessionId: ${sessionId}`)
23
+ }
24
+ return this.session
25
+ }
26
+ }
27
+
28
+ /** Let the `setTimeout(..., 0)` continuations scheduled after session/new or session/load run. */
29
+ async function drainScheduledWork(): Promise<void> {
30
+ for (let i = 0; i < 5; i += 1) await new Promise(resolve => setTimeout(resolve, 0))
31
+ }
32
+
33
+ function makeSession(proc: FakePiRpcProcess, conn: FakeAgentSideConnection): PiAcpSession {
34
+ return new PiAcpSession({
35
+ sessionId: 's1',
36
+ cwd: process.cwd(),
37
+ mcpServers: [],
38
+ proc: proc as any,
39
+ conn: asAgentConn(conn),
40
+ fileCommands: []
41
+ })
42
+ }
43
+
44
+ test('PiAcpSession: context usage request specifies the auxiliary timeout', async () => {
45
+ const conn = new FakeAgentSideConnection()
46
+ const proc = new FakePiRpcProcess()
47
+ let requestedTimeout: number | undefined
48
+ proc.getSessionStats = async (timeoutMs?: number) => {
49
+ requestedTimeout = timeoutMs
50
+ return { contextUsage: { tokens: 100, contextWindow: 100_000 } }
51
+ }
52
+
53
+ await makeSession(proc, conn).publishContextUsage()
54
+
55
+ assert.equal(requestedTimeout, SESSION_STATS_TIMEOUT_MS)
56
+ assert.deepEqual(conn.updates, [
57
+ { sessionId: 's1', update: { sessionUpdate: 'usage_update', used: 100, size: 100_000 } }
58
+ ])
59
+ })
60
+
61
+ test('PiAcpAgent: newSession publishes context usage only after the response is returned', async () => {
62
+ const conn = new FakeAgentSideConnection()
63
+ const proc = new FakePiRpcProcess()
64
+ proc.sessionStats = { contextUsage: { tokens: 1_234, contextWindow: 100_000 } }
65
+ proc.getState = async () => ({ thinkingLevel: 'medium' })
66
+ const session = makeSession(proc, conn)
67
+
68
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
69
+ ;(agent as any).sessions = new FakeSessions(session) as any
70
+
71
+ const result = await agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any)
72
+ assert.equal(result.sessionId, 's1')
73
+ assert.equal(
74
+ conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
75
+ false
76
+ )
77
+
78
+ await drainScheduledWork()
79
+
80
+ assert.deepEqual(
81
+ conn.updates.filter(u => u.update.sessionUpdate === 'usage_update'),
82
+ [{ sessionId: 's1', update: { sessionUpdate: 'usage_update', used: 1_234, size: 100_000 } }]
83
+ )
84
+ })
85
+
86
+ test('PiAcpAgent: newSession tolerates a failing get_session_stats', async () => {
87
+ const conn = new FakeAgentSideConnection()
88
+ const proc = new FakePiRpcProcess()
89
+ proc.sessionStatsError = new Error('pi get_session_stats failed: unsupported')
90
+ proc.getState = async () => ({ thinkingLevel: 'medium' })
91
+ const session = makeSession(proc, conn)
92
+
93
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
94
+ ;(agent as any).sessions = new FakeSessions(session) as any
95
+
96
+ const result = await agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any)
97
+ assert.equal(result.sessionId, 's1')
98
+
99
+ await drainScheduledWork()
100
+
101
+ assert.equal(
102
+ conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
103
+ false
104
+ )
105
+ })
106
+
107
+ test('PiAcpAgent: switching the model config option refreshes context usage', async () => {
108
+ const conn = new FakeAgentSideConnection()
109
+ const proc = new FakePiRpcProcess()
110
+ const state: { thinkingLevel: string; model: { provider: string; id: string } } = {
111
+ thinkingLevel: 'medium',
112
+ model: { provider: 'test', id: 'alpha' }
113
+ }
114
+ proc.getAvailableModels = async () => ({
115
+ models: [
116
+ { provider: 'test', id: 'alpha', name: 'Alpha' },
117
+ { provider: 'test', id: 'beta', name: 'Beta' }
118
+ ]
119
+ })
120
+ proc.getState = async () => state
121
+ ;(proc as any).setModel = async (provider: string, modelId: string) => {
122
+ state.model = { provider, id: modelId }
123
+ proc.sessionStats = { contextUsage: { tokens: 500, contextWindow: modelId === 'beta' ? 200_000 : 100_000 } }
124
+ }
125
+ proc.sessionStats = { contextUsage: { tokens: 500, contextWindow: 100_000 } }
126
+
127
+ const session = makeSession(proc, conn)
128
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
129
+ ;(agent as any).sessions = new FakeSessions(session) as any
130
+
131
+ await agent.setSessionConfigOption({ sessionId: 's1', configId: 'model', value: 'test/beta' } as any)
132
+
133
+ assert.deepEqual(
134
+ conn.updates.map(u => u.update.sessionUpdate),
135
+ ['current_mode_update', 'config_option_update', 'usage_update']
136
+ )
137
+ assert.deepEqual(conn.updates.at(-1), {
138
+ sessionId: 's1',
139
+ update: { sessionUpdate: 'usage_update', used: 500, size: 200_000 }
140
+ })
141
+ })
142
+
143
+ test('PiAcpAgent: unstable_setSessionModel refreshes context usage', async () => {
144
+ const conn = new FakeAgentSideConnection()
145
+ const proc = new FakePiRpcProcess()
146
+ const state: { thinkingLevel: string; model: { provider: string; id: string } } = {
147
+ thinkingLevel: 'medium',
148
+ model: { provider: 'test', id: 'alpha' }
149
+ }
150
+ proc.getAvailableModels = async () => ({
151
+ models: [
152
+ { provider: 'test', id: 'alpha', name: 'Alpha' },
153
+ { provider: 'test', id: 'beta', name: 'Beta' }
154
+ ]
155
+ })
156
+ proc.getState = async () => state
157
+ ;(proc as any).setModel = async (provider: string, modelId: string) => {
158
+ state.model = { provider, id: modelId }
159
+ proc.sessionStats = { contextUsage: { tokens: 700, contextWindow: modelId === 'beta' ? 200_000 : 100_000 } }
160
+ }
161
+ proc.sessionStats = { contextUsage: { tokens: 700, contextWindow: 100_000 } }
162
+
163
+ const session = makeSession(proc, conn)
164
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
165
+ ;(agent as any).sessions = new FakeSessions(session) as any
166
+
167
+ await agent.unstable_setSessionModel({ sessionId: 's1', modelId: 'test/beta' })
168
+
169
+ assert.equal(proc.getSessionStatsCount, 1)
170
+ assert.deepEqual(
171
+ conn.updates.map(u => u.update.sessionUpdate),
172
+ ['current_mode_update', 'config_option_update', 'usage_update']
173
+ )
174
+ assert.deepEqual(conn.updates.at(-1), {
175
+ sessionId: 's1',
176
+ update: { sessionUpdate: 'usage_update', used: 700, size: 200_000 }
177
+ })
178
+ })
179
+
180
+ test('PiAcpAgent: switching the thinking level does not publish context usage', async () => {
181
+ const conn = new FakeAgentSideConnection()
182
+ const proc = new FakePiRpcProcess()
183
+ const state: { thinkingLevel: string; model: { provider: string; id: string } } = {
184
+ thinkingLevel: 'medium',
185
+ model: { provider: 'test', id: 'alpha' }
186
+ }
187
+ proc.getState = async () => state
188
+ ;(proc as any).setThinkingLevel = async (level: string) => {
189
+ state.thinkingLevel = level
190
+ }
191
+ proc.sessionStats = { contextUsage: { tokens: 500, contextWindow: 100_000 } }
192
+
193
+ const session = makeSession(proc, conn)
194
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
195
+ ;(agent as any).sessions = new FakeSessions(session) as any
196
+
197
+ await agent.setSessionConfigOption({ sessionId: 's1', configId: 'thought_level', value: 'high' } as any)
198
+
199
+ assert.equal(proc.getSessionStatsCount, 0)
200
+ assert.equal(
201
+ conn.updates.some(u => u.update.sessionUpdate === 'usage_update'),
202
+ false
203
+ )
204
+ })
@@ -0,0 +1,19 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ // Minimal local impl (mirrors src/acp/agent.ts behavior)
5
+ function mergeCommands(a: Array<{ name: string }>, b: Array<{ name: string }>) {
6
+ const out: Array<{ name: string }> = []
7
+ const seen = new Set<string>()
8
+ for (const c of [...a, ...b]) {
9
+ if (seen.has(c.name)) continue
10
+ seen.add(c.name)
11
+ out.push(c)
12
+ }
13
+ return out
14
+ }
15
+
16
+ test('mergeCommands: preserves order and de-dupes (first wins)', () => {
17
+ const res = mergeCommands([{ name: 'a' }, { name: 'b' }], [{ name: 'b' }, { name: 'c' }])
18
+ assert.deepEqual(res, [{ name: 'a' }, { name: 'b' }, { name: 'c' }])
19
+ })
@@ -0,0 +1,268 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { SessionManager } from '../../src/acp/session.js'
7
+ import { SessionStore } from '../../src/acp/session-store.js'
8
+ import { PiAcpAgent } from '../../src/acp/agent.js'
9
+ import { PiRpcProcess } from '../../src/pi-rpc/process.js'
10
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
11
+
12
+ function fixture() {
13
+ const conn = new FakeAgentSideConnection()
14
+ const state = { thinkingLevel: 'max', model: { provider: 'test', id: 'reasoning' } }
15
+ const calls: string[] = []
16
+ const proc = {
17
+ onEvent: () => () => {},
18
+ getMessages: async () => ({ messages: [] }),
19
+ getState: async () => state,
20
+ getAvailableModels: async () => ({
21
+ models: [
22
+ { provider: 'test', id: 'reasoning' },
23
+ { provider: 'test', id: 'plain' }
24
+ ]
25
+ }),
26
+ getAvailableThinkingLevels: async () => (state.model.id === 'plain' ? ['off'] : ['low', 'high', 'max']),
27
+ async setThinkingLevel(level: string) {
28
+ calls.push(level)
29
+ state.thinkingLevel = 'max'
30
+ },
31
+ async setModel(provider: string, id: string) {
32
+ calls.push(id)
33
+ state.model = { provider, id }
34
+ state.thinkingLevel = id === 'plain' ? 'off' : 'max'
35
+ }
36
+ }
37
+ const session = {
38
+ sessionId: 's1',
39
+ cwd: process.cwd(),
40
+ proc,
41
+ setStartupInfo() {},
42
+ sendStartupInfoIfPending() {},
43
+ async publishContextUsage() {}
44
+ }
45
+ const agent = new PiAcpAgent(asAgentConn(conn))
46
+ Reflect.set(agent, 'sessions', {
47
+ maybeGet: () => session,
48
+ get: () => session,
49
+ create: async () => session,
50
+ close() {}
51
+ })
52
+ return { agent, conn, proc, state, calls }
53
+ }
54
+
55
+ function assertUpdates(conn: FakeAgentSideConnection, level: string, levels: string[]) {
56
+ assert.equal(conn.updates.length, 2)
57
+ assert.deepEqual(conn.updates[0], {
58
+ sessionId: 's1',
59
+ update: { sessionUpdate: 'current_mode_update', currentModeId: level }
60
+ })
61
+ const update = conn.updates[1].update as {
62
+ configOptions: Array<{ id: string; currentValue: string; options: Array<{ value: string }> }>
63
+ }
64
+ const option = update.configOptions.find(option => option.id === 'thought_level')!
65
+ assert.equal(option.currentValue, level)
66
+ assert.deepEqual(
67
+ option.options.map(option => option.value),
68
+ levels
69
+ )
70
+ }
71
+
72
+ for (const legacy of [false, true]) {
73
+ for (const requested of ['max', 'xhigh', 'ordinary', ' mean ', ' ']) {
74
+ test(`${legacy ? 'legacy' : 'config'} reasoning setter reports actual max for ${requested}`, async () => {
75
+ const { agent, conn, calls } = fixture()
76
+ if (legacy) await agent.setSessionMode({ sessionId: 's1', modeId: requested })
77
+ else {
78
+ const result = await agent.setSessionConfigOption({
79
+ sessionId: 's1',
80
+ configId: 'thought_level',
81
+ value: requested
82
+ })
83
+ assert.equal(result.configOptions.find(option => option.id === 'thought_level')?.currentValue, 'max')
84
+ }
85
+ assert.deepEqual(calls, [requested])
86
+ assertUpdates(conn, 'max', ['low', 'high', 'max'])
87
+ })
88
+ }
89
+ test(`${legacy ? 'legacy' : 'config'} reasoning setter reports an opaque applied level`, async () => {
90
+ const { agent, conn, proc, state, calls } = fixture()
91
+ proc.getAvailableThinkingLevels = async () => ['ordinary', 'mean']
92
+ proc.setThinkingLevel = async level => {
93
+ calls.push(level)
94
+ state.thinkingLevel = 'mean'
95
+ }
96
+ if (legacy) await agent.setSessionMode({ sessionId: 's1', modeId: 'ordinary' })
97
+ else {
98
+ const result = await agent.setSessionConfigOption({
99
+ sessionId: 's1',
100
+ configId: 'thought_level',
101
+ value: 'ordinary'
102
+ })
103
+ assert.equal(result.configOptions.find(option => option.id === 'thought_level')?.currentValue, 'mean')
104
+ }
105
+ assert.deepEqual(calls, ['ordinary'])
106
+ assertUpdates(conn, 'mean', ['ordinary', 'mean'])
107
+ })
108
+ test(`${legacy ? 'legacy' : 'config'} model setter refreshes model-specific levels and current mode`, async () => {
109
+ const { agent, conn } = fixture()
110
+ for (const model of ['plain', 'reasoning']) {
111
+ conn.updates.length = 0
112
+ if (legacy) await agent.unstable_setSessionModel({ sessionId: 's1', modelId: `test/${model}` })
113
+ else await agent.setSessionConfigOption({ sessionId: 's1', configId: 'model', value: `test/${model}` })
114
+ assertUpdates(conn, model === 'plain' ? 'off' : 'max', model === 'plain' ? ['off'] : ['low', 'high', 'max'])
115
+ }
116
+ })
117
+ for (const failure of ['state', 'discovery', 'empty', 'non-string', 'inconsistent']) {
118
+ test(`${legacy ? 'legacy' : 'config'} reasoning setter emits no success on ${failure} read failure`, async () => {
119
+ const { agent, conn, proc, state } = fixture()
120
+ if (failure === 'state')
121
+ proc.getState = async () => {
122
+ throw new Error('state failed')
123
+ }
124
+ if (failure === 'discovery')
125
+ proc.getAvailableThinkingLevels = async () => {
126
+ throw new Error('discovery failed')
127
+ }
128
+ if (failure === 'empty' || failure === 'non-string' || failure === 'inconsistent')
129
+ proc.setThinkingLevel = async () => {
130
+ Reflect.set(state, 'thinkingLevel', failure === 'empty' ? '' : failure === 'non-string' ? 1 : 'medium')
131
+ }
132
+ await assert.rejects(
133
+ legacy
134
+ ? agent.setSessionMode({ sessionId: 's1', modeId: 'max' })
135
+ : agent.setSessionConfigOption({ sessionId: 's1', configId: 'thought_level', value: 'max' })
136
+ )
137
+ assert.deepEqual(conn.updates, [])
138
+ })
139
+ }
140
+ }
141
+
142
+ test('invalid configuration and legacy mode requests do not mutate Pi', async () => {
143
+ const { agent, calls, conn } = fixture()
144
+ for (const request of [
145
+ { configId: 'thought_level', value: '' },
146
+ { configId: 'unknown', value: 'high' },
147
+ { configId: 'thought_level', value: 1 }
148
+ ]) {
149
+ await assert.rejects(
150
+ agent.setSessionConfigOption({ sessionId: 's1', ...request } as Parameters<
151
+ PiAcpAgent['setSessionConfigOption']
152
+ >[0]),
153
+ { code: -32602 }
154
+ )
155
+ }
156
+ for (const modeId of ['', 1, null]) {
157
+ await assert.rejects(
158
+ agent.setSessionMode({ sessionId: 's1', modeId } as Parameters<PiAcpAgent['setSessionMode']>[0]),
159
+ { code: -32602 }
160
+ )
161
+ }
162
+ assert.deepEqual(calls, [])
163
+ assert.deepEqual(conn.updates, [])
164
+ })
165
+
166
+ for (const load of [false, true]) {
167
+ for (const levels of [['off'], ['low', 'high', 'max'], ['ordinary', 'mean']]) {
168
+ test(`${load ? 'load' : 'new'} session advertises exact ${levels} levels`, async t => {
169
+ const { agent, proc, state } = fixture()
170
+ state.thinkingLevel = levels[levels.length - 1]
171
+ proc.getAvailableThinkingLevels = async () => levels
172
+ t.mock.method(globalThis, 'setTimeout', () => 0 as unknown as ReturnType<typeof setTimeout>)
173
+ t.mock.method(PiRpcProcess, 'spawn', async () => proc as unknown as PiRpcProcess)
174
+ Reflect.set(agent, 'store', {
175
+ get: () => ({ cwd: process.cwd(), sessionFile: '/tmp/thinking-test.jsonl' }),
176
+ upsert() {},
177
+ delete() {}
178
+ })
179
+ const result = load
180
+ ? await agent.loadSession({ sessionId: 's1', cwd: process.cwd(), mcpServers: [] })
181
+ : await agent.newSession({ cwd: process.cwd(), mcpServers: [] })
182
+ assert.equal(result.modes?.currentModeId, state.thinkingLevel)
183
+ assert.deepEqual(
184
+ result.modes?.availableModes.map(mode => mode.id),
185
+ levels
186
+ )
187
+ const option = result.configOptions?.find(option => option.id === 'thought_level')
188
+ assert.equal(option?.currentValue, state.thinkingLevel)
189
+ assert.deepEqual(
190
+ option?.options.map(option => ('value' in option ? option.value : undefined)),
191
+ levels
192
+ )
193
+ })
194
+ }
195
+ }
196
+
197
+ for (const failure of ['discovery', 'missing-current', 'inconsistent-current', 'state']) {
198
+ test(`load configuration ${failure} failure closes restored child and preserves history for retry`, async t => {
199
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-load-thinking-'))
200
+ t.after(() => rmSync(root, { recursive: true, force: true }))
201
+ t.mock.method(globalThis, 'setTimeout', () => 0 as unknown as ReturnType<typeof setTimeout>)
202
+ const sessionFile = join(root, 'history.jsonl')
203
+ writeFileSync(sessionFile, 'persisted history\n')
204
+ const store = new SessionStore(join(root, 'map.json'))
205
+ store.upsert({ sessionId: 's1', cwd: root, sessionFile })
206
+ const { agent, conn, proc } = fixture()
207
+ const sessions = new SessionManager()
208
+ Reflect.set(agent, 'sessions', sessions)
209
+ Reflect.set(agent, 'store', store)
210
+ let existingDisposed = 0
211
+ let restoredDisposed = 0
212
+ let historyReads = 0
213
+ let shouldFail = true
214
+ let spawns = 0
215
+ sessions.getOrCreate('existing', {
216
+ cwd: root,
217
+ mcpServers: [],
218
+ conn: asAgentConn(conn),
219
+ proc: {
220
+ onEvent: () => () => {},
221
+ dispose() {
222
+ existingDisposed++
223
+ }
224
+ } as unknown as PiRpcProcess
225
+ })
226
+ t.after(() => sessions.disposeAll())
227
+ t.mock.method(PiRpcProcess, 'spawn', async () => {
228
+ spawns++
229
+ return {
230
+ ...proc,
231
+ dispose() {
232
+ restoredDisposed++
233
+ },
234
+ async getState() {
235
+ if (shouldFail && failure === 'state') throw new Error('state unavailable')
236
+ if (shouldFail && failure === 'missing-current') return {}
237
+ if (shouldFail && failure === 'inconsistent-current') return { thinkingLevel: 'medium' }
238
+ return proc.getState()
239
+ },
240
+ async getAvailableThinkingLevels() {
241
+ if (shouldFail && failure === 'discovery') throw new Error('discovery unavailable')
242
+ return proc.getAvailableThinkingLevels()
243
+ },
244
+ async getMessages() {
245
+ historyReads++
246
+ return { messages: [{ role: 'user', content: 'persisted prompt' }] }
247
+ }
248
+ } as unknown as PiRpcProcess
249
+ })
250
+ await assert.rejects(agent.loadSession({ sessionId: 's1', cwd: root, mcpServers: [] }))
251
+ assert.equal(restoredDisposed, 1)
252
+ assert.equal(sessions.maybeGet('s1'), undefined)
253
+ assert.ok(sessions.maybeGet('existing'))
254
+ assert.equal(existingDisposed, 0)
255
+ assert.equal(historyReads, 0)
256
+ assert.equal(conn.updates.length, 0)
257
+ assert.equal(readFileSync(sessionFile, 'utf8'), 'persisted history\n')
258
+ assert.equal(store.get('s1')?.sessionFile, sessionFile)
259
+ shouldFail = false
260
+ const result = await agent.loadSession({ sessionId: 's1', cwd: root, mcpServers: [] })
261
+ assert.equal(result.modes?.currentModeId, 'max')
262
+ assert.equal(spawns, 2)
263
+ assert.equal(restoredDisposed, 1)
264
+ assert.equal(historyReads, 1)
265
+ assert.ok(conn.updates.some(update => update.update.sessionUpdate === 'user_message_chunk'))
266
+ assert.equal(readFileSync(sessionFile, 'utf8'), 'persisted history\n')
267
+ })
268
+ }
@@ -0,0 +1,51 @@
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
+ closeCalls: string[] = []
8
+
9
+ constructor(private readonly session: any) {}
10
+
11
+ async create(_params: any) {
12
+ return this.session
13
+ }
14
+
15
+ close(sessionId: string) {
16
+ this.closeCalls.push(sessionId)
17
+ }
18
+ }
19
+
20
+ test('PiAcpAgent: newSession throws AUTH_REQUIRED when pi reports zero available models', async () => {
21
+ const conn = new FakeAgentSideConnection()
22
+
23
+ const session = {
24
+ sessionId: 's1',
25
+ cwd: process.cwd(),
26
+ proc: {
27
+ async getAvailableModels() {
28
+ return { models: [] }
29
+ },
30
+ async getState() {
31
+ return { thinkingLevel: 'medium', model: null }
32
+ }
33
+ }
34
+ }
35
+
36
+ const sessions = new FakeSessions(session)
37
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
38
+ ;(agent as any).sessions = sessions as any
39
+
40
+ let threw = false
41
+ try {
42
+ await agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any)
43
+ } catch (e: any) {
44
+ threw = true
45
+ assert.equal(e?.code, -32000)
46
+ assert.match(String(e?.message), /Configure an API key or log in with an OAuth provider/i)
47
+ }
48
+
49
+ assert.equal(threw, true)
50
+ assert.deepEqual(sessions.closeCalls, ['s1'])
51
+ })
@@ -0,0 +1,26 @@
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
+ test('PiAcpAgent: newSession returns a helpful Internal error when pi is not installed', async () => {
7
+ const prevPiCmd = process.env.PI_ACP_PI_COMMAND
8
+ process.env.PI_ACP_PI_COMMAND = 'pi-does-not-exist-12345'
9
+
10
+ try {
11
+ const conn = new FakeAgentSideConnection()
12
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
13
+
14
+ await assert.rejects(
15
+ () => agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any),
16
+ (e: any) =>
17
+ e?.code === -32603 &&
18
+ String(e?.message ?? '')
19
+ .toLowerCase()
20
+ .includes('executable not found')
21
+ )
22
+ } finally {
23
+ if (prevPiCmd == null) delete process.env.PI_ACP_PI_COMMAND
24
+ else process.env.PI_ACP_PI_COMMAND = prevPiCmd
25
+ }
26
+ })