@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.
- package/CHANGELOG.md +48 -0
- package/README.md +7 -4
- package/dist/client.d.ts +2 -1
- package/dist/client.js +7 -0
- package/dist/client.js.map +1 -1
- 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/client.test.ts +46 -0
- package/src/client.ts +13 -0
- package/src/commands.test.ts +333 -37
- 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 +144 -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[] = []
|
|
@@ -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: '
|
|
471
|
+
title: 'Ship it?',
|
|
435
472
|
body: 'Ship it?',
|
|
436
473
|
})
|
|
437
|
-
expect(h.recorder.submitted[0]?.draft.platform).
|
|
438
|
-
|
|
439
|
-
|
|
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 .',
|
|
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('
|
|
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(
|
|
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('
|
|
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'],
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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?', {
|
|
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
|
|
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(
|
|
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.
|
|
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(
|
|
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.
|
|
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('
|
|
2653
|
+
it('preserves a human session label without displaying the opaque id', async () => {
|
|
2561
2654
|
const h = harness([])
|
|
2562
|
-
|
|
2563
|
-
|
|
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
|
-
|
|
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/)
|