@pi-in-go/pigpen-acp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,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
|
+
})
|