@zhushanwen/pi-msg-id-mapper 1.0.1

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/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@zhushanwen/pi-msg-id-mapper",
3
+ "version": "1.0.1",
4
+ "description": "Client UUID ↔ user entry ID mapping extension for Pi — enables metadata preservation across sessions",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "pi": {
8
+ "extensions": [
9
+ "./index.ts"
10
+ ]
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "extension",
15
+ "msg-id",
16
+ "client-uuid",
17
+ "mapping"
18
+ ],
19
+ "license": "MIT",
20
+ "files": [
21
+ "src/",
22
+ "index.ts"
23
+ ],
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^24.0.0",
29
+ "vitest": "^4.1.8"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "npx tsc --noEmit",
33
+ "test": "vitest run"
34
+ }
35
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * msg-id-mapper extension 真实行为测试。
3
+ *
4
+ * 覆盖(替换原 expect(true) 占位):
5
+ * - input hook:clientUuid 提取(有/无/畸形 userEntryId 标记、非 rpc source、多标记全剥离)
6
+ * - message_end:仅 user role + pendingClientUuid 时置 awaiting flag
7
+ * - flush(message_start/turn_end/agent_end 三重安全网):映射写入、幂等清空、
8
+ * leafId 未就绪重试、异常兜底(不抛出 + 保留 flag 供下次幂等重试)
9
+ *
10
+ * mock 策略:pi SDK 经 workspace node_modules 提供类型(import type 零运行时解析),
11
+ * ExtensionAPI/ExtensionContext 用结构化桩(参照 subagent-workflow mocks/ 的最小桩模式,
12
+ * 本包无 SDK 运行时依赖故不需要 alias 配置)。
13
+ *
14
+ * 运行:cd extensions/msg-id-mapper && npx vitest run
15
+ */
16
+ import { describe, it, expect, vi, afterEach } from 'vitest'
17
+ import createMapper from '../index'
18
+ import type {
19
+ ExtensionAPI,
20
+ ExtensionContext,
21
+ InputEvent,
22
+ MessageEndEvent,
23
+ MessageStartEvent,
24
+ TurnEndEvent,
25
+ AgentEndEvent,
26
+ } from '@earendil-works/pi-coding-agent'
27
+
28
+ const ENTRY_TYPE = 'xyz.client-msg-id'
29
+
30
+ /** 合法 uuid:`u-` + 36 字符(8-4-4-4-12 hex+连字符),与 TAG_MATCH 严格匹配 */
31
+ const UUID = 'u-123e4567-e89b-12d3-a456-426614174000'
32
+ const marker = (uuid: string): string => `<!--xyz:msg:${uuid}-->`
33
+
34
+ /** hook 注册表 + 可控桩(每个测试用例通过工厂重建,隔离闭包状态) */
35
+ interface Harness {
36
+ appendEntry: ReturnType<typeof vi.fn>
37
+ input: (text: string, source?: string) => unknown
38
+ /** 传 event 对象本身(供畸形 event 兜底测试直接给非 InputEvent 输入) */
39
+ inputRaw: (event: unknown) => unknown
40
+ messageEnd: (role: string) => unknown
41
+ /** 触发 flush 类 hook(message_start/turn_end/agent_end),ctx.getLeafId 返回 leafId */
42
+ flush: (event: 'message_start' | 'turn_end' | 'agent_end', leafId?: string | null) => void
43
+ /** ctx.getLeafId 为可注入实现(供 throw 场景) */
44
+ flushWithLeafIdFn: (event: 'message_start' | 'turn_end' | 'agent_end', getLeafId: () => string | null) => void
45
+ }
46
+
47
+ function createHarness(): Harness {
48
+ const handlers = new Map<string, (...args: unknown[]) => unknown>()
49
+ const appendEntry = vi.fn()
50
+ const pi = {
51
+ on: (event: string, handler: (...args: unknown[]) => unknown) => {
52
+ handlers.set(event, handler)
53
+ },
54
+ appendEntry,
55
+ } as unknown as ExtensionAPI
56
+ createMapper(pi)
57
+
58
+ const makeCtx = (getLeafId: () => string | null): ExtensionContext =>
59
+ ({ sessionManager: { getLeafId } }) as unknown as ExtensionContext
60
+
61
+ return {
62
+ appendEntry,
63
+ input: (text: string, source = 'rpc') =>
64
+ handlers.get('input')!({ type: 'input', text, source } satisfies InputEvent as unknown as InputEvent),
65
+ inputRaw: (event: unknown) => handlers.get('input')!(event),
66
+ messageEnd: (role: string) =>
67
+ handlers.get('message_end')!({ type: 'message_end', message: { role } } as MessageEndEvent),
68
+ flush: (event, leafId = 'e-user-entry-1') => {
69
+ const ctx = makeCtx(() => leafId)
70
+ if (event === 'message_start') handlers.get(event)!({ type: event } as MessageStartEvent, ctx)
71
+ if (event === 'turn_end') handlers.get(event)!({ type: event } as TurnEndEvent, ctx)
72
+ if (event === 'agent_end') handlers.get(event)!({ type: event } as AgentEndEvent, ctx)
73
+ },
74
+ flushWithLeafIdFn: (event, getLeafId) => {
75
+ const ctx = makeCtx(getLeafId)
76
+ if (event === 'message_start') handlers.get(event)!({ type: event } as MessageStartEvent, ctx)
77
+ if (event === 'turn_end') handlers.get(event)!({ type: event } as TurnEndEvent, ctx)
78
+ if (event === 'agent_end') handlers.get(event)!({ type: event } as AgentEndEvent, ctx)
79
+ },
80
+ }
81
+ }
82
+
83
+ afterEach(() => {
84
+ vi.restoreAllMocks()
85
+ })
86
+
87
+ describe('input hook · clientUuid 提取(extractClientUuid)', () => {
88
+ it('rpc + 合法标记 → transform 剥离标记,uuid 进入 pending(后续 flush 可写入映射)', () => {
89
+ const h = createHarness()
90
+ const result = h.input(`do the task ${marker(UUID)}`)
91
+
92
+ expect(result).toEqual({ action: 'transform', text: 'do the task' })
93
+
94
+ // pending 已捕获:走完 message_end + flush 应写出该 uuid 的映射
95
+ h.messageEnd('user')
96
+ h.flush('message_start')
97
+ expect(h.appendEntry).toHaveBeenCalledWith(ENTRY_TYPE, {
98
+ clientUuid: UUID,
99
+ userEntryId: 'e-user-entry-1',
100
+ })
101
+ })
102
+
103
+ it('rpc + 无标记 → continue(文本不动,无映射可写)', () => {
104
+ const h = createHarness()
105
+ expect(h.input('plain prompt without marker')).toEqual({ action: 'continue' })
106
+
107
+ h.messageEnd('user')
108
+ h.flush('message_start')
109
+ expect(h.appendEntry).not.toHaveBeenCalled()
110
+ })
111
+
112
+ it('非 rpc source(interactive)+ 标记 → continue(不提取不剥离)', () => {
113
+ const h = createHarness()
114
+ expect(h.input(`interactive prompt ${marker(UUID)}`, 'interactive')).toEqual({ action: 'continue' })
115
+
116
+ h.messageEnd('user')
117
+ h.flush('message_start')
118
+ expect(h.appendEntry).not.toHaveBeenCalled()
119
+ })
120
+
121
+ it('畸形 uuid(长度不足 36 / 非 hex 字符)→ 标记不匹配,continue 原文透传', () => {
122
+ const h = createHarness()
123
+ // 长度不足:u- + 短 hex
124
+ expect(h.input(`short ${marker('u-abc')}`)).toEqual({ action: 'continue' })
125
+ // 非 hex 字符(xyz 不在 [0-9a-fA-F-] 内)
126
+ expect(h.input(`badchar ${marker('u-123e4567e89b12d3a456426614174xyz')}`)).toEqual({
127
+ action: 'continue',
128
+ })
129
+
130
+ h.messageEnd('user')
131
+ h.flush('message_start')
132
+ expect(h.appendEntry).not.toHaveBeenCalled()
133
+ })
134
+
135
+ it('多标记残留 → 全部剥离(TAG_STRIP 全局替换,非只剥第一个)', () => {
136
+ const h = createHarness()
137
+ const result = h.input(`task ${marker(UUID)}${marker('u-123e4567-e89b-12d3-a456-426614174111')}`)
138
+ // 首个 uuid 进 pending
139
+ expect(result).toEqual({ action: 'transform', text: 'task' })
140
+ h.messageEnd('user')
141
+ h.flush('message_start')
142
+ expect(h.appendEntry).toHaveBeenCalledWith(ENTRY_TYPE, {
143
+ clientUuid: UUID,
144
+ userEntryId: 'e-user-entry-1',
145
+ })
146
+ })
147
+
148
+ it('畸形 event(handler 内抛错)→ 兜底 return undefined 不外抛(console.error 可观测)', () => {
149
+ const h = createHarness()
150
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
151
+ // event 为 null → 取 event.source 即 throw,走 catch 兜底
152
+ expect(h.inputRaw(null)).toBeUndefined()
153
+ expect(errSpy).toHaveBeenCalledWith(
154
+ '[xyz-client-msg-id-mapper] input hook error:',
155
+ expect.any(TypeError),
156
+ )
157
+ })
158
+ })
159
+
160
+ describe('message_end hook · awaiting flag 门控', () => {
161
+ it('assistant message_end → 不置 flag,flush 不写映射', () => {
162
+ const h = createHarness()
163
+ h.input(`task ${marker(UUID)}`)
164
+ h.messageEnd('assistant')
165
+ h.flush('message_start')
166
+ expect(h.appendEntry).not.toHaveBeenCalled()
167
+ })
168
+
169
+ it('user message_end 但无 pendingClientUuid(无前序标记输入)→ flush 不写映射', () => {
170
+ const h = createHarness()
171
+ h.messageEnd('user')
172
+ h.flush('message_start')
173
+ expect(h.appendEntry).not.toHaveBeenCalled()
174
+ })
175
+
176
+ it('user message_end 但 pendingClientUuid 已被上一轮 flush 清空 → 不置 flag', () => {
177
+ const h = createHarness()
178
+ // 第一轮完整走完(写入并清空 pending)
179
+ h.input(`task ${marker(UUID)}`)
180
+ h.messageEnd('user')
181
+ h.flush('message_start')
182
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
183
+
184
+ // 第二轮:无新标记输入,仅 user message_end → flush 不重复写
185
+ h.messageEnd('user')
186
+ h.flush('message_start')
187
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
188
+ })
189
+ })
190
+
191
+ describe('flush · 映射写入 / 幂等 / 重试 / 异常兜底(writeMapping)', () => {
192
+ it('主路径:message_start 拿 leafId 写映射(clientUuid ↔ userEntryId)', () => {
193
+ const h = createHarness()
194
+ h.input(`task ${marker(UUID)}`)
195
+ h.messageEnd('user')
196
+ h.flush('message_start', 'e-leaf-user-42')
197
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
198
+ expect(h.appendEntry).toHaveBeenCalledWith(ENTRY_TYPE, {
199
+ clientUuid: UUID,
200
+ userEntryId: 'e-leaf-user-42',
201
+ })
202
+ })
203
+
204
+ it('幂等清空:flush 后 pending 清空,重复触发(turn_end/agent_end 兜底再打)不再写', () => {
205
+ const h = createHarness()
206
+ h.input(`task ${marker(UUID)}`)
207
+ h.messageEnd('user')
208
+ h.flush('message_start')
209
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
210
+
211
+ h.flush('turn_end')
212
+ h.flush('agent_end')
213
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
214
+ })
215
+
216
+ it('abort 兜底:message_start 不来,turn_end 单独完成写入', () => {
217
+ const h = createHarness()
218
+ h.input(`task ${marker(UUID)}`)
219
+ h.messageEnd('user')
220
+ h.flush('turn_end')
221
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
222
+ })
223
+
224
+ it('leafId 未就绪(null)→ 本次不写不抛错,pending 保留,下一 hook 重试成功', () => {
225
+ const h = createHarness()
226
+ h.input(`task ${marker(UUID)}`)
227
+ h.messageEnd('user')
228
+ // message_start 时 leafId 尚未更新(null)
229
+ h.flush('message_start', null)
230
+ expect(h.appendEntry).not.toHaveBeenCalled()
231
+
232
+ // turn_end 时 leafId 就绪 → 补写
233
+ h.flush('turn_end', 'e-leaf-late-1')
234
+ expect(h.appendEntry).toHaveBeenCalledTimes(1)
235
+ expect(h.appendEntry).toHaveBeenCalledWith(ENTRY_TYPE, {
236
+ clientUuid: UUID,
237
+ userEntryId: 'e-leaf-late-1',
238
+ })
239
+ })
240
+
241
+ it('appendEntry 抛错 → flush 吞错不外抛,flag 未清,下次 hook 幂等重试', () => {
242
+ const h = createHarness()
243
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
244
+ h.appendEntry.mockImplementation(() => {
245
+ throw new Error('jsonl write failed')
246
+ })
247
+
248
+ h.input(`task ${marker(UUID)}`)
249
+ h.messageEnd('user')
250
+ expect(() => h.flush('message_start')).not.toThrow()
251
+ expect(errSpy).toHaveBeenCalledWith('[xyz-client-msg-id-mapper] flush error:', expect.any(Error))
252
+
253
+ // 重试成功:appendEntry 恢复 → turn_end 补写(flag 未清的幂等重试语义)
254
+ h.appendEntry.mockImplementation(() => {})
255
+ h.flush('turn_end')
256
+ expect(h.appendEntry).toHaveBeenCalledTimes(2) // 1 次失败 + 1 次成功
257
+ })
258
+
259
+ it('getLeafId 抛错 → flush 吞错不外抛(catch 兜底覆盖 writeMapping 全程)', () => {
260
+ const h = createHarness()
261
+ const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
262
+ h.input(`task ${marker(UUID)}`)
263
+ h.messageEnd('user')
264
+
265
+ expect(() =>
266
+ h.flushWithLeafIdFn('message_start', () => {
267
+ throw new Error('session manager gone')
268
+ }),
269
+ ).not.toThrow()
270
+ expect(errSpy).toHaveBeenCalledWith('[xyz-client-msg-id-mapper] flush error:', expect.any(Error))
271
+ })
272
+
273
+ it('无 user message_end(未置 flag)→ 三重安全网全不写', () => {
274
+ const h = createHarness()
275
+ h.input(`task ${marker(UUID)}`) // 只有 pending,没有 user 落盘
276
+ h.flush('message_start')
277
+ h.flush('turn_end')
278
+ h.flush('agent_end')
279
+ expect(h.appendEntry).not.toHaveBeenCalled()
280
+ })
281
+ })
package/src/index.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Client UUID ↔ user entry ID mapping extension for Pi.
3
+ *
4
+ * Establishes clientUuid ↔ userEntryId mapping for xyz-agent session metadata
5
+ * preservation across sessions.
6
+ *
7
+ * Mechanism (no pi source modification, pure extension hooks):
8
+ * 1. xyz-agent appends HTML comment marker `<!--xyz:msg:<uuid>-->` to prompt text
9
+ * (uuid is the full user message id from appendUser, formatted as `u-<36hex>`).
10
+ * 2. `input` hook intercepts prompts with source==='rpc', strips the marker
11
+ * (LLM doesn't see the transformed content beyond the transform), and stores
12
+ * the uuid in pendingClientUuid.
13
+ * 3. After user `message_end`, the next triggered hook (message_start/turn_end/agent_end)
14
+ * reads `ctx.sessionManager.getLeafId()` which now returns the persisted userEntryId.
15
+ * 4. `pi.appendEntry("xyz.client-msg-id", {clientUuid, userEntryId})` writes the mapping
16
+ * into pi JSONL (CustomEntry, not in LLM context).
17
+ *
18
+ * Mapping is automatically preserved with fork/clone (in the same JSONL).
19
+ * When xyz-agent reopens a session, it scans customType==="xyz.client-msg-id" entries
20
+ * to rebuild the mapping table.
21
+ *
22
+ * Degradation strategy: any hook error → pi runner's try/catch swallows it,
23
+ * mapping is missing, and xyz-agent degrades to textToSegments (split by plain text).
24
+ * Missing mapping doesn't affect the agent main flow.
25
+ *
26
+ * Same package shape as @zhushanwen/pi-system-prompt (extensions/system-prompt/):
27
+ * TypeScript source with no build step and no runtime deps (peer dep on pi only),
28
+ * bundled as a builtin extension by scripts/bundle-extensions.mjs.
29
+ */
30
+
31
+ import type { ExtensionAPI, ExtensionContext, InputEvent, MessageEndEvent, MessageStartEvent, TurnEndEvent, AgentEndEvent } from '@earendil-works/pi-coding-agent'
32
+
33
+ // uuid = appendUser 生成的 user message id(`u-` + 36 字符 hex uuid,共 38 字符)。
34
+ // 与 shared SegmentsMetadataEntry.clientUuid 严格一致(同 appendUser 返回值):
35
+ // extension 写入 custom entry 的 clientUuid = segments.json 的 clientUuid key,
36
+ // entry-tree-builder 据此精确回填 segments(无前缀转换、无双 source of truth)。
37
+ //
38
+ // m1 修复:拆两个正则。TAG_MATCH 非全局,用于 .match 提取第一个 uuid 捕获组(input hook
39
+ // 只需首个 pending uuid);TAG_STRIP 全局,用于 .replace 剥离所有残留标记(防并发/重试
40
+ // 场景多个标记拼接在同一 prompt 末尾时只剥掉第一个)。两正则分离避免 g 标志的 lastIndex
41
+ // 状态污染(String.match 与全局 exec 混用易踩坑)。
42
+ const TAG_MATCH = /<!--xyz:msg:(u-[0-9a-fA-F-]{36})-->/
43
+ const TAG_STRIP = /<!--xyz:msg:u-[0-9a-fA-F-]{36}-->/g
44
+ const ENTRY_TYPE = 'xyz.client-msg-id'
45
+
46
+ /** 提取 prompt 里首个标记的 client uuid;无标记 → undefined。 */
47
+ function extractClientUuid(text: string): string | undefined {
48
+ return text.match(TAG_MATCH)?.[1]
49
+ }
50
+
51
+ export default function (pi: ExtensionAPI): void {
52
+ // 待处理的 client uuid(input hook 抓到标记后写入,flush 后清空)。
53
+ let pendingClientUuid: string | undefined = undefined
54
+ // user message 已 message_end、等待 flush(拿到 leafId 后写映射)。
55
+ let awaitingUserPersist = false
56
+
57
+ // input hook:拦截 xyz-agent 发来的 prompt(source === 'rpc'),剥离标记。
58
+ // 非 rpc(interactive/extension)输入不含标记,直接 continue。
59
+ pi.on('input', (event: InputEvent) => {
60
+ try {
61
+ if (event.source !== 'rpc') return { action: 'continue' as const }
62
+ const clientUuid = extractClientUuid(event.text)
63
+ if (!clientUuid) return { action: 'continue' as const }
64
+ pendingClientUuid = clientUuid
65
+ // transform 后 LLM 看到的是剥离了标记的纯文本。
66
+ // TAG_STRIP 全局替换:防多个标记残留时只剥掉第一个(m1 修复)。
67
+ return { action: 'transform' as const, text: event.text.replace(TAG_STRIP, '').trimEnd() }
68
+ } catch (err) {
69
+ // 吞错,不阻断主流程(pi runner 也会 try/catch,但显式兜底避免意外 return)。
70
+ console.error('[xyz-client-msg-id-mapper] input hook error:', err)
71
+ return undefined
72
+ }
73
+ })
74
+
75
+ // message_end:user message 持久化前触发。此时 getLeafId() 还指向上一条 entry
76
+ // (user message 尚未落盘),所以只置 flag,真正 flush 在下一个 hook。
77
+ pi.on('message_end', (event: MessageEndEvent) => {
78
+ try {
79
+ if (event.message.role === 'user' && pendingClientUuid) {
80
+ awaitingUserPersist = true
81
+ }
82
+ } catch (err) {
83
+ // best-effort:置 flag 失败不阻断消息流——仅丢失该条 clientUuid↔entryId 映射,对话不受影响。
84
+ console.error('[xyz-client-msg-id-mapper] message_end hook error:', err)
85
+ }
86
+ return undefined
87
+ })
88
+
89
+ /** 写映射并清空 pending 状态。leafId 未就绪时静默返回,等下一个 hook 重试。 */
90
+ const writeMapping = (ctx: ExtensionContext): void => {
91
+ if (!pendingClientUuid) return
92
+ // 类型已保证 sessionManager.getLeafId 必有(pi 0.84.1 ReadonlySessionManager,
93
+ // 返回 string | null);运行时异常由 flush 的 catch 兜底。
94
+ const userEntryId = ctx.sessionManager.getLeafId()
95
+ if (!userEntryId) return // leafId 还没更新,等下一个 hook
96
+ pi.appendEntry(ENTRY_TYPE, {
97
+ clientUuid: pendingClientUuid,
98
+ userEntryId,
99
+ })
100
+ pendingClientUuid = undefined
101
+ awaitingUserPersist = false
102
+ }
103
+
104
+ // flush:读 getLeafId()(= userEntryId,user message 已持久化)+ appendEntry 写映射。
105
+ // 幂等:写完即清空 pendingClientUuid / awaitingUserPersist,重复触发无副作用。
106
+ const flush = (ctx: ExtensionContext): void => {
107
+ if (!awaitingUserPersist) return
108
+ try {
109
+ writeMapping(ctx)
110
+ } catch (err) {
111
+ // best-effort:映射写入失败不阻断消息流——丢的是本条映射,下次 hook 会因 flag 未清而幂等重试。
112
+ console.error('[xyz-client-msg-id-mapper] flush error:', err)
113
+ }
114
+ }
115
+
116
+ // 三重安全网:user message_end 后第一个触发的 hook 拿到的 leafId 才是 userEntryId。
117
+ // message_start(assistant 开始,此时 user message 已落盘)—— 主路径。
118
+ pi.on('message_start', (_event: MessageStartEvent, ctx: ExtensionContext) => flush(ctx))
119
+ // turn_end / agent_end 兜底(abort 等场景 message_start 可能不来)。
120
+ pi.on('turn_end', (_event: TurnEndEvent, ctx: ExtensionContext) => flush(ctx))
121
+ pi.on('agent_end', (_event: AgentEndEvent, ctx: ExtensionContext) => flush(ctx))
122
+ }