@raidiant/notifai 3.0.1 → 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.
- package/CHANGELOG.md +27 -0
- package/README.md +7 -4
- package/dist/commands.d.ts +17 -9
- package/dist/commands.js +261 -100
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.js +9 -9
- package/dist/config-schema.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/hooks.d.ts +12 -7
- package/dist/hooks.js +26 -46
- package/dist/hooks.js.map +1 -1
- package/dist/invocation-context.d.ts +42 -0
- package/dist/invocation-context.js +130 -0
- package/dist/invocation-context.js.map +1 -0
- package/dist/main.js +46 -38
- package/dist/main.js.map +1 -1
- package/dist/send.d.ts +32 -7
- package/dist/send.js +77 -32
- package/dist/send.js.map +1 -1
- package/package.json +2 -2
- package/src/commands.test.ts +325 -21
- package/src/commands.ts +354 -136
- package/src/config-schema.test.ts +1 -1
- package/src/config-schema.ts +9 -9
- package/src/config.test.ts +144 -16
- package/src/config.ts +1 -1
- package/src/hooks.test.ts +143 -38
- package/src/hooks.ts +67 -73
- package/src/invocation-context.test.ts +253 -0
- package/src/invocation-context.ts +176 -0
- package/src/main.ts +72 -41
- package/src/send.ts +116 -38
- package/src/skill.test.ts +31 -7
package/src/config-schema.ts
CHANGED
|
@@ -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: '
|
|
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: '
|
|
114
|
+
summary: 'Set the sound notifications from here use',
|
|
115
115
|
detail:
|
|
116
|
-
'
|
|
117
|
-
unsetMeans: '
|
|
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: '
|
|
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.
|
|
132
|
-
unsetMeans: '
|
|
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.
|
|
182
|
-
unsetMeans: '
|
|
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: {
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
-
{
|
|
184
|
-
{
|
|
185
|
-
{
|
|
186
|
-
|
|
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).
|
|
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
|
|
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('
|
|
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(
|
|
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(
|
|
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: ' then  and ',
|
|
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
|
+
' then  and ',
|
|
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: '',
|
|
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('')
|
|
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: '',
|
|
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['
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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[] = []
|
|
@@ -432,16 +468,38 @@ describe('pushing a registered question', () => {
|
|
|
432
468
|
device_ids: ['dev_iphone', 'dev_mac'],
|
|
433
469
|
})
|
|
434
470
|
expect(h.recorder.submitted[0]?.draft.presentation).toMatchObject({
|
|
435
|
-
title: '
|
|
471
|
+
title: 'Ship it?',
|
|
436
472
|
body: 'Ship it?',
|
|
437
473
|
})
|
|
438
|
-
expect(h.recorder.submitted[0]?.draft.platform).
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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 .',
|
|
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
|
+
])
|
|
442
500
|
})
|
|
443
501
|
|
|
444
|
-
it('
|
|
502
|
+
it('keeps Project identity out of the substantive question title', async () => {
|
|
445
503
|
const h = harness([reply({ text: 'Yes' })])
|
|
446
504
|
const projectDir = path.join(h.deps.cwd, '.notifai')
|
|
447
505
|
mkdirSync(projectDir, { recursive: true })
|
|
@@ -450,11 +508,17 @@ describe('pushing a registered question', () => {
|
|
|
450
508
|
'project = "notifai-cli"\nask_grace_seconds = 0\n',
|
|
451
509
|
)
|
|
452
510
|
writeSessionState('project-title', h.env, { last_prompt_at: AWAY })
|
|
453
|
-
registerQuestion(
|
|
511
|
+
registerQuestion(
|
|
512
|
+
'project-title',
|
|
513
|
+
h.env,
|
|
514
|
+
{ question: 'Ship it?', project: 'notifai-cli' },
|
|
515
|
+
NOW,
|
|
516
|
+
)
|
|
454
517
|
|
|
455
518
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'project-title', cwd: h.deps.cwd }))
|
|
456
519
|
|
|
457
|
-
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('
|
|
520
|
+
expect(h.recorder.submitted[0]?.draft.presentation.title).toBe('Ship it?')
|
|
521
|
+
expect(h.recorder.submitted[0]?.draft.project).toBe('notifai-cli')
|
|
458
522
|
})
|
|
459
523
|
})
|
|
460
524
|
|
|
@@ -1900,7 +1964,6 @@ describe('a question that outlives its session', () => {
|
|
|
1900
1964
|
question: 'Old?',
|
|
1901
1965
|
state: 'expired',
|
|
1902
1966
|
}],
|
|
1903
|
-
undefined,
|
|
1904
1967
|
NOW - 25 * 3600 * 1000,
|
|
1905
1968
|
)
|
|
1906
1969
|
const drained = await drainOrphanRetirements(
|
|
@@ -1962,7 +2025,7 @@ describe('ask registration', () => {
|
|
|
1962
2025
|
const h = harness()
|
|
1963
2026
|
// Inside a hook, a rejection is only a stderr note the agent never reads —
|
|
1964
2027
|
// so it would look registered and then silently never ask.
|
|
1965
|
-
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'],
|
|
2028
|
+
expect(askCommand(h.deps, 'Ship it?', { choice: ['Only one'], sessionId: 'a1' })).toBe(EXIT.usage)
|
|
1966
2029
|
expect(readSessionState('a1', h.env).pending).toBeUndefined()
|
|
1967
2030
|
})
|
|
1968
2031
|
|
|
@@ -1993,7 +2056,7 @@ describe('ask registration', () => {
|
|
|
1993
2056
|
{ text: 'Deploy where?', choices: ['Staging', 'Production'], multi: true },
|
|
1994
2057
|
{ text: 'Anything to watch?' },
|
|
1995
2058
|
],
|
|
1996
|
-
|
|
2059
|
+
body: '## Context\nThe long story.',
|
|
1997
2060
|
}) },
|
|
1998
2061
|
undefined,
|
|
1999
2062
|
)
|
|
@@ -2002,18 +2065,18 @@ describe('ask registration', () => {
|
|
|
2002
2065
|
registerQuestion('form1', h.env, {
|
|
2003
2066
|
question: built.questions[0]!.text,
|
|
2004
2067
|
questions: built.questions,
|
|
2005
|
-
|
|
2068
|
+
body: built.body,
|
|
2006
2069
|
})
|
|
2007
2070
|
const pending = readSessionState('form1', h.env).pending?.[0]
|
|
2008
2071
|
expect(pending?.questions).toHaveLength(2)
|
|
2009
|
-
expect(pending?.
|
|
2072
|
+
expect(pending?.body).toContain('long story')
|
|
2010
2073
|
|
|
2011
2074
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'form1' }))
|
|
2012
2075
|
const draft = h.recorder.submitted[0]?.draft
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
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
|
+
)
|
|
2017
2080
|
expect(draft?.reply?.questions).toEqual([
|
|
2018
2081
|
{
|
|
2019
2082
|
id: 'deploy-where',
|
|
@@ -2108,7 +2171,7 @@ describe('ask registration', () => {
|
|
|
2108
2171
|
|
|
2109
2172
|
it('does not let an explicit session bypass exact active-harness proof', () => {
|
|
2110
2173
|
const h = harness()
|
|
2111
|
-
expect(askCommand(h.deps, 'Ship it?', {
|
|
2174
|
+
expect(askCommand(h.deps, 'Ship it?', { sessionId: 'guessed' })).toBe(EXIT.usage)
|
|
2112
2175
|
expect(readSessionState('guessed', h.env).pending).toBeUndefined()
|
|
2113
2176
|
})
|
|
2114
2177
|
|
|
@@ -2529,21 +2592,46 @@ describe('malformed input', () => {
|
|
|
2529
2592
|
})
|
|
2530
2593
|
|
|
2531
2594
|
describe('telling concurrent agents apart', () => {
|
|
2532
|
-
it('stamps
|
|
2533
|
-
// The hook has always known session_id and never passed it on, so two
|
|
2534
|
-
// agents in separate worktrees produced identical notifications and the
|
|
2535
|
-
// user could answer the wrong one's question.
|
|
2595
|
+
it('stamps structured Source Context on the question it pushes', async () => {
|
|
2536
2596
|
const h = harness([])
|
|
2537
|
-
registerQuestion(
|
|
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
|
+
)
|
|
2538
2610
|
|
|
2539
2611
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2540
2612
|
|
|
2541
|
-
expect(h.recorder.submitted[0]?.draft.
|
|
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
|
+
})
|
|
2542
2618
|
})
|
|
2543
2619
|
|
|
2544
2620
|
it('stamps the retirement too, so it lands on the right agent’s notification', async () => {
|
|
2545
2621
|
const h = harness([reply({ text: 'Yes' })])
|
|
2546
|
-
registerQuestion(
|
|
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
|
+
)
|
|
2547
2635
|
|
|
2548
2636
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2549
2637
|
h.recorder.acknowledged ??= new Set()
|
|
@@ -2555,17 +2643,35 @@ describe('telling concurrent agents apart', () => {
|
|
|
2555
2643
|
)
|
|
2556
2644
|
|
|
2557
2645
|
const retirement = h.recorder.submitted.find((s) => s.draft.event === 'question_retired')
|
|
2558
|
-
expect(retirement?.draft.
|
|
2646
|
+
expect(retirement?.draft.source).toEqual({
|
|
2647
|
+
session_id: 'sess-abc',
|
|
2648
|
+
session_label: sessionLabelFromId('sess-abc'),
|
|
2649
|
+
harness: 'claude-code',
|
|
2650
|
+
})
|
|
2559
2651
|
})
|
|
2560
2652
|
|
|
2561
|
-
it('
|
|
2653
|
+
it('preserves a human session label without displaying the opaque id', async () => {
|
|
2562
2654
|
const h = harness([])
|
|
2563
|
-
|
|
2564
|
-
|
|
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
|
+
)
|
|
2565
2668
|
|
|
2566
2669
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2567
2670
|
|
|
2568
|
-
|
|
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')
|
|
2569
2675
|
})
|
|
2570
2676
|
})
|
|
2571
2677
|
|
|
@@ -2713,7 +2819,6 @@ describe('durable state writes', () => {
|
|
|
2713
2819
|
state: 'expired',
|
|
2714
2820
|
},
|
|
2715
2821
|
],
|
|
2716
|
-
'linked-pointer',
|
|
2717
2822
|
NOW,
|
|
2718
2823
|
),
|
|
2719
2824
|
).toThrow(/symlink/)
|