@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,129 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { PiAcpAgent } from '../../src/acp/agent.js'
8
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
9
+
10
+ // We mock PiRpcProcess.spawn so loadSession doesn't actually spawn `pi`.
11
+ import { PiRpcProcess } from '../../src/pi-rpc/process.js'
12
+
13
+ test('PiAcpAgent: listSessions lists pi sessions and loadSession replays history', async () => {
14
+ // Create a fake PI_CODING_AGENT_DIR with one session.
15
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-test-'))
16
+ const sessionsDir = join(root, 'sessions', '--tmp--project--')
17
+ const sessionFile = join(sessionsDir, '0000_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jsonl')
18
+
19
+ // Ensure parent dirs.
20
+ mkdirSync(sessionsDir, { recursive: true })
21
+
22
+ writeFileSync(
23
+ sessionFile,
24
+ [
25
+ JSON.stringify({
26
+ type: 'session',
27
+ version: 3,
28
+ id: 'sess-1',
29
+ timestamp: '2026-02-11T00:00:00.000Z',
30
+ cwd: '/tmp/project'
31
+ }),
32
+ JSON.stringify({
33
+ type: 'message',
34
+ id: 'a1b2c3d4',
35
+ parentId: null,
36
+ timestamp: '2026-02-11T00:00:01.000Z',
37
+ message: { role: 'user', content: 'Hello' }
38
+ }),
39
+ JSON.stringify({
40
+ type: 'message',
41
+ id: 'b2c3d4e5',
42
+ parentId: 'a1b2c3d4',
43
+ timestamp: '2026-02-11T00:00:02.000Z',
44
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] }
45
+ }),
46
+ JSON.stringify({
47
+ type: 'session_info',
48
+ id: 'c3d4e5f6',
49
+ parentId: 'b2c3d4e5',
50
+ timestamp: '2026-02-11T00:00:03.000Z',
51
+ name: 'My Named Session'
52
+ })
53
+ ].join('\n') + '\n',
54
+ { encoding: 'utf8' }
55
+ )
56
+
57
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
58
+ process.env.PI_CODING_AGENT_DIR = root
59
+
60
+ try {
61
+ const conn = new FakeAgentSideConnection()
62
+ const agent = new PiAcpAgent(asAgentConn(conn))
63
+
64
+ // 1) list sessions
65
+ const listed = await agent.listSessions({ cwd: null, cursor: null, _meta: null } as any)
66
+ assert.ok(listed.sessions.length >= 1)
67
+
68
+ const s = listed.sessions.find(x => x.sessionId === 'sess-1')
69
+ assert.ok(s)
70
+ assert.equal(s?.cwd, '/tmp/project')
71
+ assert.equal(s?.title, 'My Named Session')
72
+
73
+ // 2) load session: mock spawn to return fake proc with getMessages
74
+ const originalSpawn = PiRpcProcess.spawn
75
+
76
+ ;(PiRpcProcess as any).spawn = async (params: any) => {
77
+ // ensure loadSession resolves to some jsonl that ends with our expected filename
78
+ assert.ok(typeof params.sessionPath === 'string')
79
+ assert.ok(params.sessionPath.endsWith('/0000_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jsonl'))
80
+
81
+ return {
82
+ onEvent: () => () => {
83
+ // noop unsubscribe
84
+ },
85
+ getMessages: async () => ({
86
+ messages: [
87
+ { role: 'user', content: 'Hello' },
88
+ { role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] }
89
+ ]
90
+ }),
91
+ getAvailableThinkingLevels: async () => ['medium'],
92
+ getSessionStats: async () => ({ contextUsage: { tokens: 12_345, contextWindow: 200_000 } }),
93
+ getAvailableModels: async () => ({ models: [] }),
94
+ getState: async () => ({ thinkingLevel: 'medium' })
95
+ } as any
96
+ }
97
+
98
+ try {
99
+ await agent.loadSession({ sessionId: 'sess-1', cwd: '/tmp/project', mcpServers: [], _meta: null } as any)
100
+
101
+ // loadSession should have replayed messages as session/update notifications.
102
+ const texts = conn.updates
103
+ .map(u => (u as any).update)
104
+ .filter(Boolean)
105
+ .map(u => ({ kind: u.sessionUpdate, text: u.content?.text }))
106
+
107
+ assert.ok(texts.some(t => t.kind === 'user_message_chunk' && t.text === 'Hello'))
108
+ assert.ok(texts.some(t => t.kind === 'agent_message_chunk' && t.text === 'Hi there!'))
109
+
110
+ // loadSession schedules the usage publish after returning (client must know the sessionId first).
111
+ assert.equal(
112
+ conn.updates.some(u => (u as any).update?.sessionUpdate === 'usage_update'),
113
+ false
114
+ )
115
+ await new Promise(r => setTimeout(r, 0))
116
+ await new Promise(r => setTimeout(r, 0))
117
+
118
+ assert.deepEqual(
119
+ conn.updates.filter(u => (u as any).update?.sessionUpdate === 'usage_update'),
120
+ [{ sessionId: 'sess-1', update: { sessionUpdate: 'usage_update', used: 12_345, size: 200_000 } }]
121
+ )
122
+ } finally {
123
+ PiRpcProcess.spawn = originalSpawn
124
+ }
125
+ } finally {
126
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
127
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
128
+ }
129
+ })
@@ -0,0 +1,52 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { listPiSessions } from '../../src/acp/pi-sessions.js'
8
+
9
+ test('listPiSessions: respects sessionDir from pi settings.json', async () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-test-'))
11
+ const customSessionsDir = join(root, 'somewhere-else', '--p--')
12
+ mkdirSync(customSessionsDir, { recursive: true })
13
+
14
+ writeFileSync(
15
+ join(root, 'settings.json'),
16
+ JSON.stringify({ sessionDir: join(root, 'somewhere-else') }, null, 2),
17
+ 'utf8'
18
+ )
19
+
20
+ writeFileSync(
21
+ join(customSessionsDir, 's.jsonl'),
22
+ [
23
+ JSON.stringify({
24
+ type: 'session',
25
+ version: 3,
26
+ id: 'sess-custom',
27
+ timestamp: '2026-01-01T00:00:00.000Z',
28
+ cwd: '/tmp/project'
29
+ }),
30
+ JSON.stringify({
31
+ type: 'message',
32
+ id: 'm1',
33
+ parentId: null,
34
+ timestamp: '2026-01-01T00:00:01.000Z',
35
+ message: { role: 'user', content: 'hi' }
36
+ })
37
+ ].join('\n') + '\n',
38
+ { encoding: 'utf8' }
39
+ )
40
+
41
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
42
+ process.env.PI_CODING_AGENT_DIR = root
43
+
44
+ try {
45
+ const s = listPiSessions().find(x => x.sessionId === 'sess-custom')
46
+ assert.ok(s)
47
+ assert.equal(s?.sessionFile, join(customSessionsDir, 's.jsonl'))
48
+ } finally {
49
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
50
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
51
+ }
52
+ })
@@ -0,0 +1,76 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { PiAcpAgent } from '../../src/acp/agent.js'
8
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
9
+
10
+ test('PiAcpAgent: listSessions defaults to lastSessionCwd when cwd param is omitted', async () => {
11
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-test-'))
12
+
13
+ const dirA = join(root, 'sessions', '--a--')
14
+ const dirB = join(root, 'sessions', '--b--')
15
+ mkdirSync(dirA, { recursive: true })
16
+ mkdirSync(dirB, { recursive: true })
17
+
18
+ writeFileSync(
19
+ join(dirA, '1.jsonl'),
20
+ JSON.stringify({
21
+ type: 'session',
22
+ version: 3,
23
+ id: 'sess-a',
24
+ timestamp: '2026-01-01T00:00:00.000Z',
25
+ cwd: '/cwd/a'
26
+ }) +
27
+ '\n' +
28
+ JSON.stringify({
29
+ type: 'session_info',
30
+ id: 'a1b2c3d4',
31
+ parentId: null,
32
+ timestamp: '2026-01-01T00:00:01.000Z',
33
+ name: 'A'
34
+ }) +
35
+ '\n',
36
+ { encoding: 'utf8' }
37
+ )
38
+
39
+ writeFileSync(
40
+ join(dirB, '2.jsonl'),
41
+ JSON.stringify({
42
+ type: 'session',
43
+ version: 3,
44
+ id: 'sess-b',
45
+ timestamp: '2026-01-01T00:00:00.000Z',
46
+ cwd: '/cwd/b'
47
+ }) +
48
+ '\n' +
49
+ JSON.stringify({
50
+ type: 'session_info',
51
+ id: 'b1b2c3d4',
52
+ parentId: null,
53
+ timestamp: '2026-01-01T00:00:01.000Z',
54
+ name: 'B'
55
+ }) +
56
+ '\n',
57
+ { encoding: 'utf8' }
58
+ )
59
+
60
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
61
+ process.env.PI_CODING_AGENT_DIR = root
62
+
63
+ try {
64
+ const conn = new FakeAgentSideConnection()
65
+ const agent = new PiAcpAgent(asAgentConn(conn))
66
+
67
+ ;(agent as any).lastSessionCwd = '/cwd/a'
68
+
69
+ const listed = await agent.listSessions({} as any)
70
+ assert.equal(listed.sessions.length, 1)
71
+ assert.equal(listed.sessions[0]?.sessionId, 'sess-a')
72
+ } finally {
73
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
74
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
75
+ }
76
+ })
@@ -0,0 +1,68 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { PiAcpAgent } from '../../src/acp/agent.js'
5
+ import { FakeAgentSideConnection, asAgentConn } from '../helpers/fakes.js'
6
+ import { PiRpcProcess } from '../../src/pi-rpc/process.js'
7
+
8
+ class FakeStore {
9
+ get(_sessionId: string) {
10
+ return { sessionId: 's1', cwd: '/tmp/project', sessionFile: '/tmp/s.jsonl', updatedAt: new Date().toISOString() }
11
+ }
12
+ upsert() {}
13
+ }
14
+
15
+ test('PiAcpAgent: loadSession replays toolResult as tool_call + tool_call_update', async () => {
16
+ const originalSpawn = PiRpcProcess.spawn
17
+ ;(PiRpcProcess as any).spawn = async () => {
18
+ return {
19
+ onEvent: () => () => {},
20
+ getMessages: async () => ({
21
+ messages: [
22
+ {
23
+ role: 'toolResult',
24
+ toolCallId: 'call_1',
25
+ toolName: 'bash',
26
+ args: { command: 'echo hello' },
27
+ content: [{ type: 'text', text: 'hello from bash' }],
28
+ isError: false
29
+ }
30
+ ]
31
+ }),
32
+ getAvailableThinkingLevels: async () => ['medium'],
33
+ getAvailableModels: async () => ({ models: [] }),
34
+ getState: async () => ({ thinkingLevel: 'medium' })
35
+ } as any
36
+ }
37
+
38
+ try {
39
+ const conn = new FakeAgentSideConnection()
40
+ const agent = new PiAcpAgent(asAgentConn(conn))
41
+ ;(agent as any).store = new FakeStore()
42
+
43
+ await agent.loadSession({ sessionId: 's1', cwd: '/tmp/project', mcpServers: [] } as any)
44
+
45
+ const updates = conn.updates.map(u => (u as any).update)
46
+
47
+ const toolCall = updates.find(u => u?.sessionUpdate === 'tool_call')
48
+ assert.ok(toolCall)
49
+ assert.equal(toolCall.toolCallId, 'call_1')
50
+ assert.equal(toolCall.title, 'echo hello')
51
+ assert.equal(toolCall.kind, 'execute')
52
+ assert.deepEqual(toolCall.content, [{ type: 'terminal', terminalId: 'call_1' }])
53
+ assert.deepEqual(toolCall._meta, { terminal_info: { terminal_id: 'call_1', cwd: '/tmp/project' } })
54
+ assert.equal(toolCall.rawOutput, undefined)
55
+
56
+ const toolCallUpdate = updates.find(u => u?.sessionUpdate === 'tool_call_update')
57
+ assert.ok(toolCallUpdate)
58
+ assert.equal(toolCallUpdate.toolCallId, 'call_1')
59
+ assert.equal(toolCallUpdate.status, 'completed')
60
+ assert.deepEqual(toolCallUpdate._meta, {
61
+ terminal_output: { terminal_id: 'call_1', data: 'hello from bash' },
62
+ terminal_exit: { terminal_id: 'call_1', exit_code: 0, signal: null }
63
+ })
64
+ assert.equal(toolCallUpdate.rawOutput, undefined)
65
+ } finally {
66
+ PiRpcProcess.spawn = originalSpawn
67
+ }
68
+ })
@@ -0,0 +1,43 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpSession } from '../../src/acp/session.js'
4
+ import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
5
+
6
+ test('PiAcpSession: cancel clears queued prompts', async () => {
7
+ const conn = new FakeAgentSideConnection()
8
+ const proc = new FakePiRpcProcess()
9
+
10
+ const session = new PiAcpSession({
11
+ sessionId: 's1',
12
+ cwd: process.cwd(),
13
+ mcpServers: [],
14
+ proc: proc as any,
15
+ conn: asAgentConn(conn),
16
+ fileCommands: []
17
+ })
18
+
19
+ const first = session.prompt('one')
20
+ const second = session.prompt('two')
21
+ const third = session.prompt('three')
22
+
23
+ // first started, second+third queued
24
+ assert.equal(proc.prompts.length, 1)
25
+
26
+ await session.cancel()
27
+
28
+ assert.equal(proc.abortCount, 1)
29
+
30
+ // queued prompts should resolve as cancelled
31
+ assert.equal(await second, 'cancelled')
32
+ assert.equal(await third, 'cancelled')
33
+
34
+ // finish first prompt as cancelled after abort
35
+ proc.emit({ type: 'agent_start' })
36
+ proc.emit({ type: 'turn_end' })
37
+ proc.emit({ type: 'agent_end' })
38
+ proc.emit({ type: 'agent_settled' })
39
+ assert.equal(await first, 'cancelled')
40
+
41
+ // queue should have been cleared, so no further prompt started
42
+ assert.equal(proc.prompts.length, 1)
43
+ })
@@ -0,0 +1,37 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpSession } from '../../src/acp/session.js'
4
+ import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js'
5
+
6
+ test('PiAcpSession: expands /command before sending to pi', async () => {
7
+ const conn = new FakeAgentSideConnection()
8
+ const proc = new FakePiRpcProcess()
9
+
10
+ const session = new PiAcpSession({
11
+ sessionId: 's1',
12
+ cwd: process.cwd(),
13
+ mcpServers: [],
14
+ proc: proc as any,
15
+ conn: asAgentConn(conn),
16
+ fileCommands: [
17
+ {
18
+ name: 'hello',
19
+ description: '(user)',
20
+ content: 'Expanded $1',
21
+ source: '(user)'
22
+ }
23
+ ]
24
+ })
25
+
26
+ const p = session.prompt('/hello world')
27
+
28
+ proc.emit({ type: 'agent_start' })
29
+ proc.emit({ type: 'turn_end' })
30
+ proc.emit({ type: 'agent_end' })
31
+ proc.emit({ type: 'agent_settled' })
32
+ const reason = await p
33
+
34
+ assert.equal(reason, 'end_turn')
35
+ assert.equal(proc.prompts.length, 1)
36
+ assert.equal(proc.prompts[0]!.message, 'Expanded world')
37
+ })
@@ -0,0 +1,22 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PiAcpAgent } from '../../src/acp/agent.js'
4
+
5
+ class FakeConn {
6
+ updates: any[] = []
7
+ async sessionUpdate(msg: any) {
8
+ this.updates.push(msg)
9
+ }
10
+ }
11
+
12
+ test('PiAcpAgent: setSessionMode maps to pi setThinkingLevel + emits current_mode_update', async () => {
13
+ const conn = new FakeConn()
14
+ const agent = new PiAcpAgent(conn as any)
15
+
16
+ // Create a fake session by calling newSession is heavyweight (spawns pi).
17
+ // Instead, reach into session manager via loadSession isn't possible either.
18
+ // So we unit-test the mapping via a minimal fake session manager would require refactor.
19
+ // For now we just assert the method exists and rejects unknown session IDs.
20
+
21
+ await assert.rejects(() => agent.setSessionMode({ sessionId: 'nope', modeId: 'invalid' } as any), /invalid params/i)
22
+ })
@@ -0,0 +1,56 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { listPiSessions } from '../../src/acp/pi-sessions.js'
8
+
9
+ // Ensures we still pick up session_info.name even if it is older than the tail window.
10
+
11
+ test('listPiSessions: finds session_info.name even when it is outside the tail window', async () => {
12
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-test-'))
13
+ const sessionsDir = join(root, 'sessions', '--p--')
14
+ mkdirSync(sessionsDir, { recursive: true })
15
+
16
+ const sessionFile = join(sessionsDir, 's.jsonl')
17
+
18
+ const header = JSON.stringify({
19
+ type: 'session',
20
+ version: 3,
21
+ id: 'sess-1',
22
+ timestamp: '2026-01-01T00:00:00.000Z',
23
+ cwd: '/tmp/project'
24
+ })
25
+ const info = JSON.stringify({
26
+ type: 'session_info',
27
+ id: 'i1',
28
+ parentId: null,
29
+ timestamp: '2026-01-01T00:00:01.000Z',
30
+ name: 'Named Early'
31
+ })
32
+
33
+ // Create a large filler so the name is far outside the last 256KB tail.
34
+ const fillerLine = JSON.stringify({
35
+ type: 'message',
36
+ id: 'm',
37
+ parentId: null,
38
+ timestamp: '2026-01-01T00:00:02.000Z',
39
+ message: { role: 'user', content: 'x'.repeat(2000) }
40
+ })
41
+ const filler = Array.from({ length: 400 }, () => fillerLine).join('\n')
42
+
43
+ writeFileSync(sessionFile, [header, info, filler].join('\n') + '\n', { encoding: 'utf8' })
44
+
45
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
46
+ process.env.PI_CODING_AGENT_DIR = root
47
+
48
+ try {
49
+ const s = listPiSessions().find(x => x.sessionId === 'sess-1')
50
+ assert.ok(s)
51
+ assert.equal(s?.title, 'Named Early')
52
+ } finally {
53
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
54
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
55
+ }
56
+ })
@@ -0,0 +1,57 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import { listPiSessions } from '../../src/acp/pi-sessions.js'
8
+
9
+ test('listPiSessions: updatedAt prefers last message timestamp over later non-message entries', async () => {
10
+ const root = mkdtempSync(join(tmpdir(), 'pi-acp-test-'))
11
+ const sessionsDir = join(root, 'sessions', '--p--')
12
+ mkdirSync(sessionsDir, { recursive: true })
13
+
14
+ const sessionFile = join(sessionsDir, 's.jsonl')
15
+
16
+ // Last message at 00:00:02, but a later session_info at 00:00:10.
17
+ // We want updatedAt == 00:00:02.
18
+ writeFileSync(
19
+ sessionFile,
20
+ [
21
+ JSON.stringify({
22
+ type: 'session',
23
+ version: 3,
24
+ id: 'sess-1',
25
+ timestamp: '2026-01-01T00:00:00.000Z',
26
+ cwd: '/tmp/project'
27
+ }),
28
+ JSON.stringify({
29
+ type: 'message',
30
+ id: 'a1b2c3d4',
31
+ parentId: null,
32
+ timestamp: '2026-01-01T00:00:02.000Z',
33
+ message: { role: 'user', content: 'hi' }
34
+ }),
35
+ JSON.stringify({
36
+ type: 'session_info',
37
+ id: 'b1b2c3d4',
38
+ parentId: 'a1b2c3d4',
39
+ timestamp: '2026-01-01T00:00:10.000Z',
40
+ name: 'named'
41
+ })
42
+ ].join('\n') + '\n',
43
+ { encoding: 'utf8' }
44
+ )
45
+
46
+ const oldEnv = process.env.PI_CODING_AGENT_DIR
47
+ process.env.PI_CODING_AGENT_DIR = root
48
+
49
+ try {
50
+ const sessions = listPiSessions().filter(s => s.sessionId === 'sess-1')
51
+ assert.equal(sessions.length, 1)
52
+ assert.equal(sessions[0]?.updatedAt, '2026-01-01T00:00:02.000Z')
53
+ } finally {
54
+ if (oldEnv === undefined) delete process.env.PI_CODING_AGENT_DIR
55
+ else process.env.PI_CODING_AGENT_DIR = oldEnv
56
+ }
57
+ })
@@ -0,0 +1,87 @@
1
+ import type { AgentSideConnection } from '@agentclientprotocol/sdk'
2
+ import type { PiRpcEvent, PiSessionStats } from '../../src/pi-rpc/process.js'
3
+
4
+ type SessionUpdateMsg = Parameters<AgentSideConnection['sessionUpdate']>[0]
5
+
6
+ export class FakeAgentSideConnection {
7
+ readonly updates: SessionUpdateMsg[] = []
8
+ readonly permissionRequests: unknown[] = []
9
+ nextPermissionResponse: { outcome: { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' } } = {
10
+ outcome: { outcome: 'selected', optionId: 'allow' }
11
+ }
12
+
13
+ async sessionUpdate(msg: SessionUpdateMsg): Promise<void> {
14
+ this.updates.push(msg)
15
+ }
16
+
17
+ async requestPermission(
18
+ params: unknown
19
+ ): Promise<{ outcome: { outcome: 'selected'; optionId: string } | { outcome: 'cancelled' } }> {
20
+ this.permissionRequests.push(params)
21
+ return this.nextPermissionResponse
22
+ }
23
+ }
24
+
25
+ export class FakePiRpcProcess {
26
+ private handlers: Array<(ev: PiRpcEvent) => void> = []
27
+
28
+ // spies
29
+ readonly prompts: Array<{ message: string; attachments: unknown[] }> = []
30
+ readonly extensionUiResponses: unknown[] = []
31
+ abortCount = 0
32
+ getSessionStatsCount = 0
33
+
34
+ sessionStats: PiSessionStats = {}
35
+ /** When set, `getSessionStats()` rejects with this error. */
36
+ sessionStatsError: unknown = null
37
+
38
+ onEvent(handler: (ev: PiRpcEvent) => void): () => void {
39
+ this.handlers.push(handler)
40
+ return () => {
41
+ this.handlers = this.handlers.filter(h => h !== handler)
42
+ }
43
+ }
44
+
45
+ emit(ev: PiRpcEvent) {
46
+ for (const h of this.handlers) h(ev)
47
+ }
48
+
49
+ async prompt(message: string, attachments: unknown[] = []): Promise<void> {
50
+ this.prompts.push({ message, attachments })
51
+ }
52
+
53
+ async abort(): Promise<void> {
54
+ this.abortCount += 1
55
+ }
56
+
57
+ async sendExtensionUiResponse(response: unknown): Promise<void> {
58
+ this.extensionUiResponses.push(response)
59
+ }
60
+
61
+ async getState(): Promise<any> {
62
+ return {}
63
+ }
64
+
65
+ async getAvailableModels(): Promise<any> {
66
+ return { models: [{ provider: 'test', id: 'model', name: 'model' }] }
67
+ }
68
+
69
+ async getAvailableThinkingLevels(): Promise<string[]> {
70
+ return ['medium', 'high']
71
+ }
72
+
73
+ async getMessages(): Promise<any> {
74
+ return { messages: [] }
75
+ }
76
+
77
+ async getSessionStats(): Promise<PiSessionStats> {
78
+ this.getSessionStatsCount += 1
79
+ if (this.sessionStatsError) throw this.sessionStatsError
80
+ return this.sessionStats
81
+ }
82
+ }
83
+
84
+ export function asAgentConn(conn: FakeAgentSideConnection): AgentSideConnection {
85
+ // We only implement the method(s) used by PiAcpSession in tests.
86
+ return conn as unknown as AgentSideConnection
87
+ }
@@ -0,0 +1,22 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { getAuthMethods, PI_SETUP_METHOD_ID } from '../../src/acp/auth.js'
4
+
5
+ test('getAuthMethods: includes Zed terminal-auth metadata when enabled', () => {
6
+ const methods = getAuthMethods({ supportsTerminalAuthMeta: true })
7
+ assert.equal(methods.length, 1)
8
+ const m: any = methods[0]
9
+
10
+ assert.equal(m.id, PI_SETUP_METHOD_ID)
11
+ assert.ok(m._meta)
12
+ assert.ok(m._meta['terminal-auth'])
13
+ assert.ok(typeof m._meta['terminal-auth'].command === 'string')
14
+ assert.deepEqual(m._meta['terminal-auth'].args, ['--terminal-login'])
15
+ assert.equal(m._meta['terminal-auth'].label, 'Launch pi')
16
+ })
17
+
18
+ test('getAuthMethods: omits Zed terminal-auth metadata when disabled', () => {
19
+ const methods = getAuthMethods({ supportsTerminalAuthMeta: false })
20
+ const m: any = methods[0]
21
+ assert.ok(!m._meta || !m._meta['terminal-auth'])
22
+ })