@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/hooks.ts
CHANGED
|
@@ -14,9 +14,11 @@ import path from 'node:path'
|
|
|
14
14
|
import type {
|
|
15
15
|
LifecycleEndState,
|
|
16
16
|
ListRepliesResponse,
|
|
17
|
+
MediaItemT,
|
|
17
18
|
NotificationDraftT,
|
|
18
19
|
QuestionT,
|
|
19
20
|
ReplyView,
|
|
21
|
+
SourceContextT,
|
|
20
22
|
SubmissionReceipt,
|
|
21
23
|
} from '@raidiant/notifai-protocol'
|
|
22
24
|
import {
|
|
@@ -160,6 +162,8 @@ export interface RetiringQuestion {
|
|
|
160
162
|
device_ids: string[]
|
|
161
163
|
/** Shown if the companion has no history entry to correlate against. */
|
|
162
164
|
question: string
|
|
165
|
+
project?: string
|
|
166
|
+
source?: SourceContextT
|
|
163
167
|
state: LifecycleEndState
|
|
164
168
|
}
|
|
165
169
|
|
|
@@ -175,8 +179,6 @@ export interface RetiringQuestion {
|
|
|
175
179
|
* next holds a client.
|
|
176
180
|
*/
|
|
177
181
|
export interface OrphanRetirement extends RetiringQuestion {
|
|
178
|
-
/** Label of the session that asked, so the retirement sync matches its badge. */
|
|
179
|
-
session?: string
|
|
180
182
|
/** Epoch ms when the entry was orphaned; entries beyond the TTL are dropped. */
|
|
181
183
|
enqueued_at: number
|
|
182
184
|
}
|
|
@@ -208,8 +210,13 @@ export interface PendingQuestion {
|
|
|
208
210
|
* texts, choices, multi flags. What actually rides the push.
|
|
209
211
|
*/
|
|
210
212
|
questions?: QuestionT[]
|
|
211
|
-
/**
|
|
212
|
-
|
|
213
|
+
/** Canonical Markdown body composed when the question was registered. */
|
|
214
|
+
body?: string
|
|
215
|
+
/** Final ordered media collection; uploads complete before registration. */
|
|
216
|
+
media?: MediaItemT[]
|
|
217
|
+
/** Final Project and Source Context frozen at registration. */
|
|
218
|
+
project?: string
|
|
219
|
+
source?: SourceContextT
|
|
213
220
|
/** Set once the question has actually been pushed, so it can be retired. */
|
|
214
221
|
request_id?: string
|
|
215
222
|
collapse_key?: string
|
|
@@ -840,10 +847,10 @@ async function prepareQuestionSubmission(
|
|
|
840
847
|
title: string
|
|
841
848
|
body: string
|
|
842
849
|
questions: QuestionT[]
|
|
843
|
-
|
|
850
|
+
media?: MediaItemT[] | undefined
|
|
851
|
+
project?: string | undefined
|
|
852
|
+
source?: SourceContextT | undefined
|
|
844
853
|
event: string
|
|
845
|
-
/** Which agent is asking; two of them must not look alike. */
|
|
846
|
-
session?: string | undefined
|
|
847
854
|
/** How long the server keeps accepting an answer. */
|
|
848
855
|
windowSeconds: number
|
|
849
856
|
/** Absolute owner deadline persisted before submit. */
|
|
@@ -857,19 +864,23 @@ async function prepareQuestionSubmission(
|
|
|
857
864
|
if (answerable.length === 0) {
|
|
858
865
|
return { error: 'no device can answer a question yet; leaving this to the terminal' }
|
|
859
866
|
}
|
|
860
|
-
const build = buildDraft(
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
867
|
+
const build = buildDraft(
|
|
868
|
+
ctx.config,
|
|
869
|
+
{
|
|
870
|
+
title: options.title,
|
|
871
|
+
body: options.body,
|
|
872
|
+
event: options.event,
|
|
873
|
+
lifecycle: { tier: 'needs_you' },
|
|
874
|
+
...(options.project !== undefined ? { project: options.project } : {}),
|
|
875
|
+
...(options.media !== undefined ? { media: options.media } : {}),
|
|
876
|
+
device: answerable,
|
|
877
|
+
reply: true,
|
|
878
|
+
replyWindow: Math.max(60, options.windowSeconds),
|
|
879
|
+
questions: options.questions,
|
|
880
|
+
collapseKey,
|
|
881
|
+
},
|
|
882
|
+
options.source === undefined ? {} : { source: options.source },
|
|
883
|
+
)
|
|
873
884
|
if (!build.ok) return { error: build.error }
|
|
874
885
|
|
|
875
886
|
return {
|
|
@@ -996,23 +1007,6 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
996
1007
|
.map((device) => device.device_id)
|
|
997
1008
|
}
|
|
998
1009
|
|
|
999
|
-
/**
|
|
1000
|
-
* The session id this push is attributed to — the same one `send` carries.
|
|
1001
|
-
*
|
|
1002
|
-
* The hook has always known `session_id` and never passed it on, so two agents
|
|
1003
|
-
* in separate worktrees produced identical notifications and the user could
|
|
1004
|
-
* answer the wrong one's question. An exported `NOTIFAI_SESSION`
|
|
1005
|
-
* still wins, for coherence rather than vanity: it is THE session id wherever
|
|
1006
|
-
* it is set, so a session that exported one before launching must carry
|
|
1007
|
-
* the same on its own sends and on the questions its hooks push. A name the
|
|
1008
|
-
* user chose also outlives harness restarts, which a per-launch UUID cannot.
|
|
1009
|
-
*/
|
|
1010
|
-
function sessionLabel(ctx: HookContext, envelope: HookEnvelope): string | undefined {
|
|
1011
|
-
const explicit = ctx.env['NOTIFAI_SESSION']
|
|
1012
|
-
if (explicit !== undefined && explicit !== '') return explicit
|
|
1013
|
-
return envelope.session_id
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
1010
|
/**
|
|
1017
1011
|
* Everything retirement needs. Narrower than a HookContext on purpose:
|
|
1018
1012
|
* `notifai ask` supersedes the previous question and it is a plain command with
|
|
@@ -1058,18 +1052,23 @@ async function retire(
|
|
|
1058
1052
|
endState: LifecycleEndState,
|
|
1059
1053
|
retiresRequestId: string,
|
|
1060
1054
|
devices?: string[],
|
|
1061
|
-
|
|
1055
|
+
project?: string | undefined,
|
|
1056
|
+
source?: SourceContextT | undefined,
|
|
1062
1057
|
): Promise<boolean> {
|
|
1063
|
-
const build = buildDraft(
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1058
|
+
const build = buildDraft(
|
|
1059
|
+
ctx.config,
|
|
1060
|
+
{
|
|
1061
|
+
title,
|
|
1062
|
+
body,
|
|
1063
|
+
event: 'question_retired',
|
|
1064
|
+
lifecycle: { tier: 'done', state: endState, retires_request_id: retiresRequestId },
|
|
1065
|
+
...(project !== undefined ? { project } : {}),
|
|
1066
|
+
...(devices !== undefined && devices.length > 0 ? { device: devices } : {}),
|
|
1067
|
+
collapseKey,
|
|
1068
|
+
level: 'passive',
|
|
1069
|
+
},
|
|
1070
|
+
source === undefined ? {} : { source },
|
|
1071
|
+
)
|
|
1073
1072
|
if (!build.ok) return false
|
|
1074
1073
|
try {
|
|
1075
1074
|
await ctx.client.submit(
|
|
@@ -1150,6 +1149,8 @@ function retiringQuestion(
|
|
|
1150
1149
|
collapse_key: pending.collapse_key!,
|
|
1151
1150
|
device_ids: [...pending.device_ids!],
|
|
1152
1151
|
question: pending.question,
|
|
1152
|
+
...(pending.project !== undefined ? { project: pending.project } : {}),
|
|
1153
|
+
...(pending.source !== undefined ? { source: pending.source } : {}),
|
|
1153
1154
|
state,
|
|
1154
1155
|
}
|
|
1155
1156
|
}
|
|
@@ -1172,7 +1173,6 @@ export async function drainRetirements(
|
|
|
1172
1173
|
ctx: RetireDeps,
|
|
1173
1174
|
sessionId: string,
|
|
1174
1175
|
env: NodeJS.ProcessEnv,
|
|
1175
|
-
session?: string | undefined,
|
|
1176
1176
|
): Promise<string[]> {
|
|
1177
1177
|
const queue = readSessionState(sessionId, env).retiring ?? []
|
|
1178
1178
|
if (queue.length === 0) return []
|
|
@@ -1188,7 +1188,8 @@ export async function drainRetirements(
|
|
|
1188
1188
|
entry.state,
|
|
1189
1189
|
entry.request_id,
|
|
1190
1190
|
retirementDeviceIds(entry),
|
|
1191
|
-
|
|
1191
|
+
entry.project,
|
|
1192
|
+
entry.source,
|
|
1192
1193
|
)
|
|
1193
1194
|
if (sent) {
|
|
1194
1195
|
retired.push(entry.request_id)
|
|
@@ -1241,7 +1242,6 @@ function updateOrphanQueue(
|
|
|
1241
1242
|
export function orphanRetirements(
|
|
1242
1243
|
env: NodeJS.ProcessEnv,
|
|
1243
1244
|
entries: RetiringQuestion[],
|
|
1244
|
-
session: string | undefined,
|
|
1245
1245
|
now: number,
|
|
1246
1246
|
): void {
|
|
1247
1247
|
if (entries.length === 0) return
|
|
@@ -1249,11 +1249,7 @@ export function orphanRetirements(
|
|
|
1249
1249
|
const known = new Set(queue.map((entry) => entry.request_id))
|
|
1250
1250
|
const added = entries
|
|
1251
1251
|
.filter((entry) => !known.has(entry.request_id))
|
|
1252
|
-
.map((entry) => ({
|
|
1253
|
-
...entry,
|
|
1254
|
-
...(session !== undefined ? { session } : {}),
|
|
1255
|
-
enqueued_at: now,
|
|
1256
|
-
}))
|
|
1252
|
+
.map((entry) => ({ ...entry, enqueued_at: now }))
|
|
1257
1253
|
return [...queue, ...added].slice(-ORPHAN_QUEUE_CAP)
|
|
1258
1254
|
})
|
|
1259
1255
|
}
|
|
@@ -1291,7 +1287,8 @@ export async function drainOrphanRetirements(
|
|
|
1291
1287
|
entry.state,
|
|
1292
1288
|
entry.request_id,
|
|
1293
1289
|
retirementDeviceIds(entry),
|
|
1294
|
-
entry.
|
|
1290
|
+
entry.project,
|
|
1291
|
+
entry.source,
|
|
1295
1292
|
)
|
|
1296
1293
|
if (sent) {
|
|
1297
1294
|
done.push(entry.request_id)
|
|
@@ -1865,7 +1862,7 @@ export async function handleUserPromptSubmit(
|
|
|
1865
1862
|
notes.push('the late device answer will continue the agent at this turn’s Stop')
|
|
1866
1863
|
return { notes }
|
|
1867
1864
|
}
|
|
1868
|
-
const retired = await drainRetirements(ctx, sessionId, ctx.env
|
|
1865
|
+
const retired = await drainRetirements(ctx, sessionId, ctx.env)
|
|
1869
1866
|
const orphaned = await drainOrphanRetirements(ctx, ctx.env, ctx.now())
|
|
1870
1867
|
const swept = [...retired, ...orphaned]
|
|
1871
1868
|
if (swept.length > 0) {
|
|
@@ -2031,7 +2028,7 @@ export async function runEscalationWaiter(
|
|
|
2031
2028
|
const pending = pendingList(state)
|
|
2032
2029
|
if (pending.length === 0) {
|
|
2033
2030
|
const swept = [
|
|
2034
|
-
...(await drainRetirements(ctx, sessionId, ctx.env
|
|
2031
|
+
...(await drainRetirements(ctx, sessionId, ctx.env)),
|
|
2035
2032
|
...(await drainOrphanRetirements(ctx, ctx.env, ctx.now())),
|
|
2036
2033
|
]
|
|
2037
2034
|
if (swept.length > 0) {
|
|
@@ -2378,21 +2375,19 @@ async function escalate(
|
|
|
2378
2375
|
const questions = pendingQuestions(entry)
|
|
2379
2376
|
let intent = entry.submission
|
|
2380
2377
|
if (intent === undefined) {
|
|
2378
|
+
if (entry.body === undefined) {
|
|
2379
|
+
notes.push('registered question has no canonical body; register it again with this CLI')
|
|
2380
|
+
continue
|
|
2381
|
+
}
|
|
2381
2382
|
const prepared = await prepareQuestionSubmission(ctx, {
|
|
2382
|
-
title
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
: `Question · ${ctx.config.project.value}`,
|
|
2386
|
-
// A set is answered on the expanded card; the banner leads with the
|
|
2387
|
-
// first question and says how much more is waiting behind it.
|
|
2388
|
-
body:
|
|
2389
|
-
questions.length > 1
|
|
2390
|
-
? `${questions[0]!.text} (+${questions.length - 1} more)`
|
|
2391
|
-
: entry.question,
|
|
2383
|
+
// Type and Project have their own fields; the title is only substance.
|
|
2384
|
+
title: questions[0]!.text,
|
|
2385
|
+
body: entry.body,
|
|
2392
2386
|
questions,
|
|
2393
|
-
...(entry.
|
|
2387
|
+
...(entry.media !== undefined ? { media: entry.media } : {}),
|
|
2388
|
+
...(entry.project !== undefined ? { project: entry.project } : {}),
|
|
2389
|
+
...(entry.source !== undefined ? { source: entry.source } : {}),
|
|
2394
2390
|
event: 'agent_question',
|
|
2395
|
-
session: sessionLabel(ctx, envelope),
|
|
2396
2391
|
windowSeconds: replyWindowSeconds,
|
|
2397
2392
|
ownerDeadlineAt,
|
|
2398
2393
|
})
|
|
@@ -2704,8 +2699,7 @@ export function handleSessionEnd(
|
|
|
2704
2699
|
}
|
|
2705
2700
|
}
|
|
2706
2701
|
if (orphans.length > 0) {
|
|
2707
|
-
|
|
2708
|
-
orphanRetirements(env, orphans, label !== undefined && label !== '' ? label : sessionId, now)
|
|
2702
|
+
orphanRetirements(env, orphans, now)
|
|
2709
2703
|
notes.push(
|
|
2710
2704
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
2711
2705
|
)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sessionLabelFromId } from '@raidiant/notifai-protocol'
|
|
3
|
+
import {
|
|
4
|
+
buildSourceContext,
|
|
5
|
+
inferInvocationContext,
|
|
6
|
+
portableBasename,
|
|
7
|
+
projectSlugFrom,
|
|
8
|
+
truncateContext,
|
|
9
|
+
type GitCommand,
|
|
10
|
+
} from './invocation-context.js'
|
|
11
|
+
|
|
12
|
+
function fixtureGit(values: Record<string, string | null>): GitCommand {
|
|
13
|
+
return (_cwd, args) => values[args.join(' ')] ?? null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('projectSlugFrom', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
['My Project', 'my-project'],
|
|
19
|
+
['.Hidden Repo', 'hidden-repo'],
|
|
20
|
+
['API_v2.0', 'api_v2.0'],
|
|
21
|
+
['---', null],
|
|
22
|
+
['项目', null],
|
|
23
|
+
])('turns %s into %s', (name, expected) => {
|
|
24
|
+
expect(projectSlugFrom(name)).toBe(expected)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps the Project identifier inside its 64-character bound', () => {
|
|
28
|
+
expect(projectSlugFrom(`repo-${'x'.repeat(100)}`)).toHaveLength(64)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('portableBasename', () => {
|
|
33
|
+
it.each([
|
|
34
|
+
['/Users/person/code/notifai/', 'notifai'],
|
|
35
|
+
['C:\\Users\\Person\\code\\notifai\\', 'notifai'],
|
|
36
|
+
['\\\\server\\share\\worktrees\\release\\', 'release'],
|
|
37
|
+
])('extracts the basename from %s', (value, expected) => {
|
|
38
|
+
expect(portableBasename(value)).toBe(expected)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('inferInvocationContext', () => {
|
|
43
|
+
it('uses the common-directory parent for a repository from a nested cwd', () => {
|
|
44
|
+
const context = inferInvocationContext(
|
|
45
|
+
'/code/Signal Garden/packages/api',
|
|
46
|
+
fixtureGit({
|
|
47
|
+
'rev-parse --git-common-dir': '../../.git',
|
|
48
|
+
'rev-parse --git-dir': '../../.git',
|
|
49
|
+
'rev-parse --show-toplevel': '/code/Signal Garden',
|
|
50
|
+
'rev-parse --abbrev-ref HEAD': 'feature/alerts',
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
expect(context).toEqual({ project: 'signal-garden', branch: 'feature/alerts' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps one Project across linked worktrees and exposes only the worktree basename', () => {
|
|
57
|
+
const context = inferInvocationContext(
|
|
58
|
+
'/Users/name/worktrees/notifai-release/apps/cli',
|
|
59
|
+
fixtureGit({
|
|
60
|
+
'rev-parse --git-common-dir': '/Users/name/code/notifai/.git',
|
|
61
|
+
'rev-parse --git-dir': '/Users/name/code/notifai/.git/worktrees/notifai-release',
|
|
62
|
+
'rev-parse --show-toplevel': '/Users/name/worktrees/notifai-release/',
|
|
63
|
+
'rev-parse --abbrev-ref HEAD': 'release/content',
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
expect(context).toEqual({
|
|
67
|
+
project: 'notifai',
|
|
68
|
+
branch: 'release/content',
|
|
69
|
+
worktree: 'notifai-release',
|
|
70
|
+
})
|
|
71
|
+
expect(JSON.stringify(context)).not.toContain('/Users/name')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles drive-letter paths and compares them case-insensitively', () => {
|
|
75
|
+
const context = inferInvocationContext(
|
|
76
|
+
'D:\\Worktrees\\Alerts\\src',
|
|
77
|
+
fixtureGit({
|
|
78
|
+
'rev-parse --git-common-dir': 'C:\\Code\\Notifai\\.git\\',
|
|
79
|
+
'rev-parse --git-dir': 'c:\\code\\notifai\\.git\\worktrees\\Alerts',
|
|
80
|
+
'rev-parse --show-toplevel': 'D:\\Worktrees\\Alerts\\',
|
|
81
|
+
'rev-parse --abbrev-ref HEAD': 'feature/windows',
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
expect(context).toEqual({
|
|
85
|
+
project: 'notifai',
|
|
86
|
+
branch: 'feature/windows',
|
|
87
|
+
worktree: 'Alerts',
|
|
88
|
+
})
|
|
89
|
+
expect(JSON.stringify(context)).not.toContain('D:\\Worktrees')
|
|
90
|
+
expect(JSON.stringify(context)).not.toContain('C:\\Code')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('handles a UNC common directory and trailing separators', () => {
|
|
94
|
+
const context = inferInvocationContext(
|
|
95
|
+
'\\\\server\\share\\trees\\topic\\src',
|
|
96
|
+
fixtureGit({
|
|
97
|
+
'rev-parse --git-common-dir': '\\\\server\\share\\Notifai\\.git\\',
|
|
98
|
+
'rev-parse --git-dir': '\\\\server\\share\\Notifai\\.git\\worktrees\\topic\\',
|
|
99
|
+
'rev-parse --show-toplevel': '\\\\server\\share\\trees\\topic\\',
|
|
100
|
+
'rev-parse --abbrev-ref HEAD': 'topic',
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
expect(context).toEqual({ project: 'notifai', branch: 'topic', worktree: 'topic' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('falls back to the cwd basename outside Git', () => {
|
|
107
|
+
expect(inferInvocationContext('/tmp/My Scratch/', fixtureGit({}))).toEqual({
|
|
108
|
+
project: 'my-scratch',
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('omits an unusable Project instead of inventing one', () => {
|
|
113
|
+
expect(inferInvocationContext('/tmp/项目', fixtureGit({}))).toEqual({ project: null })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('omits branch and worktree on detached HEAD in an ordinary checkout', () => {
|
|
117
|
+
expect(
|
|
118
|
+
inferInvocationContext(
|
|
119
|
+
'/code/repo',
|
|
120
|
+
fixtureGit({
|
|
121
|
+
'rev-parse --git-common-dir': '.git',
|
|
122
|
+
'rev-parse --git-dir': '.git',
|
|
123
|
+
'rev-parse --show-toplevel': '/code/repo',
|
|
124
|
+
'rev-parse --abbrev-ref HEAD': 'HEAD',
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
).toEqual({ project: 'repo' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('bounds branch and worktree display strings without leaking their parents', () => {
|
|
131
|
+
const longBranch = `feature/${'界'.repeat(140)}`
|
|
132
|
+
const longWorktree = `tree-${'x'.repeat(80)}`
|
|
133
|
+
const context = inferInvocationContext(
|
|
134
|
+
'/private/work/tree/src',
|
|
135
|
+
fixtureGit({
|
|
136
|
+
'rev-parse --git-common-dir': '/private/main/repo/.git',
|
|
137
|
+
'rev-parse --git-dir': '/private/main/repo/.git/worktrees/tree',
|
|
138
|
+
'rev-parse --show-toplevel': `/private/work/${longWorktree}`,
|
|
139
|
+
'rev-parse --abbrev-ref HEAD': longBranch,
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
expect(Array.from(context.branch ?? '')).toHaveLength(128)
|
|
143
|
+
expect(Array.from(context.worktree ?? '')).toHaveLength(64)
|
|
144
|
+
expect(context.branch).toMatch(/…$/)
|
|
145
|
+
expect(context.worktree).toMatch(/…$/)
|
|
146
|
+
expect(JSON.stringify(context)).not.toContain('/private/')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('truncateContext', () => {
|
|
151
|
+
it('counts Unicode characters rather than UTF-16 code units', () => {
|
|
152
|
+
expect(truncateContext('A😀BC', 3)).toBe('A😀…')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('buildSourceContext', () => {
|
|
157
|
+
const invocation = { project: 'repo', branch: 'main', worktree: 'topic' }
|
|
158
|
+
|
|
159
|
+
it('applies flag, environment, then inferred precedence per session field', () => {
|
|
160
|
+
const built = buildSourceContext({
|
|
161
|
+
env: {
|
|
162
|
+
NOTIFAI_SESSION_ID: 'env-id',
|
|
163
|
+
NOTIFAI_SESSION_LABEL: 'Environment Label',
|
|
164
|
+
},
|
|
165
|
+
invocation,
|
|
166
|
+
sessionId: 'flag-id',
|
|
167
|
+
sessionLabel: 'Flag Label',
|
|
168
|
+
activeHarness: { harness: 'claude-code', sessionId: 'inferred-id' },
|
|
169
|
+
})
|
|
170
|
+
expect(built).toEqual({
|
|
171
|
+
ok: true,
|
|
172
|
+
source: {
|
|
173
|
+
session_id: 'flag-id',
|
|
174
|
+
session_label: 'Flag Label',
|
|
175
|
+
harness: 'claude-code',
|
|
176
|
+
branch: 'main',
|
|
177
|
+
worktree: 'topic',
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('uses environment values ahead of an inferred exact session', () => {
|
|
183
|
+
expect(
|
|
184
|
+
buildSourceContext({
|
|
185
|
+
env: {
|
|
186
|
+
NOTIFAI_SESSION_ID: 'env-id',
|
|
187
|
+
NOTIFAI_SESSION_LABEL: 'Environment Label',
|
|
188
|
+
},
|
|
189
|
+
invocation: { project: 'repo' },
|
|
190
|
+
activeHarness: { harness: 'codex', sessionId: 'inferred-id' },
|
|
191
|
+
}),
|
|
192
|
+
).toEqual({
|
|
193
|
+
ok: true,
|
|
194
|
+
source: {
|
|
195
|
+
session_id: 'env-id',
|
|
196
|
+
session_label: 'Environment Label',
|
|
197
|
+
harness: 'codex',
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('generates a stable non-identifying label for an inferred exact session', () => {
|
|
203
|
+
const sessionId = 'opaque-thread-1234567890'
|
|
204
|
+
expect(
|
|
205
|
+
buildSourceContext({
|
|
206
|
+
env: {},
|
|
207
|
+
invocation: { project: 'repo', branch: 'main' },
|
|
208
|
+
activeHarness: { harness: 'opencode', sessionId },
|
|
209
|
+
}),
|
|
210
|
+
).toEqual({
|
|
211
|
+
ok: true,
|
|
212
|
+
source: {
|
|
213
|
+
session_id: sessionId,
|
|
214
|
+
session_label: sessionLabelFromId(sessionId),
|
|
215
|
+
harness: 'opencode',
|
|
216
|
+
branch: 'main',
|
|
217
|
+
},
|
|
218
|
+
})
|
|
219
|
+
expect(sessionLabelFromId(sessionId)).not.toContain('1234567890')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('does not emit a harness or label when no exact session id is available', () => {
|
|
223
|
+
expect(
|
|
224
|
+
buildSourceContext({
|
|
225
|
+
env: {},
|
|
226
|
+
invocation: { project: 'repo', branch: 'main' },
|
|
227
|
+
activeHarness: { harness: 'cursor' },
|
|
228
|
+
}),
|
|
229
|
+
).toEqual({ ok: true, source: { branch: 'main' } })
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('ignores the deleted NOTIFAI_SESSION variable', () => {
|
|
233
|
+
expect(
|
|
234
|
+
buildSourceContext({
|
|
235
|
+
env: { NOTIFAI_SESSION: 'legacy-id' },
|
|
236
|
+
invocation: { project: 'repo' },
|
|
237
|
+
}),
|
|
238
|
+
).toEqual({ ok: true })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('rejects a display label without an exact session id', () => {
|
|
242
|
+
expect(
|
|
243
|
+
buildSourceContext({
|
|
244
|
+
env: {},
|
|
245
|
+
invocation: { project: 'repo' },
|
|
246
|
+
sessionLabel: 'Unbound Label',
|
|
247
|
+
}),
|
|
248
|
+
).toEqual({
|
|
249
|
+
ok: false,
|
|
250
|
+
error: '--session-label (or NOTIFAI_SESSION_LABEL) needs an exact session id.',
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
})
|