@raidiant/notifai 1.0.1 → 2.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 +50 -0
- package/README.md +17 -7
- package/dist/client.d.ts +5 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/commands.d.ts +10 -1
- package/dist/commands.js +151 -54
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +5 -9
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +54 -3
- package/dist/credentials.js +237 -18
- package/dist/credentials.js.map +1 -1
- package/dist/file-lock.js +15 -6
- package/dist/file-lock.js.map +1 -1
- package/dist/hook-adapter.d.ts +6 -2
- package/dist/hook-adapter.js +110 -29
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +183 -12
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +50 -21
- package/dist/install-hooks.js +141 -57
- package/dist/install-hooks.js.map +1 -1
- package/dist/logging.d.ts +1 -1
- package/dist/logging.js +4 -0
- package/dist/logging.js.map +1 -1
- package/dist/main.js +14 -3
- package/dist/main.js.map +1 -1
- package/dist/native-skills.js +5 -10
- package/dist/native-skills.js.map +1 -1
- package/dist/opencode-plugin.d.ts +7 -2
- package/dist/opencode-plugin.js +142 -4
- package/dist/opencode-plugin.js.map +1 -1
- package/dist/platform.d.ts +73 -0
- package/dist/platform.js +143 -0
- package/dist/platform.js.map +1 -0
- package/dist/ui/config-view.js +5 -1
- package/dist/ui/config-view.js.map +1 -1
- package/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +351 -30
- package/src/commands.ts +207 -49
- package/src/config.ts +11 -9
- package/src/credentials.test.ts +291 -0
- package/src/credentials.ts +274 -17
- package/src/file-lock.ts +34 -9
- package/src/hook-adapter.test.ts +136 -0
- package/src/hook-adapter.ts +120 -28
- package/src/hooks.test.ts +175 -1
- package/src/hooks.ts +241 -12
- package/src/install-hooks.test.ts +174 -33
- package/src/install-hooks.ts +186 -54
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/native-skills.ts +5 -10
- package/src/opencode-plugin.ts +148 -3
- package/src/platform.test.ts +152 -0
- package/src/platform.ts +173 -0
- package/src/skill.test.ts +32 -1
- package/src/ui/config-view.ts +5 -5
package/src/hooks.test.ts
CHANGED
|
@@ -89,6 +89,9 @@ interface Recorder {
|
|
|
89
89
|
beforeReplies?: () => Promise<void>
|
|
90
90
|
/** Per-request answer stream for multi-question ownership tests. */
|
|
91
91
|
repliesFor?: (requestId: string) => ReplyView[]
|
|
92
|
+
acknowledgementRequiredFor?: (requestId: string) => boolean
|
|
93
|
+
acknowledged?: Set<string>
|
|
94
|
+
acknowledgementChecks?: string[]
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
@@ -115,6 +118,7 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
115
118
|
platform: 'ios' as const,
|
|
116
119
|
permission_status: 'authorized',
|
|
117
120
|
registration_healthy: true,
|
|
121
|
+
reply_protocol_version: 2,
|
|
118
122
|
last_seen_at: null,
|
|
119
123
|
},
|
|
120
124
|
{
|
|
@@ -123,12 +127,36 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
123
127
|
platform: 'macos' as const,
|
|
124
128
|
permission_status: 'authorized',
|
|
125
129
|
registration_healthy: true,
|
|
130
|
+
reply_protocol_version: 2,
|
|
126
131
|
last_seen_at: null,
|
|
127
132
|
},
|
|
128
133
|
],
|
|
129
134
|
}),
|
|
130
135
|
capabilities: notUsed,
|
|
131
136
|
evidence: notUsed,
|
|
137
|
+
putAgentAcknowledgement: async (requestId, body) => {
|
|
138
|
+
recorder.acknowledged ??= new Set()
|
|
139
|
+
recorder.acknowledged.add(requestId)
|
|
140
|
+
return {
|
|
141
|
+
status: 'recorded',
|
|
142
|
+
agent_acknowledgement: {
|
|
143
|
+
text: body.text,
|
|
144
|
+
created_at: new Date(NOW).toISOString(),
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
agentAcknowledgement: async (requestId) => {
|
|
149
|
+
recorder.acknowledgementChecks ??= []
|
|
150
|
+
recorder.acknowledgementChecks.push(requestId)
|
|
151
|
+
const required = recorder.acknowledgementRequiredFor?.(requestId) ?? true
|
|
152
|
+
return {
|
|
153
|
+
request_id: requestId,
|
|
154
|
+
agent_acknowledgement_required: required,
|
|
155
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
156
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
157
|
+
: null,
|
|
158
|
+
}
|
|
159
|
+
},
|
|
132
160
|
createMediaUpload: notUsed,
|
|
133
161
|
uploadMedia: notUsed,
|
|
134
162
|
health: async () => true,
|
|
@@ -143,6 +171,8 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
143
171
|
return {
|
|
144
172
|
request_id: requestId,
|
|
145
173
|
reply_expires_at: new Date(NOW + 480_000).toISOString(),
|
|
174
|
+
agent_acknowledgement_required:
|
|
175
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
146
176
|
replayed: false,
|
|
147
177
|
overall: 'provider_accepted_all',
|
|
148
178
|
deliveries: [],
|
|
@@ -154,6 +184,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
154
184
|
return ({
|
|
155
185
|
request_id: requestId,
|
|
156
186
|
reply_expires_at: null,
|
|
187
|
+
agent_acknowledgement_required:
|
|
188
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
189
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
190
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
191
|
+
: null,
|
|
157
192
|
replies: recordedReplies(requestId),
|
|
158
193
|
}) satisfies ListRepliesResponse
|
|
159
194
|
},
|
|
@@ -162,6 +197,11 @@ function fakeClient(recorder: Recorder, replies: ReplyView[]): ApiClient {
|
|
|
162
197
|
return {
|
|
163
198
|
request_id: requestId,
|
|
164
199
|
reply_expires_at: new Date(NOW).toISOString(),
|
|
200
|
+
agent_acknowledgement_required:
|
|
201
|
+
recorder.acknowledgementRequiredFor?.(requestId) ?? true,
|
|
202
|
+
agent_acknowledgement: recorder.acknowledged?.has(requestId)
|
|
203
|
+
? { text: 'I will continue.', created_at: new Date(NOW).toISOString() }
|
|
204
|
+
: null,
|
|
165
205
|
replies: recordedReplies(requestId),
|
|
166
206
|
} satisfies ListRepliesResponse
|
|
167
207
|
},
|
|
@@ -716,6 +756,30 @@ describe('terminal-first grace window', () => {
|
|
|
716
756
|
).toBeGreaterThanOrEqual(60)
|
|
717
757
|
})
|
|
718
758
|
|
|
759
|
+
it('never pushes a question to a Companion without the current reply protocol', async () => {
|
|
760
|
+
const h = harness([])
|
|
761
|
+
const factory = h.deps.clientFactory
|
|
762
|
+
h.deps.clientFactory = () => {
|
|
763
|
+
const client = factory!()
|
|
764
|
+
return {
|
|
765
|
+
...client,
|
|
766
|
+
listDevices: async () => ({
|
|
767
|
+
devices: (await client.listDevices()).devices.map((device) => ({
|
|
768
|
+
...device,
|
|
769
|
+
reply_protocol_version: null,
|
|
770
|
+
})),
|
|
771
|
+
}),
|
|
772
|
+
} as ApiClient
|
|
773
|
+
}
|
|
774
|
+
writeSessionState('old-companion', h.env, { last_prompt_at: AWAY })
|
|
775
|
+
registerQuestion('old-companion', h.env, { question: 'Ship it?' }, NOW)
|
|
776
|
+
|
|
777
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'old-companion' }))
|
|
778
|
+
|
|
779
|
+
expect(h.recorder.submitted).toHaveLength(0)
|
|
780
|
+
expect(h.io.errLines.join('\n')).toContain('no device can answer a question yet')
|
|
781
|
+
})
|
|
782
|
+
|
|
719
783
|
it('charges transport overrun and close time to the one waiter ceiling', async () => {
|
|
720
784
|
const h = harness([])
|
|
721
785
|
const factory = h.deps.clientFactory
|
|
@@ -816,6 +880,8 @@ describe('nagging guards', () => {
|
|
|
816
880
|
registerQuestion('n3', h.env, { question: 'First question?' }, NOW)
|
|
817
881
|
|
|
818
882
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'n3' }))
|
|
883
|
+
h.recorder.acknowledged ??= new Set()
|
|
884
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
819
885
|
await h.deps.sleep?.(1)
|
|
820
886
|
registerQuestion('n3', h.env, { question: 'Follow-up question?' }, (h.deps.now?.() ?? NOW))
|
|
821
887
|
h.io.outLines = []
|
|
@@ -1189,6 +1255,8 @@ describe('the waiter owning one question to the end', () => {
|
|
|
1189
1255
|
])
|
|
1190
1256
|
|
|
1191
1257
|
answers.set('req_hook_2', [reply({ text: 'Second answer' })])
|
|
1258
|
+
h.recorder.acknowledged ??= new Set()
|
|
1259
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
1192
1260
|
h.io.outLines = []
|
|
1193
1261
|
await hookRunCommand(
|
|
1194
1262
|
h.deps,
|
|
@@ -1595,6 +1663,8 @@ describe('several questions in flight', () => {
|
|
|
1595
1663
|
expect(JSON.parse(h.io.outLines.at(-1)!).reason).toContain('Ship it')
|
|
1596
1664
|
expect(readSessionState('answer-handoff', h.env).accepted).toBeDefined()
|
|
1597
1665
|
|
|
1666
|
+
h.recorder.acknowledged ??= new Set()
|
|
1667
|
+
h.recorder.acknowledged.add('req_existing')
|
|
1598
1668
|
h.io.outLines = []
|
|
1599
1669
|
await hookRunCommand(
|
|
1600
1670
|
h.deps,
|
|
@@ -1612,6 +1682,104 @@ describe('several questions in flight', () => {
|
|
|
1612
1682
|
).toBe('answered')
|
|
1613
1683
|
})
|
|
1614
1684
|
|
|
1685
|
+
it('blocks Stop with the exact command until a required Agent Acknowledgement is recorded', async () => {
|
|
1686
|
+
const h = harness([reply({ text: 'Ship it' })])
|
|
1687
|
+
writeSessionState('ack-required', h.env, {
|
|
1688
|
+
pending: [
|
|
1689
|
+
{
|
|
1690
|
+
question: 'Ship it?',
|
|
1691
|
+
request_id: 'req_ack_required',
|
|
1692
|
+
collapse_key: 'collapse-ack-required',
|
|
1693
|
+
device_ids: ['dev_iphone'],
|
|
1694
|
+
reply_deadline_at: NOW + 60_000,
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
})
|
|
1698
|
+
|
|
1699
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-required' }))
|
|
1700
|
+
expect(readSessionState('ack-required', h.env).acknowledgement_due).toEqual([
|
|
1701
|
+
{ request_id: 'req_ack_required', recorded_at: NOW },
|
|
1702
|
+
])
|
|
1703
|
+
|
|
1704
|
+
h.io.outLines = []
|
|
1705
|
+
await hookRunCommand(
|
|
1706
|
+
h.deps,
|
|
1707
|
+
'stop',
|
|
1708
|
+
stdin({ session_id: 'ack-required', stop_hook_active: true }),
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
const blocked = JSON.parse(h.io.outLines[0] ?? '{}') as { decision?: string; reason?: string }
|
|
1712
|
+
expect(blocked.decision).toBe('block')
|
|
1713
|
+
expect(blocked.reason).toContain('req_ack_required')
|
|
1714
|
+
expect(blocked.reason).toContain(
|
|
1715
|
+
'notifai acknowledge req_ack_required --text <text>',
|
|
1716
|
+
)
|
|
1717
|
+
expect(blocked.reason).toContain('concrete work')
|
|
1718
|
+
expect(readSessionState('ack-required', h.env).accepted).toBeUndefined()
|
|
1719
|
+
})
|
|
1720
|
+
|
|
1721
|
+
it('reconciles a server-recorded Agent Acknowledgement after a local clearing crash', async () => {
|
|
1722
|
+
const h = harness([])
|
|
1723
|
+
writeSessionState('ack-heal', h.env, {
|
|
1724
|
+
acknowledgement_due: [{ request_id: 'req_ack_heal', recorded_at: NOW }],
|
|
1725
|
+
})
|
|
1726
|
+
h.recorder.acknowledged = new Set(['req_ack_heal'])
|
|
1727
|
+
|
|
1728
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-heal' }))
|
|
1729
|
+
|
|
1730
|
+
expect(h.recorder.acknowledgementChecks).toEqual(['req_ack_heal'])
|
|
1731
|
+
expect(readSessionState('ack-heal', h.env).acknowledgement_due).toBeUndefined()
|
|
1732
|
+
expect(h.io.outLines).toEqual([])
|
|
1733
|
+
})
|
|
1734
|
+
|
|
1735
|
+
it('records only the requests whose immutable snapshot requires acknowledgement', async () => {
|
|
1736
|
+
const h = harness([])
|
|
1737
|
+
repliesByRequest(
|
|
1738
|
+
h,
|
|
1739
|
+
new Map([
|
|
1740
|
+
['req_hook_1', [reply({ text: 'Ship it' })]],
|
|
1741
|
+
['req_hook_2', [reply({ text: 'Staging' })]],
|
|
1742
|
+
]),
|
|
1743
|
+
)
|
|
1744
|
+
h.recorder.acknowledgementRequiredFor = (requestId) =>
|
|
1745
|
+
h.recorder.aliases.get(requestId) === 1
|
|
1746
|
+
writeSessionState('ack-mixed', h.env, { last_prompt_at: AWAY })
|
|
1747
|
+
registerQuestion('ack-mixed', h.env, { question: 'Ship it?' }, NOW)
|
|
1748
|
+
registerQuestion('ack-mixed', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
1749
|
+
|
|
1750
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-mixed' }))
|
|
1751
|
+
|
|
1752
|
+
const state = readSessionState('ack-mixed', h.env)
|
|
1753
|
+
expect(state.acknowledgement_due?.map((entry) => entry.request_id)).toEqual([
|
|
1754
|
+
h.recorder.receipts[0],
|
|
1755
|
+
])
|
|
1756
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
1757
|
+
expect(output.reason).toContain(h.recorder.receipts[0]!)
|
|
1758
|
+
expect(output.reason).not.toContain(h.recorder.receipts[1]!)
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
it('lists every required request and command in a multi-answer continuation', async () => {
|
|
1762
|
+
const h = harness([])
|
|
1763
|
+
repliesByRequest(
|
|
1764
|
+
h,
|
|
1765
|
+
new Map([
|
|
1766
|
+
['req_hook_1', [reply({ text: 'Ship it' })]],
|
|
1767
|
+
['req_hook_2', [reply({ text: 'Staging' })]],
|
|
1768
|
+
]),
|
|
1769
|
+
)
|
|
1770
|
+
writeSessionState('ack-multiple', h.env, { last_prompt_at: AWAY })
|
|
1771
|
+
registerQuestion('ack-multiple', h.env, { question: 'Ship it?' }, NOW)
|
|
1772
|
+
registerQuestion('ack-multiple', h.env, { question: 'Deploy where?' }, NOW + 1)
|
|
1773
|
+
|
|
1774
|
+
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'ack-multiple' }))
|
|
1775
|
+
|
|
1776
|
+
const output = JSON.parse(h.io.outLines[0] ?? '{}') as { reason?: string }
|
|
1777
|
+
for (const requestId of h.recorder.receipts) {
|
|
1778
|
+
expect(output.reason).toContain(requestId)
|
|
1779
|
+
expect(output.reason).toContain(`notifai acknowledge ${requestId} --text <text>`)
|
|
1780
|
+
}
|
|
1781
|
+
})
|
|
1782
|
+
|
|
1615
1783
|
it('resumes with a partial answer and keeps the rest registered', async () => {
|
|
1616
1784
|
const h = harness([])
|
|
1617
1785
|
const byRequest = new Map([['req_hook_1', [reply({ text: 'Ship it' })]]])
|
|
@@ -1632,6 +1800,8 @@ describe('several questions in flight', () => {
|
|
|
1632
1800
|
// The remaining question's answer arrives before the next turn ends; the
|
|
1633
1801
|
// late-answer path hands it over without asking anything twice.
|
|
1634
1802
|
byRequest.set('req_hook_2', [reply({ text: 'Staging' })])
|
|
1803
|
+
h.recorder.acknowledged ??= new Set()
|
|
1804
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
1635
1805
|
h.io.outLines = []
|
|
1636
1806
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'multi3', stop_hook_active: true }))
|
|
1637
1807
|
|
|
@@ -2153,6 +2323,8 @@ describe('user-prompt-submit hook', () => {
|
|
|
2153
2323
|
registerQuestion('s16', h.env, { question: 'Ship it?' })
|
|
2154
2324
|
|
|
2155
2325
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 's16' }))
|
|
2326
|
+
h.recorder.acknowledged ??= new Set()
|
|
2327
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
2156
2328
|
await hookRunCommand(
|
|
2157
2329
|
h.deps,
|
|
2158
2330
|
'stop',
|
|
@@ -2247,7 +2419,7 @@ describe('session-end hook', () => {
|
|
|
2247
2419
|
await waitUntil(
|
|
2248
2420
|
() =>
|
|
2249
2421
|
readdirSync(logLock).filter((name) => name.startsWith('ticket-')).length >= 2,
|
|
2250
|
-
|
|
2422
|
+
5_000,
|
|
2251
2423
|
'the real Codex hook did not reach contended diagnostics before its deadline',
|
|
2252
2424
|
)
|
|
2253
2425
|
|
|
@@ -2373,6 +2545,8 @@ describe('telling concurrent agents apart', () => {
|
|
|
2373
2545
|
registerQuestion('sess-abc', h.env, { question: 'Ship it?' }, NOW)
|
|
2374
2546
|
|
|
2375
2547
|
await hookRunCommand(h.deps, 'stop', stdin({ session_id: 'sess-abc' }))
|
|
2548
|
+
h.recorder.acknowledged ??= new Set()
|
|
2549
|
+
h.recorder.acknowledged.add(h.recorder.receipts[0]!)
|
|
2376
2550
|
await hookRunCommand(
|
|
2377
2551
|
h.deps,
|
|
2378
2552
|
'stop',
|
package/src/hooks.ts
CHANGED
|
@@ -118,6 +118,18 @@ export interface SessionState {
|
|
|
118
118
|
* reaches the harness replays it instead of erasing it.
|
|
119
119
|
*/
|
|
120
120
|
accepted?: AcceptedAnswerDelivery
|
|
121
|
+
/**
|
|
122
|
+
* Required Agent Acknowledgements that the resumed agent still owes. This is
|
|
123
|
+
* separate from answer delivery: the answer journal may settle as soon as a
|
|
124
|
+
* harness accepts the continuation, while this obligation must survive until
|
|
125
|
+
* the service confirms the agent-authored follow-up exists.
|
|
126
|
+
*/
|
|
127
|
+
acknowledgement_due?: AcknowledgementDue[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface AcknowledgementDue {
|
|
131
|
+
request_id: string
|
|
132
|
+
recorded_at: number
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
interface AcceptedAnswerDelivery {
|
|
@@ -784,6 +796,7 @@ export type GateReason =
|
|
|
784
796
|
| 'continuation-repeat'
|
|
785
797
|
| 'continuation-limit'
|
|
786
798
|
| 'delivery-limit'
|
|
799
|
+
| 'acknowledgement-required'
|
|
787
800
|
| 'notifications-off'
|
|
788
801
|
| 'claimed-elsewhere'
|
|
789
802
|
| 'no-devices'
|
|
@@ -976,7 +989,8 @@ async function answerableDevices(ctx: HookContext): Promise<string[]> {
|
|
|
976
989
|
.filter(
|
|
977
990
|
(device) =>
|
|
978
991
|
(device.platform === 'ios' || device.platform === 'macos') &&
|
|
979
|
-
device.registration_healthy
|
|
992
|
+
device.registration_healthy &&
|
|
993
|
+
device.reply_protocol_version === 2,
|
|
980
994
|
)
|
|
981
995
|
.filter((device) => configured === null || configured.includes(device.device_id))
|
|
982
996
|
.map((device) => device.device_id)
|
|
@@ -1339,6 +1353,8 @@ interface AnsweredPending {
|
|
|
1339
1353
|
pending: PendingQuestion
|
|
1340
1354
|
reply: ReplyView
|
|
1341
1355
|
replies: ReplyView[]
|
|
1356
|
+
/** Immutable server snapshot for this request, known after a replies/close response. */
|
|
1357
|
+
agent_acknowledgement_required?: boolean | undefined
|
|
1342
1358
|
}
|
|
1343
1359
|
|
|
1344
1360
|
interface FinalizedPending {
|
|
@@ -1363,7 +1379,15 @@ async function finalizePendings(
|
|
|
1363
1379
|
function finalizedAnswer(finalized: FinalizedPending): AnsweredPending | null {
|
|
1364
1380
|
const replies = finalized.response?.replies ?? []
|
|
1365
1381
|
const reply = replies.at(-1)
|
|
1366
|
-
return reply === undefined
|
|
1382
|
+
return reply === undefined
|
|
1383
|
+
? null
|
|
1384
|
+
: {
|
|
1385
|
+
pending: finalized.pending,
|
|
1386
|
+
reply,
|
|
1387
|
+
replies,
|
|
1388
|
+
agent_acknowledgement_required:
|
|
1389
|
+
finalized.response?.agent_acknowledgement_required,
|
|
1390
|
+
}
|
|
1367
1391
|
}
|
|
1368
1392
|
|
|
1369
1393
|
/** Persist and surface one answer without letting the two representations drift. */
|
|
@@ -1475,13 +1499,45 @@ function answerContext(answered: AnsweredPending): string {
|
|
|
1475
1499
|
* tied to the question that asked it, with a truthful note about anything
|
|
1476
1500
|
* still waiting.
|
|
1477
1501
|
*/
|
|
1502
|
+
function acknowledgementCommand(requestId: string): string {
|
|
1503
|
+
return `notifai acknowledge ${requestId} --text <text>`
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function acknowledgementContext(answered: AnsweredPending[]): string {
|
|
1507
|
+
const due = answered.filter(
|
|
1508
|
+
(entry) =>
|
|
1509
|
+
entry.agent_acknowledgement_required === true && entry.pending.request_id !== undefined,
|
|
1510
|
+
)
|
|
1511
|
+
if (due.length === 0) {
|
|
1512
|
+
return ' Agent Acknowledgement is not required for the answered request(s).'
|
|
1513
|
+
}
|
|
1514
|
+
if (due.length === 1) {
|
|
1515
|
+
const requestId = due[0]!.pending.request_id!
|
|
1516
|
+
return (
|
|
1517
|
+
` Agent Acknowledgement is required for request ${requestId}. Immediately, before doing the resumed work or ending this turn, run ` +
|
|
1518
|
+
`\`${acknowledgementCommand(requestId)}\` with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient.`
|
|
1519
|
+
)
|
|
1520
|
+
}
|
|
1521
|
+
const commands = due
|
|
1522
|
+
.map((entry) => {
|
|
1523
|
+
const requestId = entry.pending.request_id!
|
|
1524
|
+
return `- ${requestId}: \`${acknowledgementCommand(requestId)}\``
|
|
1525
|
+
})
|
|
1526
|
+
.join('\n')
|
|
1527
|
+
return (
|
|
1528
|
+
` Agent Acknowledgement is required for ${due.length} requests. Immediately, before doing the resumed work or ending this turn, run every command below with non-empty text saying what concrete work you will do because of that reply; a bare acknowledgement is insufficient:\n` +
|
|
1529
|
+
commands
|
|
1530
|
+
)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1478
1533
|
function answersContext(answered: AnsweredPending[], remaining: number): string {
|
|
1479
1534
|
const tail =
|
|
1480
1535
|
remaining > 0
|
|
1481
1536
|
? ` (${remaining} more registered question${remaining === 1 ? ' is' : 's are'} still waiting for an answer.)`
|
|
1482
1537
|
: ''
|
|
1538
|
+
const guidance = acknowledgementContext(answered)
|
|
1483
1539
|
if (answered.length === 1) {
|
|
1484
|
-
return answerContext(answered[0]!) + tail
|
|
1540
|
+
return answerContext(answered[0]!) + tail + guidance
|
|
1485
1541
|
}
|
|
1486
1542
|
const lines = answered.map(({ pending, replies }) => {
|
|
1487
1543
|
const latest = replies.at(-1)!
|
|
@@ -1493,7 +1549,7 @@ function answersContext(answered: AnsweredPending[], remaining: number): string
|
|
|
1493
1549
|
const identity = ids.length === 0 ? '' : `question_id${ids.length === 1 ? '' : 's'} ${ids.join(', ')}: `
|
|
1494
1550
|
return `- ${identity}${JSON.stringify(pending.question)} → ${answer}`
|
|
1495
1551
|
})
|
|
1496
|
-
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}`
|
|
1552
|
+
return `Notifai — the user answered ${answered.length} questions:\n${lines.join('\n')}${tail}${guidance}`
|
|
1497
1553
|
}
|
|
1498
1554
|
|
|
1499
1555
|
function stopAnswerOutput(context: string): string {
|
|
@@ -1524,12 +1580,25 @@ function stageAcceptedAnswers(
|
|
|
1524
1580
|
const pendingRemaining = pendingList(current).filter(
|
|
1525
1581
|
(entry) => !answered.some(({ pending }) => isSamePending(entry, pending)),
|
|
1526
1582
|
)
|
|
1583
|
+
const acknowledgementDue = [...(current.acknowledgement_due ?? [])]
|
|
1584
|
+
for (const answer of answered) {
|
|
1585
|
+
const requestId = answer.pending.request_id
|
|
1586
|
+
if (
|
|
1587
|
+
answer.agent_acknowledgement_required === true &&
|
|
1588
|
+
requestId !== undefined &&
|
|
1589
|
+
!acknowledgementDue.some((entry) => entry.request_id === requestId)
|
|
1590
|
+
) {
|
|
1591
|
+
acknowledgementDue.push({ request_id: requestId, recorded_at: accepted.recorded_at })
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1527
1594
|
const next: SessionState = {
|
|
1528
1595
|
...current,
|
|
1529
1596
|
accepted,
|
|
1530
1597
|
}
|
|
1531
1598
|
if (pendingRemaining.length > 0) next.pending = pendingRemaining
|
|
1532
1599
|
else delete next.pending
|
|
1600
|
+
if (acknowledgementDue.length > 0) next.acknowledgement_due = acknowledgementDue
|
|
1601
|
+
else delete next.acknowledgement_due
|
|
1533
1602
|
return next
|
|
1534
1603
|
})
|
|
1535
1604
|
return accepted
|
|
@@ -1546,6 +1615,68 @@ function amendAcceptedAnswers(
|
|
|
1546
1615
|
)
|
|
1547
1616
|
}
|
|
1548
1617
|
|
|
1618
|
+
export function clearAcknowledgementObligation(
|
|
1619
|
+
sessionId: string,
|
|
1620
|
+
env: NodeJS.ProcessEnv,
|
|
1621
|
+
requestId: string,
|
|
1622
|
+
): boolean {
|
|
1623
|
+
let cleared = false
|
|
1624
|
+
updateSessionState(sessionId, env, (current) => {
|
|
1625
|
+
const due = current.acknowledgement_due ?? []
|
|
1626
|
+
const remaining = due.filter((entry) => entry.request_id !== requestId)
|
|
1627
|
+
cleared = remaining.length !== due.length
|
|
1628
|
+
if (!cleared) return current
|
|
1629
|
+
const next = { ...current }
|
|
1630
|
+
if (remaining.length > 0) next.acknowledgement_due = remaining
|
|
1631
|
+
else delete next.acknowledgement_due
|
|
1632
|
+
return next
|
|
1633
|
+
})
|
|
1634
|
+
return cleared
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function acknowledgementBlockContext(due: readonly AcknowledgementDue[]): string {
|
|
1638
|
+
const commands = due
|
|
1639
|
+
.map((entry) => `- ${entry.request_id}: \`${acknowledgementCommand(entry.request_id)}\``)
|
|
1640
|
+
.join('\n')
|
|
1641
|
+
return (
|
|
1642
|
+
`Notifai — required Agent Acknowledgement${due.length === 1 ? '' : 's'} still missing for request${due.length === 1 ? '' : 's'} ${due.map((entry) => entry.request_id).join(', ')}. ` +
|
|
1643
|
+
`Before doing more resumed work or ending this turn, run ${due.length === 1 ? 'this command' : 'every command'} with non-empty text saying what concrete work you will do because of the reply; a bare acknowledgement is insufficient:\n${commands}`
|
|
1644
|
+
)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
async function reconcileAcknowledgementObligations(
|
|
1648
|
+
ctx: HookContext,
|
|
1649
|
+
sessionId: string,
|
|
1650
|
+
due: readonly AcknowledgementDue[],
|
|
1651
|
+
): Promise<AcknowledgementDue[]> {
|
|
1652
|
+
const unresolved: AcknowledgementDue[] = []
|
|
1653
|
+
for (const obligation of due) {
|
|
1654
|
+
try {
|
|
1655
|
+
const snapshot = await ctx.client.agentAcknowledgement(obligation.request_id, {
|
|
1656
|
+
waitSeconds: 0,
|
|
1657
|
+
})
|
|
1658
|
+
if (
|
|
1659
|
+
snapshot.agent_acknowledgement_required === false ||
|
|
1660
|
+
snapshot.agent_acknowledgement !== null
|
|
1661
|
+
) {
|
|
1662
|
+
clearAcknowledgementObligation(sessionId, ctx.env, obligation.request_id)
|
|
1663
|
+
} else {
|
|
1664
|
+
unresolved.push(obligation)
|
|
1665
|
+
}
|
|
1666
|
+
} catch (err) {
|
|
1667
|
+
unresolved.push(obligation)
|
|
1668
|
+
ctx.log?.error('hook.gate', {
|
|
1669
|
+
verdict: 'held',
|
|
1670
|
+
reason: 'acknowledgement-required',
|
|
1671
|
+
stage: 'reconcile-failed',
|
|
1672
|
+
request_id: obligation.request_id,
|
|
1673
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1674
|
+
})
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return unresolved
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1549
1680
|
/**
|
|
1550
1681
|
* Retire the answered questions and close the journal.
|
|
1551
1682
|
*
|
|
@@ -1665,7 +1796,14 @@ export async function handleUserPromptSubmit(
|
|
|
1665
1796
|
`could not confirm the close fence for ${entry.pending.request_id}; preserving the durable answer already observed`,
|
|
1666
1797
|
)
|
|
1667
1798
|
}
|
|
1668
|
-
return {
|
|
1799
|
+
return {
|
|
1800
|
+
...entry,
|
|
1801
|
+
replies,
|
|
1802
|
+
reply: replies.at(-1)!,
|
|
1803
|
+
agent_acknowledgement_required:
|
|
1804
|
+
response?.agent_acknowledgement_required ??
|
|
1805
|
+
entry.agent_acknowledgement_required,
|
|
1806
|
+
}
|
|
1669
1807
|
})
|
|
1670
1808
|
if (answered.length > 0) {
|
|
1671
1809
|
stageAcceptedAnswers(
|
|
@@ -1799,6 +1937,27 @@ export async function runEscalationWaiter(
|
|
|
1799
1937
|
let state = readSessionState(sessionId, ctx.env)
|
|
1800
1938
|
if (state.accepted !== undefined) {
|
|
1801
1939
|
const accepted = state.accepted
|
|
1940
|
+
if (envelope.stop_hook_active === true && pendingList(state).length > 0) {
|
|
1941
|
+
const due = await reconcileAcknowledgementObligations(
|
|
1942
|
+
ctx,
|
|
1943
|
+
sessionId,
|
|
1944
|
+
state.acknowledgement_due ?? [],
|
|
1945
|
+
)
|
|
1946
|
+
if (due.length > 0) {
|
|
1947
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1948
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1949
|
+
})
|
|
1950
|
+
return {
|
|
1951
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1952
|
+
notes,
|
|
1953
|
+
log: {
|
|
1954
|
+
stage: 'acknowledgement-required',
|
|
1955
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1956
|
+
},
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1960
|
+
}
|
|
1802
1961
|
// Acknowledgement is per route, because routes end in different places.
|
|
1803
1962
|
//
|
|
1804
1963
|
// A blocking continuation ends at this process's stdout, which the
|
|
@@ -1819,6 +1978,54 @@ export async function runEscalationWaiter(
|
|
|
1819
1978
|
settleAcceptedAnswers(ctx, sessionId, accepted)
|
|
1820
1979
|
settledAnswerThisPass = true
|
|
1821
1980
|
state = readSessionState(sessionId, ctx.env)
|
|
1981
|
+
const currentAcceptedIds = new Set(
|
|
1982
|
+
accepted.answers.flatMap(({ pending }) =>
|
|
1983
|
+
pending.request_id === undefined ? [] : [pending.request_id],
|
|
1984
|
+
),
|
|
1985
|
+
)
|
|
1986
|
+
const due = await reconcileAcknowledgementObligations(
|
|
1987
|
+
ctx,
|
|
1988
|
+
sessionId,
|
|
1989
|
+
(state.acknowledgement_due ?? []).filter((entry) =>
|
|
1990
|
+
currentAcceptedIds.has(entry.request_id),
|
|
1991
|
+
),
|
|
1992
|
+
)
|
|
1993
|
+
if (due.length > 0) {
|
|
1994
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
1995
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
1996
|
+
})
|
|
1997
|
+
return {
|
|
1998
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
1999
|
+
notes,
|
|
2000
|
+
log: {
|
|
2001
|
+
stage: 'acknowledgement-required',
|
|
2002
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2003
|
+
},
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
state = readSessionState(sessionId, ctx.env)
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
if (state.accepted === undefined && (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2010
|
+
const due = await reconcileAcknowledgementObligations(
|
|
2011
|
+
ctx,
|
|
2012
|
+
sessionId,
|
|
2013
|
+
state.acknowledgement_due!,
|
|
2014
|
+
)
|
|
2015
|
+
if (due.length > 0) {
|
|
2016
|
+
gate(ctx, 'held', 'acknowledgement-required', {
|
|
2017
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2018
|
+
})
|
|
2019
|
+
return {
|
|
2020
|
+
stdout: stopAnswerOutput(acknowledgementBlockContext(due)),
|
|
2021
|
+
notes,
|
|
2022
|
+
log: {
|
|
2023
|
+
stage: 'acknowledgement-required',
|
|
2024
|
+
request_ids: due.map((entry) => entry.request_id),
|
|
2025
|
+
},
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
state = readSessionState(sessionId, ctx.env)
|
|
1822
2029
|
}
|
|
1823
2030
|
|
|
1824
2031
|
const pending = pendingList(state)
|
|
@@ -2013,7 +2220,14 @@ async function handleClaimedStop(
|
|
|
2013
2220
|
`could not confirm the close fence for ${entry.pending.request_id}; delivering the durable answer already observed`,
|
|
2014
2221
|
)
|
|
2015
2222
|
}
|
|
2016
|
-
return {
|
|
2223
|
+
return {
|
|
2224
|
+
...entry,
|
|
2225
|
+
replies,
|
|
2226
|
+
reply: replies.at(-1)!,
|
|
2227
|
+
agent_acknowledgement_required:
|
|
2228
|
+
response?.agent_acknowledgement_required ??
|
|
2229
|
+
entry.agent_acknowledgement_required,
|
|
2230
|
+
}
|
|
2017
2231
|
})
|
|
2018
2232
|
const accepted = stageAcceptedAnswers(
|
|
2019
2233
|
ctx,
|
|
@@ -2428,7 +2642,13 @@ async function escalate(
|
|
|
2428
2642
|
// unreachable, never discard the durable reply already observed.
|
|
2429
2643
|
const finalReplies = finalized.response?.replies ?? []
|
|
2430
2644
|
const authoritative = finalReplies.length > 0 ? finalReplies : replies
|
|
2431
|
-
answered.push({
|
|
2645
|
+
answered.push({
|
|
2646
|
+
pending: entry,
|
|
2647
|
+
reply: authoritative.at(-1)!,
|
|
2648
|
+
replies: authoritative,
|
|
2649
|
+
agent_acknowledgement_required:
|
|
2650
|
+
finalized.response?.agent_acknowledgement_required,
|
|
2651
|
+
})
|
|
2432
2652
|
if (finalized.response === null) {
|
|
2433
2653
|
notes.push(
|
|
2434
2654
|
`could not confirm the close fence for ${entry.request_id}; delivering the durable answer already observed`,
|
|
@@ -2490,19 +2710,28 @@ export function handleSessionEnd(
|
|
|
2490
2710
|
`queued ${orphans.length} question${orphans.length > 1 ? 's' : ''} for retirement on the next hook`,
|
|
2491
2711
|
)
|
|
2492
2712
|
}
|
|
2493
|
-
if (state.accepted !== undefined) {
|
|
2494
|
-
const preserved: SessionState = {
|
|
2713
|
+
if (state.accepted !== undefined || (state.acknowledgement_due?.length ?? 0) > 0) {
|
|
2714
|
+
const preserved: SessionState = {}
|
|
2715
|
+
if (state.accepted !== undefined) preserved.accepted = state.accepted
|
|
2716
|
+
if (state.acknowledgement_due !== undefined && state.acknowledgement_due.length > 0) {
|
|
2717
|
+
preserved.acknowledgement_due = state.acknowledgement_due
|
|
2718
|
+
}
|
|
2495
2719
|
if (state.last_prompt_at !== undefined) preserved.last_prompt_at = state.last_prompt_at
|
|
2496
2720
|
if (state.last_stop_at !== undefined) preserved.last_stop_at = state.last_stop_at
|
|
2497
2721
|
if (state.continuation !== undefined) preserved.continuation = state.continuation
|
|
2498
2722
|
writeSessionState(sessionId, env, preserved)
|
|
2499
|
-
notes.push(
|
|
2723
|
+
notes.push(
|
|
2724
|
+
state.accepted !== undefined
|
|
2725
|
+
? 'preserved an accepted device answer for this exact session to resume'
|
|
2726
|
+
: 'preserved required Agent Acknowledgement obligations for this exact session',
|
|
2727
|
+
)
|
|
2500
2728
|
return {
|
|
2501
2729
|
notes,
|
|
2502
2730
|
log: {
|
|
2503
|
-
outcome: 'answer-preserved',
|
|
2731
|
+
outcome: state.accepted !== undefined ? 'answer-preserved' : 'acknowledgement-preserved',
|
|
2504
2732
|
queued_retirements: orphans.length,
|
|
2505
|
-
accepted_answers: state.accepted
|
|
2733
|
+
accepted_answers: state.accepted?.answers.length ?? 0,
|
|
2734
|
+
acknowledgement_due: state.acknowledgement_due?.length ?? 0,
|
|
2506
2735
|
},
|
|
2507
2736
|
}
|
|
2508
2737
|
}
|