@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,148 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, 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 { SessionStore } from '../../src/acp/session-store.js'
8
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
9
+
10
+ class FakeSessions {
11
+ closeCalls: string[] = []
12
+
13
+ constructor(private readonly session: any) {}
14
+
15
+ async create() {
16
+ return this.session
17
+ }
18
+
19
+ close(sessionId: string) {
20
+ this.closeCalls.push(sessionId)
21
+ }
22
+ }
23
+
24
+ test('PiAcpAgent: newSession returns AUTH_REQUIRED when pi reports an auth error after spawn', async () => {
25
+ const conn = new FakeAgentSideConnection()
26
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-runtime-auth-'))
27
+ const sessionFile = join(root, 'sessions', 'failed.jsonl')
28
+ const sessionMapPath = join(root, 'session-map.json')
29
+
30
+ mkdirSync(join(root, 'sessions'), { recursive: true })
31
+ writeFileSync(
32
+ sessionFile,
33
+ JSON.stringify({
34
+ type: 'session',
35
+ version: 3,
36
+ id: 's-auth',
37
+ timestamp: '2026-05-07T00:00:00.000Z',
38
+ cwd: process.cwd()
39
+ }) + '\n',
40
+ 'utf-8'
41
+ )
42
+
43
+ const session = {
44
+ sessionId: 's-auth',
45
+ cwd: process.cwd(),
46
+ proc: {
47
+ async getAvailableModels() {
48
+ throw new Error('Authentication required: missing key')
49
+ },
50
+ async getState() {
51
+ return { thinkingLevel: 'medium', model: null, sessionFile }
52
+ }
53
+ }
54
+ }
55
+
56
+ const sessions = new FakeSessions(session)
57
+ const store = new SessionStore(sessionMapPath)
58
+ store.upsert({ sessionId: 's-auth', cwd: process.cwd(), sessionFile })
59
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
60
+ ;(agent as any).sessions = sessions as any
61
+ ;(agent as any).store = store as any
62
+
63
+ await assert.rejects(
64
+ () => agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any),
65
+ (e: any) => e?.code === -32000
66
+ )
67
+
68
+ assert.deepEqual(sessions.closeCalls, ['s-auth'])
69
+ assert.equal(existsSync(sessionFile), false)
70
+ assert.equal(store.get('s-auth'), null)
71
+ })
72
+
73
+ test('PiAcpAgent: newSession returns Internal error on non-auth model probe failures after spawn', async () => {
74
+ const conn = new FakeAgentSideConnection()
75
+
76
+ const session = {
77
+ sessionId: 's-internal',
78
+ cwd: process.cwd(),
79
+ proc: {
80
+ async getAvailableModels() {
81
+ throw new Error('socket hang up')
82
+ },
83
+ async getState() {
84
+ return { thinkingLevel: 'medium', model: null }
85
+ }
86
+ }
87
+ }
88
+
89
+ const sessions = new FakeSessions(session)
90
+ const agent = new PiAcpAgent(asAgentConn(conn), {} as any)
91
+ ;(agent as any).sessions = sessions as any
92
+
93
+ await assert.rejects(
94
+ () => agent.newSession({ cwd: process.cwd(), mcpServers: [] } as any),
95
+ (e: any) => e?.code === -32603 && String(e?.message ?? '').includes('socket hang up')
96
+ )
97
+
98
+ assert.deepEqual(sessions.closeCalls, ['s-internal'])
99
+ })
100
+
101
+ for (const failure of ['discovery', 'auth', 'invalid-current', 'inconsistent-current', 'state']) {
102
+ test(`PiAcpAgent: cleans up only the new session after ${failure} configuration failure`, async t => {
103
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-thinking-failure-'))
104
+ t.after(() => rmSync(root, { recursive: true, force: true }))
105
+ const sessionFile = join(root, 'failed.jsonl')
106
+ const existingFile = join(root, 'existing.jsonl')
107
+ writeFileSync(sessionFile, 'new session\n')
108
+ writeFileSync(existingFile, 'existing session\n')
109
+ const store = new SessionStore(join(root, 'map.json'))
110
+ store.upsert({ sessionId: 'failed', cwd: root, sessionFile })
111
+ store.upsert({ sessionId: 'existing', cwd: root, sessionFile: existingFile })
112
+ const session = {
113
+ sessionId: 'failed',
114
+ cwd: root,
115
+ proc: {
116
+ async getAvailableModels() {
117
+ return { models: [{ provider: 'test', id: 'model' }] }
118
+ },
119
+ async getState() {
120
+ if (failure === 'state') throw new Error('state read failed')
121
+ return {
122
+ sessionFile,
123
+ thinkingLevel: failure === 'invalid-current' ? '' : failure === 'inconsistent-current' ? 'medium' : 'max'
124
+ }
125
+ },
126
+ async getAvailableThinkingLevels() {
127
+ if (failure === 'discovery') throw new Error('discovery failed')
128
+ if (failure === 'auth') throw new Error('Authentication required: missing key')
129
+ return ['low', 'high', 'max']
130
+ }
131
+ }
132
+ }
133
+ const sessions = new FakeSessions(session)
134
+ const conn = new FakeAgentSideConnection()
135
+ const agent = new PiAcpAgent(asAgentConn(conn))
136
+ Reflect.set(agent, 'sessions', sessions)
137
+ Reflect.set(agent, 'store', store)
138
+ await assert.rejects(agent.newSession({ cwd: root, mcpServers: [] }), {
139
+ code: failure === 'auth' ? -32000 : -32603
140
+ })
141
+ assert.deepEqual(sessions.closeCalls, ['failed'])
142
+ assert.equal(existsSync(sessionFile), false)
143
+ assert.equal(store.get('failed'), null)
144
+ assert.equal(existsSync(existingFile), true)
145
+ assert.equal(store.get('existing')?.sessionFile, existingFile)
146
+ assert.deepEqual(conn.updates, [])
147
+ })
148
+ }
@@ -0,0 +1,44 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { defaultPiCommand, shouldUseShellForPiCommand } from '../../src/pi-rpc/command.js'
4
+
5
+ test('defaultPiCommand: uses pi.cmd on Windows and pi elsewhere', () => {
6
+ const originalPlatform = process.platform
7
+
8
+ try {
9
+ Object.defineProperty(process, 'platform', { value: 'win32' })
10
+ assert.equal(defaultPiCommand(), 'pi.cmd')
11
+
12
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
13
+ assert.equal(defaultPiCommand(), 'pi')
14
+ } finally {
15
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
16
+ }
17
+ })
18
+
19
+ test('shouldUseShellForPiCommand: enables shell for Windows cmd launchers only', () => {
20
+ const originalPlatform = process.platform
21
+ Object.defineProperty(process, 'platform', { value: 'win32' })
22
+
23
+ try {
24
+ assert.equal(shouldUseShellForPiCommand('pi.cmd'), true)
25
+ assert.equal(shouldUseShellForPiCommand('C:\\Users\\me\\AppData\\Roaming\\npm\\pi.CMD'), true)
26
+ assert.equal(shouldUseShellForPiCommand('pi.bat'), true)
27
+ assert.equal(shouldUseShellForPiCommand('pi'), false)
28
+ assert.equal(shouldUseShellForPiCommand('C:\\tools\\pi.exe'), false)
29
+ } finally {
30
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
31
+ }
32
+ })
33
+
34
+ test('shouldUseShellForPiCommand: keeps shell disabled on non-Windows', () => {
35
+ const originalPlatform = process.platform
36
+ Object.defineProperty(process, 'platform', { value: 'darwin' })
37
+
38
+ try {
39
+ assert.equal(shouldUseShellForPiCommand('pi.cmd'), false)
40
+ assert.equal(shouldUseShellForPiCommand('pi'), false)
41
+ } finally {
42
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
43
+ }
44
+ })
@@ -0,0 +1,32 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { toAvailableCommandsFromPiGetCommands } from '../../src/acp/pi-commands.js'
4
+
5
+ test('toAvailableCommandsFromPiGetCommands: hides extension commands by default and filters skill commands', () => {
6
+ const data = {
7
+ commands: [
8
+ { name: 'x', description: 'X', source: 'extension' },
9
+ { name: 'skill:foo', description: 'Foo', source: 'skill', location: 'user' },
10
+ { name: 'y', source: 'prompt', location: 'project' }
11
+ ]
12
+ }
13
+
14
+ const all = toAvailableCommandsFromPiGetCommands(data, { enableSkillCommands: true }).commands
15
+ assert.deepEqual(all, [
16
+ { name: 'skill:foo', description: 'Foo' },
17
+ { name: 'y', description: '(prompt:project)' }
18
+ ])
19
+
20
+ const includeExt = toAvailableCommandsFromPiGetCommands(data, {
21
+ enableSkillCommands: true,
22
+ includeExtensionCommands: true
23
+ }).commands
24
+ assert.deepEqual(includeExt, [
25
+ { name: 'x', description: 'X' },
26
+ { name: 'skill:foo', description: 'Foo' },
27
+ { name: 'y', description: '(prompt:project)' }
28
+ ])
29
+
30
+ const noSkills = toAvailableCommandsFromPiGetCommands(data, { enableSkillCommands: false }).commands
31
+ assert.deepEqual(noSkills, [{ name: 'y', description: '(prompt:project)' }])
32
+ })
@@ -0,0 +1,35 @@
1
+ import test, { afterEach, beforeEach } 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
+ beforeEach(() => {
7
+ delete process.env.PI_ACP_ENABLE_EMBEDDED_CONTEXT
8
+ })
9
+
10
+ afterEach(() => {
11
+ delete process.env.PI_ACP_ENABLE_EMBEDDED_CONTEXT
12
+ })
13
+
14
+ async function initializeWithEmbeddedContext(value?: string) {
15
+ if (value != null) process.env.PI_ACP_ENABLE_EMBEDDED_CONTEXT = value
16
+
17
+ const agent = new PiAcpAgent(asAgentConn(new FakeAgentSideConnection()))
18
+ const res = await agent.initialize({ protocolVersion: 1 } as any)
19
+
20
+ assert.ok(res.agentCapabilities)
21
+ assert.ok(res.agentCapabilities.promptCapabilities)
22
+ return res.agentCapabilities.promptCapabilities.embeddedContext
23
+ }
24
+
25
+ test('PI_ACP_ENABLE_EMBEDDED_CONTEXT: defaults embeddedContext to false when undefined', async () => {
26
+ assert.equal(await initializeWithEmbeddedContext(), false)
27
+ })
28
+
29
+ test("PI_ACP_ENABLE_EMBEDDED_CONTEXT: 'false' keeps embeddedContext disabled", async () => {
30
+ assert.equal(await initializeWithEmbeddedContext('false'), false)
31
+ })
32
+
33
+ test("PI_ACP_ENABLE_EMBEDDED_CONTEXT: 'true' enables embeddedContext", async () => {
34
+ assert.equal(await initializeWithEmbeddedContext('true'), true)
35
+ })
@@ -0,0 +1,29 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { normalizePiAssistantText, normalizePiMessageText } from '../../src/acp/translate/pi-messages.js'
4
+
5
+ test('normalizePiMessageText: supports string', () => {
6
+ assert.equal(normalizePiMessageText('hello'), 'hello')
7
+ })
8
+
9
+ test('normalizePiMessageText: joins text blocks', () => {
10
+ assert.equal(
11
+ normalizePiMessageText([
12
+ { type: 'text', text: 'a' },
13
+ { type: 'text', text: 'b' },
14
+ { type: 'not_text', x: 1 }
15
+ ]),
16
+ 'ab'
17
+ )
18
+ })
19
+
20
+ test('normalizePiAssistantText: joins only text blocks', () => {
21
+ assert.equal(
22
+ normalizePiAssistantText([
23
+ { type: 'text', text: 'hi' },
24
+ { type: 'thinking', text: '...' },
25
+ { type: 'text', text: '!' }
26
+ ]),
27
+ 'hi!'
28
+ )
29
+ })
@@ -0,0 +1,137 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { EventEmitter } from 'node:events'
4
+ import { PassThrough } from 'node:stream'
5
+ import { PiRpcProcess, SESSION_STATS_TIMEOUT_MS } from '../../src/pi-rpc/process.js'
6
+
7
+ type FakeChild = {
8
+ child: any
9
+ stdout: PassThrough
10
+ written: string[]
11
+ }
12
+
13
+ function makeFakeChild(): FakeChild {
14
+ const stdout = new PassThrough()
15
+ const written: string[] = []
16
+ const child: any = new EventEmitter()
17
+ child.stdout = stdout
18
+ child.stderr = new PassThrough()
19
+ child.killed = false
20
+ child.kill = () => {}
21
+ child.stdin = {
22
+ write: (line: string, cb?: (error?: Error | null) => void) => {
23
+ written.push(String(line))
24
+ cb?.(null)
25
+ return true
26
+ }
27
+ }
28
+ return { child, stdout, written }
29
+ }
30
+
31
+ // The constructor is private in TS only; tests drive it directly to avoid spawning `pi`.
32
+ function makeProcess(child: any): PiRpcProcess {
33
+ return new (PiRpcProcess as unknown as new (c: any) => PiRpcProcess)(child)
34
+ }
35
+
36
+ function pendingSize(proc: PiRpcProcess): number {
37
+ return (proc as unknown as { pending: Map<string, unknown> }).pending.size
38
+ }
39
+
40
+ function writeLine(stdout: PassThrough, msg: unknown): Promise<void> {
41
+ stdout.write(`${JSON.stringify(msg)}\n`)
42
+ return new Promise<void>(resolve => setImmediate(() => setImmediate(resolve)))
43
+ }
44
+
45
+ test('PiRpcProcess: auxiliary context usage has a one-second timeout', () => {
46
+ assert.equal(SESSION_STATS_TIMEOUT_MS, 1_000)
47
+ })
48
+
49
+ test('PiRpcProcess: getSessionStats without a timeout accepts a response after one second', async () => {
50
+ const { child, stdout, written } = makeFakeChild()
51
+ const proc = makeProcess(child)
52
+
53
+ const result = proc.getSessionStats()
54
+ const sent = JSON.parse(written[0]) as { id: string }
55
+
56
+ await new Promise(resolve => setTimeout(resolve, SESSION_STATS_TIMEOUT_MS + 50))
57
+ assert.equal(pendingSize(proc), 1)
58
+
59
+ await writeLine(stdout, {
60
+ type: 'response',
61
+ id: sent.id,
62
+ command: 'get_session_stats',
63
+ success: true,
64
+ data: { totalMessages: 42 }
65
+ })
66
+
67
+ assert.deepEqual(await result, { totalMessages: 42 })
68
+ assert.equal(pendingSize(proc), 0)
69
+ })
70
+
71
+ test('PiRpcProcess: request timeout rejects, clears pending, and swallows the late response', async () => {
72
+ const { child, stdout, written } = makeFakeChild()
73
+ const proc = makeProcess(child)
74
+
75
+ const events: unknown[] = []
76
+ proc.onEvent(ev => events.push(ev))
77
+
78
+ // The request timer is unref'd; keep the test process alive until it fires.
79
+ const keepAlive = setTimeout(() => {}, 50)
80
+ try {
81
+ await assert.rejects(proc.getSessionStats(5), /pi get_session_stats timed out after 5ms/)
82
+ } finally {
83
+ clearTimeout(keepAlive)
84
+ }
85
+ assert.equal(pendingSize(proc), 0)
86
+
87
+ const sent = JSON.parse(written[0]) as { id: string; type: string }
88
+ assert.equal(sent.type, 'get_session_stats')
89
+
90
+ await writeLine(stdout, {
91
+ type: 'response',
92
+ id: sent.id,
93
+ command: 'get_session_stats',
94
+ success: true,
95
+ data: { contextUsage: { tokens: 1, contextWindow: 2 } }
96
+ })
97
+
98
+ assert.deepEqual(events, [])
99
+ assert.equal(pendingSize(proc), 0)
100
+ })
101
+
102
+ test('PiRpcProcess: response before the timeout resolves and clears pending', async () => {
103
+ const { child, stdout, written } = makeFakeChild()
104
+ const proc = makeProcess(child)
105
+
106
+ const events: unknown[] = []
107
+ proc.onEvent(ev => events.push(ev))
108
+
109
+ const p = proc.getSessionStats(5_000)
110
+ const sent = JSON.parse(written[0]) as { id: string }
111
+
112
+ await writeLine(stdout, {
113
+ type: 'response',
114
+ id: sent.id,
115
+ command: 'get_session_stats',
116
+ success: true,
117
+ data: { contextUsage: { tokens: 10, contextWindow: 100 } }
118
+ })
119
+
120
+ assert.deepEqual(await p, { contextUsage: { tokens: 10, contextWindow: 100 } })
121
+ assert.equal(pendingSize(proc), 0)
122
+ assert.deepEqual(events, [])
123
+ })
124
+
125
+ test('PiRpcProcess: responses with unknown ids are dropped while real events are delivered', async () => {
126
+ const { child, stdout } = makeFakeChild()
127
+ const proc = makeProcess(child)
128
+
129
+ const events: unknown[] = []
130
+ proc.onEvent(ev => events.push(ev))
131
+
132
+ await writeLine(stdout, { type: 'response', id: 'nope', command: 'get_state', success: true, data: {} })
133
+ await writeLine(stdout, { type: 'response', command: 'get_state', success: true, data: {} })
134
+ await writeLine(stdout, { type: 'agent_start' })
135
+
136
+ assert.deepEqual(events, [{ type: 'agent_start' }])
137
+ })
@@ -0,0 +1,93 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { PiRpcProcess } from '../../src/pi-rpc/process.js'
7
+
8
+ function writeFakePi(root: string): string {
9
+ const fixture = join(root, 'fake-pi.cjs')
10
+ writeFileSync(
11
+ fixture,
12
+ [
13
+ "const { appendFileSync } = require('node:fs')",
14
+ "const readline = require('node:readline')",
15
+ "const sessionPath = process.argv[process.argv.indexOf('--session') + 1]",
16
+ "appendFileSync(sessionPath, 'started\\n')",
17
+ "readline.createInterface({ input: process.stdin }).on('line', line => {",
18
+ ' const command = JSON.parse(line)',
19
+ " process.stdout.write(JSON.stringify({ type: 'response', id: command.id, command: command.type, success: true, data: { sessionFile: sessionPath } }) + '\\n')",
20
+ ' setTimeout(() => process.exit(0), 20)',
21
+ '})'
22
+ ].join('\n')
23
+ )
24
+ return fixture
25
+ }
26
+
27
+ test('PiRpcProcess passes distinct session paths intact to the pi launcher', async () => {
28
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp launcher '))
29
+ const fixture = writeFakePi(root)
30
+ const launchers = process.platform === 'win32' ? ['pi.cmd', 'pi.bat'] : ['pi']
31
+ const previousTestVar = process.env.PI_ACP_PATH_TEST
32
+
33
+ process.env.PI_ACP_PATH_TEST = 'expanded'
34
+
35
+ try {
36
+ const sessionDir = join(root, 'project & (a) ^ 100% !PI_ACP_PATH_TEST! %PI_ACP_PATH_TEST%')
37
+ mkdirSync(sessionDir)
38
+
39
+ for (const launcherName of launchers) {
40
+ const launcher = join(root, launcherName)
41
+ if (process.platform === 'win32') {
42
+ writeFileSync(launcher, `@echo off\r\n"${process.execPath}" "${fixture}" %*\r\n`)
43
+ } else {
44
+ writeFileSync(launcher, `#!/usr/bin/env node\nrequire('./fake-pi.cjs')\n`)
45
+ chmodSync(launcher, 0o755)
46
+ }
47
+
48
+ const first = join(sessionDir, `${launcherName}-first.jsonl`)
49
+ const second = join(sessionDir, `${launcherName}-second.jsonl`)
50
+
51
+ for (const sessionPath of [first, second]) {
52
+ const proc = await PiRpcProcess.spawn({ cwd: root, piCommand: launcher, sessionPath })
53
+ proc.dispose()
54
+ }
55
+
56
+ assert.equal(readFileSync(first, 'utf8'), 'started\n')
57
+ assert.equal(readFileSync(second, 'utf8'), 'started\n')
58
+ assert.equal(existsSync(join(root, 'project')), false)
59
+ }
60
+ } finally {
61
+ if (previousTestVar === undefined) delete process.env.PI_ACP_PATH_TEST
62
+ else process.env.PI_ACP_PATH_TEST = previousTestVar
63
+ await new Promise(resolve => setTimeout(resolve, 50))
64
+ rmSync(root, { recursive: true, force: true })
65
+ }
66
+ })
67
+
68
+ test(
69
+ 'PiRpcProcess keeps a simulated Windows cmd session path intact',
70
+ { skip: process.platform === 'win32' },
71
+ async () => {
72
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-cmd-'))
73
+ writeFakePi(root)
74
+ const launcher = join(root, 'pi.cmd')
75
+ writeFileSync(launcher, "#!/usr/bin/env node\nrequire('./fake-pi.cjs')\n")
76
+ chmodSync(launcher, 0o755)
77
+ const sessionDir = join(root, 'my project')
78
+ mkdirSync(sessionDir)
79
+ const sessionPath = join(sessionDir, 'restored.jsonl')
80
+ const originalPlatform = process.platform
81
+
82
+ try {
83
+ Object.defineProperty(process, 'platform', { value: 'win32' })
84
+ const proc = await PiRpcProcess.spawn({ cwd: root, piCommand: launcher, sessionPath })
85
+ proc.dispose()
86
+ assert.equal(readFileSync(sessionPath, 'utf8'), 'started\n')
87
+ } finally {
88
+ Object.defineProperty(process, 'platform', { value: originalPlatform })
89
+ await new Promise(resolve => setTimeout(resolve, 50))
90
+ rmSync(root, { recursive: true, force: true })
91
+ }
92
+ }
93
+ )
@@ -0,0 +1,40 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { toolResultToText } from '../../src/acp/translate/pi-tools.js'
4
+
5
+ test('toolResultToText: extracts text from content blocks', () => {
6
+ const text = toolResultToText({
7
+ content: [
8
+ { type: 'text', text: 'hello' },
9
+ { type: 'text', text: ' world' }
10
+ ]
11
+ })
12
+ assert.equal(text, 'hello world')
13
+ })
14
+
15
+ test('toolResultToText: prefers details.diff when present', () => {
16
+ const text = toolResultToText({
17
+ content: [{ type: 'text', text: 'Successfully replaced 2 block(s) in a.txt.' }],
18
+ details: { diff: '--- a\n+++ b\n' }
19
+ })
20
+ assert.equal(text, '--- a\n+++ b\n')
21
+ })
22
+
23
+ test('toolResultToText: falls back to JSON', () => {
24
+ const text = toolResultToText({ a: 1 })
25
+ assert.match(text, /"a": 1/)
26
+ })
27
+
28
+ test('toolResultToText: extracts bash stdout/stderr from details', () => {
29
+ const text = toolResultToText({
30
+ details: {
31
+ stdout: 'ok\n',
32
+ stderr: 'warn\n',
33
+ exitCode: 0
34
+ }
35
+ })
36
+ assert.match(text, /ok/)
37
+ assert.match(text, /stderr:/)
38
+ assert.match(text, /warn/)
39
+ assert.match(text, /exit code: 0/)
40
+ })
@@ -0,0 +1,74 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { promptToPiMessage } from '../../src/acp/translate/prompt.js'
4
+
5
+ test('promptToPiMessage: concatenates text and resource links', () => {
6
+ const { message, images } = promptToPiMessage([
7
+ { type: 'text', text: 'Hello' },
8
+ { type: 'resource_link', uri: 'file:///tmp/foo.txt', name: 'foo' },
9
+ { type: 'text', text: ' world' }
10
+ ])
11
+
12
+ assert.equal(message, 'Hello\n[Context] file:///tmp/foo.txt world')
13
+ assert.deepEqual(images, [])
14
+ })
15
+
16
+ test('promptToPiMessage: includes embedded resource text as marker', () => {
17
+ const { message, images } = promptToPiMessage([
18
+ {
19
+ type: 'resource',
20
+ resource: {
21
+ uri: 'file:///tmp/a.txt',
22
+ mimeType: 'text/plain',
23
+ text: 'hi'
24
+ }
25
+ }
26
+ ] as any)
27
+
28
+ assert.equal(message, '\n[Embedded Context] file:///tmp/a.txt (text/plain)\nhi')
29
+ assert.deepEqual(images, [])
30
+ })
31
+
32
+ test('promptToPiMessage: includes embedded resource blob as marker', () => {
33
+ const blob = Buffer.from('xyz', 'utf8').toString('base64')
34
+
35
+ const { message, images } = promptToPiMessage([
36
+ {
37
+ type: 'resource',
38
+ resource: {
39
+ uri: 'file:///tmp/a.bin',
40
+ mimeType: 'application/octet-stream',
41
+ blob
42
+ }
43
+ }
44
+ ] as any)
45
+
46
+ assert.equal(message, '\n[Embedded Context] file:///tmp/a.bin (application/octet-stream, 3 bytes)')
47
+ assert.deepEqual(images, [])
48
+ })
49
+
50
+ test('promptToPiMessage: includes audio as marker', () => {
51
+ const data = Buffer.from('abc', 'utf8').toString('base64')
52
+
53
+ const { message, images } = promptToPiMessage([{ type: 'audio', mimeType: 'audio/wav', data }] as any)
54
+
55
+ assert.equal(message, '\n[Audio] (audio/wav, 3 bytes) not supported by pi-acp')
56
+ assert.deepEqual(images, [])
57
+ })
58
+
59
+ test('promptToPiMessage: maps image to pi image content', () => {
60
+ const base64 = Buffer.from('abc', 'utf8').toString('base64')
61
+
62
+ const { message, images } = promptToPiMessage([
63
+ { type: 'text', text: 'see' },
64
+ { type: 'image', mimeType: 'image/png', data: base64, uri: 'img-1' }
65
+ ])
66
+
67
+ assert.equal(message, 'see')
68
+ assert.equal(images.length, 1)
69
+ assert.deepEqual(images[0], {
70
+ type: 'image',
71
+ mimeType: 'image/png',
72
+ data: base64
73
+ })
74
+ })