@raidiant/notifai 2.0.2 → 4.0.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.
@@ -107,14 +107,14 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
107
107
  choiceHints: {
108
108
  default: 'the standard notification tone',
109
109
  done: 'the completion chime',
110
- attention: 'used for questions that want an answer',
110
+ attention: 'a distinct attention tone',
111
111
  alert: 'the most insistent tone',
112
112
  none: 'silent — arrives without a sound',
113
113
  },
114
- summary: 'Override the sound every notification uses',
114
+ summary: 'Set the sound notifications from here use',
115
115
  detail:
116
- 'Normally the sound comes from what the notification is: progress updates are silent, completions get the done chime, questions get the attention tone. Setting this pins one sound for everything sent from here, overriding those profiles.\n\nA common use is `none`, to make a noisy project completely silent.',
117
- unsetMeans: 'each notification uses the sound for its kind',
116
+ 'Setting this pins one sound for every notification sent from here. Kind remains semantic status metadata and never changes native-banner attention.\n\nLeave this unset to omit the sound field and let the destination use its normal behavior. A common explicit preference is `none`, to make a noisy project completely silent.',
117
+ unsetMeans: 'not specified; the destination uses its normal behavior',
118
118
  },
119
119
  interruption_level: {
120
120
  label: 'Interruption level',
@@ -126,10 +126,10 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
126
126
  active: 'wakes the screen and plays the sound',
127
127
  time_sensitive: 'may break through Focus modes',
128
128
  },
129
- summary: 'Override how insistently notifications arrive',
129
+ summary: 'Set how insistently notifications from here arrive',
130
130
  detail:
131
- 'How hard a notification tries to reach you, in the operating system\'s terms. Like the sound, this is normally decided by what the notification is; setting it pins one level for everything.\n\n`time_sensitive` can pierce a Focus mode, so it is worth reserving for the cases that genuinely earn it.',
132
- unsetMeans: 'each notification uses the level for its kind',
131
+ 'How hard a notification tries to reach you, in the operating system\'s terms. Setting this pins one level for everything sent from here; kind never changes it.\n\nLeave this unset to omit the field and let the destination use its normal behavior. `time_sensitive` can pierce a Focus mode, so reserve it for cases that genuinely earn it.',
132
+ unsetMeans: 'not specified; the destination uses its normal behavior',
133
133
  },
134
134
  devices: {
135
135
  label: 'Default devices',
@@ -178,8 +178,8 @@ const INFO: Record<ConfigKey, Omit<ConfigKeyInfo, 'key'>> = {
178
178
  kind: 'string',
179
179
  summary: 'The name this project reports itself as',
180
180
  detail:
181
- 'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives. Normally set once in `.notifai/config.toml` and committed, so everyone working on the repository reports the same name.',
182
- unsetMeans: 'notifications arrive without a project',
181
+ 'Stamped on every notification sent from this directory, and how the companion apps group and filter what arrives. When unset, the CLI infers a stable identifier from Git (shared across linked worktrees) or the current directory. Set it in `.notifai/config.toml` only when that inferred name is not the identity you want.',
182
+ unsetMeans: 'inferred from Git or the current directory',
183
183
  example: 'my-app',
184
184
  },
185
185
  notify_criteria: {
@@ -98,6 +98,25 @@ describe('harness config layers', () => {
98
98
  })
99
99
  })
100
100
 
101
+ it('uses NOTIFAI_SESSION_ID for the session layer and ignores the deleted env name', () => {
102
+ const { env, cwd } = setup({
103
+ sessionToml: 'ask_grace_seconds = 45\n',
104
+ sessionId: 'exact-session',
105
+ })
106
+ expect(
107
+ loadConfig({
108
+ cwd,
109
+ env: { ...env, NOTIFAI_SESSION_ID: 'exact-session' },
110
+ }).ask_grace_seconds.value,
111
+ ).toBe(45)
112
+ expect(
113
+ loadConfig({
114
+ cwd,
115
+ env: { ...env, NOTIFAI_SESSION: 'exact-session' },
116
+ }).ask_grace_seconds.value,
117
+ ).toBe(0)
118
+ })
119
+
101
120
  it('keeps hostile or colliding session ids inside their own file', () => {
102
121
  const { env, cwd } = setup({
103
122
  sessionToml: 'ask_grace_seconds = 99\n',
@@ -172,28 +191,23 @@ describe('draft building', () => {
172
191
  presentation: { title: 'T', body: 'B' },
173
192
  targets: { mode: 'all' },
174
193
  delivery: { ttl_seconds: 86400, collapse_key: null },
175
- platform: {
176
- ios: { sound: null, interruption_level: 'passive' },
177
- macos: { sound: null, interruption_level: 'passive' },
178
- },
179
194
  })
180
195
  })
181
196
 
182
197
  it.each([
183
- { flags: {}, sound: null, level: 'passive' },
184
- { flags: { kind: 'done' }, sound: 'done', level: 'passive' },
185
- { flags: { reply: true }, sound: 'attention', level: 'active' },
186
- ])('derives the $sound / $level kind profile from $flags', ({ flags, sound, level }) => {
198
+ {},
199
+ { kind: 'done' },
200
+ { kind: 'failed' },
201
+ { kind: 'blocked' },
202
+ { reply: true },
203
+ ])('never derives sound or interruption level from kind: %j', (flags) => {
187
204
  const config = loadConfig({ cwd: base.cwd, env: base.env })
188
205
  const build = buildDraft(config, { title: 'T', body: 'B', ...flags })
189
206
  if (!build.ok) throw new Error(build.error)
190
- expect(build.draft.platform).toEqual({
191
- ios: { sound, interruption_level: level },
192
- macos: { sound, interruption_level: level },
193
- })
207
+ expect(build.draft.platform).toBeUndefined()
194
208
  })
195
209
 
196
- it('orders explicit flags over user config over the kind profile', () => {
210
+ it('orders explicit flags over saved user config', () => {
197
211
  const { env, cwd } = setup({
198
212
  projectToml: 'sound = "alert"\ninterruption_level = "active"\n',
199
213
  })
@@ -227,15 +241,129 @@ describe('draft building', () => {
227
241
  expect(build.draft.reply?.expires_in_seconds).toBe(3_600)
228
242
  })
229
243
 
230
- it('stamps the project from flag or project config', () => {
244
+ it('orders explicit and configured Project identity over inference', () => {
231
245
  const { env, cwd } = setup({ projectToml: 'project = "my-app"\n' })
232
246
  const config = loadConfig({ cwd, env })
233
- const fromConfig = buildDraft(config, { title: 'T', body: 'B' })
247
+ const fromConfig = buildDraft(
248
+ config,
249
+ { title: 'T', body: 'B' },
250
+ { inferredProject: 'inferred-app' },
251
+ )
234
252
  if (!fromConfig.ok) throw new Error(fromConfig.error)
235
253
  expect(fromConfig.draft.project).toBe('my-app')
236
- const fromFlag = buildDraft(config, { title: 'T', body: 'B', project: 'other-app' })
254
+ const fromFlag = buildDraft(
255
+ config,
256
+ { title: 'T', body: 'B', project: 'other-app' },
257
+ { inferredProject: 'inferred-app' },
258
+ )
237
259
  if (!fromFlag.ok) throw new Error(fromFlag.error)
238
260
  expect(fromFlag.draft.project).toBe('other-app')
261
+
262
+ const inferredCase = setup({})
263
+ const unset = loadConfig({ cwd: inferredCase.cwd, env: inferredCase.env })
264
+ const inferred = buildDraft(
265
+ unset,
266
+ { title: 'T', body: 'B' },
267
+ { inferredProject: 'inferred-app' },
268
+ )
269
+ if (!inferred.ok) throw new Error(inferred.error)
270
+ expect(inferred.draft.project).toBe('inferred-app')
271
+ })
272
+
273
+ it('carries Source Context without copying authoring overrides into legacy fields', () => {
274
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
275
+ const build = buildDraft(
276
+ config,
277
+ { title: 'T', body: 'B', sessionId: 'opaque', sessionLabel: 'Visible' },
278
+ {
279
+ source: {
280
+ session_id: 'opaque',
281
+ session_label: 'Visible',
282
+ harness: 'codex',
283
+ branch: 'main',
284
+ worktree: 'topic',
285
+ },
286
+ },
287
+ )
288
+ if (!build.ok) throw new Error(build.error)
289
+ expect(build.draft.source).toEqual({
290
+ session_id: 'opaque',
291
+ session_label: 'Visible',
292
+ harness: 'codex',
293
+ branch: 'main',
294
+ worktree: 'topic',
295
+ })
296
+ expect(build.draft).not.toHaveProperty('session')
297
+ })
298
+
299
+ it('rewrites positional media references and preserves canonical ids', () => {
300
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
301
+ const build = buildDraft(config, {
302
+ title: 'Visual result',
303
+ body: '![first](media:1) then ![second](media:2) and ![existing](media:med_keep)',
304
+ image: ['med_first', 'med_second', 'med_attached'],
305
+ imageAlt: ['First image', 'Second image'],
306
+ })
307
+ if (!build.ok) throw new Error(build.error)
308
+ expect(build.draft.presentation.body).toBe(
309
+ '![first](media:med_first) then ![second](media:med_second) and ![existing](media:med_keep)',
310
+ )
311
+ expect(build.draft.presentation.body).not.toMatch(/media:[1-8](?!\d)/)
312
+ expect(build.draft.presentation.media).toEqual([
313
+ { media_id: 'med_first', alt: 'First image' },
314
+ { media_id: 'med_second', alt: 'Second image' },
315
+ { media_id: 'med_attached' },
316
+ ])
317
+ })
318
+
319
+ it('preserves sparse alt text in an internal ordered media manifest', () => {
320
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
321
+ const build = buildDraft(config, {
322
+ title: 'Visual result',
323
+ body: '![second](media:2)',
324
+ media: [
325
+ { media_id: 'med_first' },
326
+ { media_id: 'med_second', alt: 'Second image' },
327
+ ],
328
+ })
329
+ if (!build.ok) throw new Error(build.error)
330
+ expect(build.draft.presentation.body).toBe('![second](media:med_second)')
331
+ expect(build.draft.presentation.media).toEqual([
332
+ { media_id: 'med_first' },
333
+ { media_id: 'med_second', alt: 'Second image' },
334
+ ])
335
+ })
336
+
337
+ it('names an out-of-range positional media reference', () => {
338
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
339
+ const build = buildDraft(config, {
340
+ title: 'Visual result',
341
+ body: '![missing](media:3)',
342
+ image: ['med_first', 'med_second'],
343
+ })
344
+ expect(build).toEqual({
345
+ ok: false,
346
+ error: 'Body reference "media:3" has no matching --image occurrence.',
347
+ })
348
+ })
349
+
350
+ it('validates media cardinality and alt pairing', () => {
351
+ const config = loadConfig({ cwd: base.cwd, env: base.env })
352
+ expect(
353
+ buildDraft(config, {
354
+ title: 'T',
355
+ body: 'B',
356
+ image: Array.from({ length: 9 }, (_, index) => `med_${index}`),
357
+ }),
358
+ ).toMatchObject({ ok: false, error: expect.stringContaining('at most 8') })
359
+ expect(
360
+ buildDraft(config, {
361
+ title: 'T',
362
+ body: 'B',
363
+ image: ['med_one'],
364
+ imageAlt: ['one', 'two'],
365
+ }),
366
+ ).toMatchObject({ ok: false, error: expect.stringContaining('2 --image-alt') })
239
367
  })
240
368
 
241
369
  it('carries --kind and rejects one outside the vocabulary', () => {
package/src/config.ts CHANGED
@@ -380,7 +380,7 @@ export function loadConfig(options: {
380
380
  const projectRaw = projectPath ? readTomlFile(projectPath) : {}
381
381
  const projectLocalPath = findProjectLocalConfigPath(cwd, env)
382
382
  const projectLocalRaw = projectLocalPath ? readTomlFile(projectLocalPath) : {}
383
- const sessionId = options.sessionId ?? env['NOTIFAI_SESSION']
383
+ const sessionId = options.sessionId ?? env['NOTIFAI_SESSION_ID']
384
384
  const sessionPath = sessionId ? sessionConfigPath(sessionId, env) : null
385
385
  const sessionRaw = sessionPath && existsSync(sessionPath) ? readTomlFile(sessionPath) : {}
386
386
 
package/src/hooks.test.ts CHANGED
@@ -14,11 +14,12 @@ import {
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
16
  import { PassThrough } from 'node:stream'
17
- import type {
18
- ListRepliesResponse,
19
- ReplyView,
20
- SubmissionReceipt,
21
- SubmitNotificationRequestT,
17
+ import {
18
+ sessionLabelFromId,
19
+ type ListRepliesResponse,
20
+ type ReplyView,
21
+ type SubmissionReceipt,
22
+ type SubmitNotificationRequestT,
22
23
  } from '@raidiant/notifai-protocol'
23
24
  import { describe, expect, it, vi } from 'vitest'
24
25
  import { ApiCallError, type ApiClient } from './client.js'
@@ -54,11 +55,46 @@ import {
54
55
  readMatchingProjectSessionPointer,
55
56
  readProjectSession,
56
57
  readSessionState,
57
- registerQuestion,
58
+ registerQuestion as persistQuestion,
58
59
  writeProjectSession,
59
- writeSessionState,
60
+ writeSessionState as persistSessionState,
61
+ type PendingQuestion,
62
+ type SessionState,
60
63
  } from './hooks.js'
61
64
 
65
+ /** New-format test fixtures always carry the canonical body explicitly. */
66
+ function registerQuestion(
67
+ sessionId: string,
68
+ env: NodeJS.ProcessEnv,
69
+ question: PendingQuestion,
70
+ now?: number,
71
+ ): void {
72
+ persistQuestion(
73
+ sessionId,
74
+ env,
75
+ { ...question, body: question.body ?? question.question },
76
+ now,
77
+ )
78
+ }
79
+
80
+ function writeSessionState(
81
+ sessionId: string,
82
+ env: NodeJS.ProcessEnv,
83
+ state: SessionState,
84
+ ): void {
85
+ persistSessionState(sessionId, env, {
86
+ ...state,
87
+ ...(state.pending === undefined
88
+ ? {}
89
+ : {
90
+ pending: state.pending.map((entry) => ({
91
+ ...entry,
92
+ body: entry.body ?? entry.question,
93
+ })),
94
+ }),
95
+ })
96
+ }
97
+
62
98
  class CapturedIo implements CommandIo {
63
99
  outLines: string[] = []
64
100
  errLines: string[] = []
@@ -158,6 +194,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
158
194
  }
159
195
  },
160
196
  createMediaUpload: notUsed,
197
+ finalizeMediaUpload: notUsed,
161
198
  uploadMedia: notUsed,
162
199
  health: async () => true,
163
200
  submit: async (body) => {
@@ -431,16 +468,38 @@ describe('pushing a registered question', () => {
431
468
  device_ids: ['dev_iphone', 'dev_mac'],
432
469
  })
433
470
  expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
434
- title: 'Question',
471
+ title: 'Ship it?',
435
472
  body: 'Ship it?',
436
473
  })
437
- expect(h.recorder.submitted[0]?.draft.platform).toEqual({
438
- ios: { sound: 'attention', interruption_level: 'active' },
439
- macos: { sound: 'attention', interruption_level: 'active' },
440
- })
474
+ expect(h.recorder.submitted[0]?.draft.platform).toBeUndefined()
475
+ })
476
+
477
+ it('keeps sparse alt text paired with its original media item', async () => {
478
+ const h = harness([reply({ text: 'Yes' })])
479
+ writeSessionState('media-order', h.env, { last_prompt_at: AWAY })
480
+ registerQuestion(
481
+ 'media-order',
482
+ h.env,
483
+ {
484
+ question: 'Which image should ship?',
485
+ body: 'Compare ![the second image](media:med_second).',
486
+ media: [
487
+ { media_id: 'med_first' },
488
+ { media_id: 'med_second', alt: 'Second image' },
489
+ ],
490
+ },
491
+ NOW,
492
+ )
493
+
494
+ await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'media-order' }))
495
+
496
+ expect(h.recorder.submitted[0]?.draft.presentation.media).toEqual([
497
+ { media_id: 'med_first' },
498
+ { media_id: 'med_second', alt: 'Second image' },
499
+ ])
441
500
  })
442
501
 
443
- it('names the resolved project in the pushed question title', async () => {
502
+ it('keeps Project identity out of the substantive question title', async () => {
444
503
  const h = harness([reply({ text: 'Yes' })])
445
504
  const projectDir = path.join(h.deps.cwd, '.notifai')
446
505
  mkdirSync(projectDir, { recursive: true })
@@ -449,11 +508,17 @@ describe('pushing a registered question', () => {
449
508
  'project = "notifai-cli"\nask_grace_seconds = 0\n',
450
509
  )
451
510
  writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
452
- registerQuestion('project-title', h.env, { question: 'Ship it?' }, NOW)
511
+ registerQuestion(
512
+ 'project-title',
513
+ h.env,
514
+ { question: 'Ship it?', project: 'notifai-cli' },
515
+ NOW,
516
+ )
453
517
 
454
518
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
455
519
 
456
- expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Question · notifai-cli')
520
+ expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Ship it?')
521
+ expect(h.recorder.submitted[0]?.draft.project).toBe('notifai-cli')
457
522
  })
458
523
  })
459
524
 
@@ -1899,7 +1964,6 @@ describe('a question that outlives its session', () => {
1899
1964
  question: 'Old?',
1900
1965
  state: 'expired',
1901
1966
  }],
1902
- undefined,
1903
1967
  NOW - 25 * 3600 * 1000,
1904
1968
  )
1905
1969
  const drained = await drainOrphanRetirements(
@@ -1961,7 +2025,7 @@ describe('ask registration', () => {
1961
2025
  const h = harness()
1962
2026
  // Inside a hook, a rejection is only a stderr note the agent never reads —
1963
2027
  // so it would look registered and then silently never ask.
1964
- expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], session: 'a1' })).toBe(EXIT.usage)
2028
+ expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], sessionId: 'a1' })).toBe(EXIT.usage)
1965
2029
  expect(readSessionState('a1', h.env).pending).toBeUndefined()
1966
2030
  })
1967
2031
 
@@ -1992,7 +2056,7 @@ describe('ask registration', () => {
1992
2056
  { text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
1993
2057
  { text: 'Anything to watch?' },
1994
2058
  ],
1995
- detail: '## Context\nThe long story.',
2059
+ body: '## Context\nThe long story.',
1996
2060
  }) },
1997
2061
  undefined,
1998
2062
  )
@@ -2001,18 +2065,18 @@ describe('ask registration', () => {
2001
2065
  registerQuestion('form1', h.env, {
2002
2066
  question: built.questions[0]!.text,
2003
2067
  questions: built.questions,
2004
- ...(built.detail === undefined ? {} : { detail: built.detail }),
2068
+ body: built.body,
2005
2069
  })
2006
2070
  const pending = readSessionState('form1', h.env).pending?.[0]
2007
2071
  expect(pending?.questions).toHaveLength(2)
2008
- expect(pending?.detail).toContain('long story')
2072
+ expect(pending?.body).toContain('long story')
2009
2073
 
2010
2074
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
2011
2075
  const draft = h.recorder.submitted[0]?.draft
2012
- // The banner leads with the first question and admits the rest exists;
2013
- // the full set rides the payload for the answering card.
2014
- expect(draft?.presentation.body).toBe('Deploy where? (+1 more)')
2015
- expect(draft?.presentation.detail).toContain('long story')
2076
+ expect(draft?.presentation.title).toBe('Deploy where?')
2077
+ expect(draft?.presentation.body).toBe(
2078
+ '1. Deploy where?\n2. Anything to watch?\n\n## Context\nThe long story.',
2079
+ )
2016
2080
  expect(draft?.reply?.questions).toEqual([
2017
2081
  {
2018
2082
  id: 'deploy-where',
@@ -2107,7 +2171,7 @@ describe('ask registration', () => {
2107
2171
 
2108
2172
  it('does not let an explicit session bypass exact active-harness proof', () => {
2109
2173
  const h = harness()
2110
- expect(askCommand(h.deps, 'Ship it?', { session: 'guessed' })).toBe(EXIT.usage)
2174
+ expect(askCommand(h.deps, 'Ship it?', { sessionId: 'guessed' })).toBe(EXIT.usage)
2111
2175
  expect(readSessionState('guessed', h.env).pending).toBeUndefined()
2112
2176
  })
2113
2177
 
@@ -2528,21 +2592,46 @@ describe('malformed input', () => {
2528
2592
  })
2529
2593
 
2530
2594
  describe('telling concurrent agents apart', () => {
2531
- it('stamps the harness session on the question it pushes', async () => {
2532
- // The hook has always known session_id and never passed it on, so two
2533
- // agents in separate worktrees produced identical notifications and the
2534
- // user could answer the wrong one's question.
2595
+ it('stamps structured Source Context on the question it pushes', async () => {
2535
2596
  const h = harness([])
2536
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2597
+ registerQuestion(
2598
+ 'sess-abc',
2599
+ h.env,
2600
+ {
2601
+ question: 'Ship it?',
2602
+ source: {
2603
+ session_id: 'sess-abc',
2604
+ session_label: sessionLabelFromId('sess-abc'),
2605
+ harness: 'claude-code',
2606
+ },
2607
+ },
2608
+ NOW,
2609
+ )
2537
2610
 
2538
2611
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2539
2612
 
2540
- expect(h.recorder.submitted[0]?.draft.session).toBe('sess-abc')
2613
+ expect(h.recorder.submitted[0]?.draft.source).toEqual({
2614
+ session_id: 'sess-abc',
2615
+ session_label: sessionLabelFromId('sess-abc'),
2616
+ harness: 'claude-code',
2617
+ })
2541
2618
  })
2542
2619
 
2543
2620
  it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
2544
2621
  const h = harness([reply({ text: 'Yes' })])
2545
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2622
+ registerQuestion(
2623
+ 'sess-abc',
2624
+ h.env,
2625
+ {
2626
+ question: 'Ship it?',
2627
+ source: {
2628
+ session_id: 'sess-abc',
2629
+ session_label: sessionLabelFromId('sess-abc'),
2630
+ harness: 'claude-code',
2631
+ },
2632
+ },
2633
+ NOW,
2634
+ )
2546
2635
 
2547
2636
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2548
2637
  h.recorder.acknowledged ??= new Set()
@@ -2554,17 +2643,35 @@ describe('telling concurrent agents apart', () => {
2554
2643
  )
2555
2644
 
2556
2645
  const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
2557
- expect(retirement?.draft.session).toBe('sess-abc')
2646
+ expect(retirement?.draft.source).toEqual({
2647
+ session_id: 'sess-abc',
2648
+ session_label: sessionLabelFromId('sess-abc'),
2649
+ harness: 'claude-code',
2650
+ })
2558
2651
  })
2559
2652
 
2560
- it('prefers a name the user chose over the harness UUID', async () => {
2653
+ it('preserves a human session label without displaying the opaque id', async () => {
2561
2654
  const h = harness([])
2562
- h.env['NOTIFAI_SESSION'] = 'migration-worktree'
2563
- registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
2655
+ registerQuestion(
2656
+ 'sess-abc',
2657
+ h.env,
2658
+ {
2659
+ question: 'Ship it?',
2660
+ source: {
2661
+ session_id: 'sess-abc',
2662
+ session_label: 'Migration Worktree',
2663
+ harness: 'claude-code',
2664
+ },
2665
+ },
2666
+ NOW,
2667
+ )
2564
2668
 
2565
2669
  await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
2566
2670
 
2567
- expect(h.recorder.submitted[0]?.draft.session).toBe('migration-worktree')
2671
+ const draft = h.recorder.submitted[0]?.draft
2672
+ expect(draft?.source?.session_label).toBe('Migration Worktree')
2673
+ expect(draft?.presentation.title).not.toContain('sess-abc')
2674
+ expect(draft?.presentation.body).not.toContain('sess-abc')
2568
2675
  })
2569
2676
  })
2570
2677
 
@@ -2712,7 +2819,6 @@ describe('durable state writes', () => {
2712
2819
  state: 'expired',
2713
2820
  },
2714
2821
  ],
2715
- 'linked-pointer',
2716
2822
  NOW,
2717
2823
  ),
2718
2824
  ).toThrow(/symlink/)