@raidiant/notifai 1.1.0 → 2.0.1
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 +41 -0
- 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 +8 -1
- package/dist/commands.js +120 -15
- package/dist/commands.js.map +1 -1
- package/dist/hooks.d.ts +15 -1
- package/dist/hooks.js +182 -12
- package/dist/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/package.json +11 -10
- package/src/client.test.ts +53 -0
- package/src/client.ts +24 -0
- package/src/commands.test.ts +252 -5
- package/src/commands.ts +158 -13
- package/src/hooks.test.ts +150 -2
- package/src/hooks.ts +240 -12
- package/src/logging.ts +4 -0
- package/src/main.ts +17 -2
- package/src/skill.test.ts +32 -1
package/src/client.test.ts
CHANGED
|
@@ -80,6 +80,59 @@ describe('a server that never answers', () => {
|
|
|
80
80
|
expect(result.replies).toEqual([])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
it('puts and fetches Agent Acknowledgements on the encoded request path', async () => {
|
|
84
|
+
const seen: { method?: string; url?: string; body?: unknown }[] = []
|
|
85
|
+
const baseUrl = await serving((request, response) => {
|
|
86
|
+
let raw = ''
|
|
87
|
+
request.on('data', (chunk) => {
|
|
88
|
+
raw += String(chunk)
|
|
89
|
+
})
|
|
90
|
+
request.on('end', () => {
|
|
91
|
+
seen.push({
|
|
92
|
+
method: request.method,
|
|
93
|
+
url: request.url,
|
|
94
|
+
...(raw === '' ? {} : { body: JSON.parse(raw) as unknown }),
|
|
95
|
+
})
|
|
96
|
+
response.setHeader('content-type', 'application/json')
|
|
97
|
+
if (request.method === 'PUT') {
|
|
98
|
+
response.end(
|
|
99
|
+
JSON.stringify({
|
|
100
|
+
status: 'recorded',
|
|
101
|
+
agent_acknowledgement: {
|
|
102
|
+
text: 'I will deploy.',
|
|
103
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
} else {
|
|
108
|
+
response.end(
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
request_id: 'req/encoded',
|
|
111
|
+
agent_acknowledgement_required: true,
|
|
112
|
+
agent_acknowledgement: null,
|
|
113
|
+
}),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
const client = createClient(baseUrl, 'Bearer test')
|
|
119
|
+
|
|
120
|
+
await client.putAgentAcknowledgement('req/encoded', { text: 'I will deploy.' })
|
|
121
|
+
await client.agentAcknowledgement('req/encoded', { waitSeconds: 25 })
|
|
122
|
+
|
|
123
|
+
expect(seen).toEqual([
|
|
124
|
+
{
|
|
125
|
+
method: 'PUT',
|
|
126
|
+
url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement',
|
|
127
|
+
body: { text: 'I will deploy.' },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
method: 'GET',
|
|
131
|
+
url: '/api/v1/notifications/req%2Fencoded/agent-acknowledgement?wait_seconds=25',
|
|
132
|
+
},
|
|
133
|
+
])
|
|
134
|
+
})
|
|
135
|
+
|
|
83
136
|
it('treats a truncated body as a transport failure, not a crash', async () => {
|
|
84
137
|
// A body that stops mid-JSON used to escape as a raw parse error outside
|
|
85
138
|
// the retry path, so nothing backed off and retried it.
|
package/src/client.ts
CHANGED
|
@@ -6,10 +6,13 @@ import type {
|
|
|
6
6
|
CreateMediaUploadRequestT,
|
|
7
7
|
CreateMediaUploadResponse,
|
|
8
8
|
EvidenceSnapshot,
|
|
9
|
+
GetAgentAcknowledgementResponse,
|
|
9
10
|
ListDevicesResponse,
|
|
10
11
|
ListRepliesResponse,
|
|
11
12
|
Platform,
|
|
12
13
|
PollPairingResponse,
|
|
14
|
+
PutAgentAcknowledgementRequestT,
|
|
15
|
+
PutAgentAcknowledgementResponse,
|
|
13
16
|
SubmissionReceipt,
|
|
14
17
|
SubmitNotificationRequestT,
|
|
15
18
|
} from '@raidiant/notifai-protocol'
|
|
@@ -72,6 +75,14 @@ export interface ApiClient {
|
|
|
72
75
|
): Promise<ListRepliesResponse>
|
|
73
76
|
/** Retire a question and return the replies committed before the close fence. */
|
|
74
77
|
closeReplies(requestId: string): Promise<ListRepliesResponse>
|
|
78
|
+
putAgentAcknowledgement(
|
|
79
|
+
requestId: string,
|
|
80
|
+
body: PutAgentAcknowledgementRequestT,
|
|
81
|
+
): Promise<PutAgentAcknowledgementResponse>
|
|
82
|
+
agentAcknowledgement(
|
|
83
|
+
requestId: string,
|
|
84
|
+
options: { waitSeconds: number },
|
|
85
|
+
): Promise<GetAgentAcknowledgementResponse>
|
|
75
86
|
createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
|
|
76
87
|
uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
|
|
77
88
|
health(): Promise<boolean>
|
|
@@ -223,6 +234,19 @@ export function createClient(
|
|
|
223
234
|
'POST',
|
|
224
235
|
`/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`,
|
|
225
236
|
),
|
|
237
|
+
putAgentAcknowledgement: (requestId, body) =>
|
|
238
|
+
call<PutAgentAcknowledgementResponse>(
|
|
239
|
+
'PUT',
|
|
240
|
+
`/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement`,
|
|
241
|
+
body,
|
|
242
|
+
),
|
|
243
|
+
agentAcknowledgement: (requestId, { waitSeconds }) =>
|
|
244
|
+
call<GetAgentAcknowledgementResponse>(
|
|
245
|
+
'GET',
|
|
246
|
+
`/api/v1/notifications/${encodeURIComponent(requestId)}/agent-acknowledgement?wait_seconds=${waitSeconds}`,
|
|
247
|
+
undefined,
|
|
248
|
+
waitSeconds,
|
|
249
|
+
),
|
|
226
250
|
createMediaUpload: (body) => call('POST', '/api/v1/media', body),
|
|
227
251
|
uploadMedia: async (grant, bytes) => {
|
|
228
252
|
let response: Response
|
package/src/commands.test.ts
CHANGED
|
@@ -23,11 +23,13 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
23
23
|
import { ApiCallError, NetworkError, type ApiClient } from './client.js'
|
|
24
24
|
import type { ClaudeWakeAdapters } from './claude-wake.js'
|
|
25
25
|
import {
|
|
26
|
+
acknowledgeCommand,
|
|
26
27
|
askCommand,
|
|
27
28
|
accessStatusCommand,
|
|
28
29
|
buildQuestions,
|
|
29
30
|
assessReadiness,
|
|
30
31
|
capabilitiesCommand,
|
|
32
|
+
closeCommand,
|
|
31
33
|
configExplainCommand,
|
|
32
34
|
configSetCommand,
|
|
33
35
|
configShowCommand,
|
|
@@ -257,6 +259,8 @@ function isolatedEnv(cwd: string): NodeJS.ProcessEnv {
|
|
|
257
259
|
|
|
258
260
|
const receipt: SubmissionReceipt = {
|
|
259
261
|
request_id: 'req_reply_test',
|
|
262
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
263
|
+
agent_acknowledgement_required: true,
|
|
260
264
|
replayed: false,
|
|
261
265
|
overall: 'provider_accepted_all',
|
|
262
266
|
deliveries: [
|
|
@@ -285,10 +289,18 @@ const reply: ReplyView = {
|
|
|
285
289
|
created_at: '2026-08-01T18:01:00.000Z',
|
|
286
290
|
}
|
|
287
291
|
|
|
288
|
-
function replyResponse(
|
|
292
|
+
function replyResponse(
|
|
293
|
+
replies: ReplyView[] = [],
|
|
294
|
+
options: {
|
|
295
|
+
required?: boolean
|
|
296
|
+
acknowledgement?: ListRepliesResponse['agent_acknowledgement']
|
|
297
|
+
} = {},
|
|
298
|
+
): ListRepliesResponse {
|
|
289
299
|
return {
|
|
290
300
|
request_id: receipt.request_id,
|
|
291
301
|
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
302
|
+
agent_acknowledgement_required: options.required ?? true,
|
|
303
|
+
agent_acknowledgement: options.acknowledgement ?? null,
|
|
292
304
|
replies,
|
|
293
305
|
}
|
|
294
306
|
}
|
|
@@ -635,7 +647,11 @@ describe('command contracts', () => {
|
|
|
635
647
|
).toBe(EXIT.ok)
|
|
636
648
|
expect(polls).toHaveLength(3)
|
|
637
649
|
expect(polls.every((poll) => poll.waitSeconds <= 25)).toBe(true)
|
|
638
|
-
expect(io.outLines
|
|
650
|
+
expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
|
|
651
|
+
expect(io.outLines).toContain('Agent Acknowledgement required.')
|
|
652
|
+
expect(io.outLines.join('\n')).toContain(
|
|
653
|
+
`notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
654
|
+
)
|
|
639
655
|
const received = readLogRecords(deps.env, { event: ['reply.received'] }).records
|
|
640
656
|
expect(received).toHaveLength(1)
|
|
641
657
|
expect(received[0]?.data).toMatchObject({
|
|
@@ -718,7 +734,11 @@ describe('command contracts', () => {
|
|
|
718
734
|
}),
|
|
719
735
|
).toBe(EXIT.ok)
|
|
720
736
|
expect(replyCalls).toBe(3)
|
|
721
|
-
expect(io.outLines
|
|
737
|
+
expect(io.outLines).toContain('reply from iPhone: yes, after the migration')
|
|
738
|
+
expect(io.outLines).toContain('Agent Acknowledgement required.')
|
|
739
|
+
expect(io.outLines.join('\n')).toContain(
|
|
740
|
+
`notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
741
|
+
)
|
|
722
742
|
expect(io.errLines.join('\n')).not.toContain('internal_error')
|
|
723
743
|
})
|
|
724
744
|
|
|
@@ -799,9 +819,14 @@ describe('command contracts', () => {
|
|
|
799
819
|
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
800
820
|
type: 'reply_result',
|
|
801
821
|
request_id: receipt.request_id,
|
|
822
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
802
823
|
replies: [],
|
|
824
|
+
agent_acknowledgement_required: true,
|
|
825
|
+
agent_acknowledgement: null,
|
|
826
|
+
acknowledgement_command: null,
|
|
803
827
|
degraded: false,
|
|
804
828
|
})
|
|
829
|
+
expect(io.outLines).not.toContain('Agent Acknowledgement required.')
|
|
805
830
|
expect(io.errLines.join('\n')).toContain(`notifai replies ${receipt.request_id}`)
|
|
806
831
|
expect(io.errLines.join('\n')).toContain(`notifai close ${receipt.request_id}`)
|
|
807
832
|
})
|
|
@@ -827,11 +852,227 @@ describe('command contracts', () => {
|
|
|
827
852
|
expect(JSON.parse(io.outLines[1] ?? '{}')).toEqual({
|
|
828
853
|
type: 'reply_result',
|
|
829
854
|
request_id: receipt.request_id,
|
|
855
|
+
reply_expires_at: '2026-08-02T18:00:00.000Z',
|
|
830
856
|
replies: [reply],
|
|
857
|
+
agent_acknowledgement_required: true,
|
|
858
|
+
agent_acknowledgement: null,
|
|
859
|
+
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
831
860
|
degraded: false,
|
|
832
861
|
})
|
|
833
862
|
})
|
|
834
863
|
|
|
864
|
+
it('acknowledges non-interactively, trims text, emits JSON, and logs request identity', async () => {
|
|
865
|
+
const io = new CapturedIo()
|
|
866
|
+
let submitted: { requestId: string; text: string } | undefined
|
|
867
|
+
const client = {
|
|
868
|
+
putAgentAcknowledgement: async (requestId: string, body: { text: string }) => {
|
|
869
|
+
submitted = { requestId, text: body.text }
|
|
870
|
+
return {
|
|
871
|
+
status: 'recorded' as const,
|
|
872
|
+
agent_acknowledgement: {
|
|
873
|
+
text: body.text,
|
|
874
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
875
|
+
},
|
|
876
|
+
}
|
|
877
|
+
},
|
|
878
|
+
} as unknown as ApiClient
|
|
879
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-acknowledge-log-'))
|
|
880
|
+
const deps = makeDeps(io, client)
|
|
881
|
+
deps.env = {
|
|
882
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
883
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
884
|
+
}
|
|
885
|
+
deps.logger = createLogger({ env: deps.env, cmd: 'acknowledge' })
|
|
886
|
+
|
|
887
|
+
expect(
|
|
888
|
+
await acknowledgeCommand(deps, receipt.request_id, {
|
|
889
|
+
text: ' I will deploy staging now. ',
|
|
890
|
+
json: true,
|
|
891
|
+
}),
|
|
892
|
+
).toBe(EXIT.ok)
|
|
893
|
+
expect(submitted).toEqual({
|
|
894
|
+
requestId: receipt.request_id,
|
|
895
|
+
text: 'I will deploy staging now.',
|
|
896
|
+
})
|
|
897
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toEqual({
|
|
898
|
+
request_id: receipt.request_id,
|
|
899
|
+
outcome: 'recorded',
|
|
900
|
+
acknowledgement: {
|
|
901
|
+
text: 'I will deploy staging now.',
|
|
902
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
903
|
+
},
|
|
904
|
+
agent_acknowledgement_required: true,
|
|
905
|
+
})
|
|
906
|
+
const events = readLogRecords(deps.env, {
|
|
907
|
+
request: receipt.request_id,
|
|
908
|
+
event: ['acknowledgement.attempted', 'acknowledgement.outcome'],
|
|
909
|
+
}).records
|
|
910
|
+
expect(events.map((event) => event.event)).toEqual([
|
|
911
|
+
'acknowledgement.attempted',
|
|
912
|
+
'acknowledgement.outcome',
|
|
913
|
+
])
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
it('clears a matching active-session obligation after acknowledgement success', async () => {
|
|
917
|
+
const io = new CapturedIo()
|
|
918
|
+
const client = {
|
|
919
|
+
putAgentAcknowledgement: async () => ({
|
|
920
|
+
status: 'recorded' as const,
|
|
921
|
+
agent_acknowledgement: {
|
|
922
|
+
text: 'I will deploy staging now.',
|
|
923
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
} as unknown as ApiClient
|
|
927
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'notifai-ack-clear-'))
|
|
928
|
+
const deps = makeDeps(io, client)
|
|
929
|
+
deps.env = {
|
|
930
|
+
XDG_CONFIG_HOME: path.join(root, 'config'),
|
|
931
|
+
XDG_STATE_HOME: path.join(root, 'state'),
|
|
932
|
+
}
|
|
933
|
+
const now = 1_800_000_000_000
|
|
934
|
+
deps.now = () => now
|
|
935
|
+
writeSessionState('ack-session', deps.env, {
|
|
936
|
+
acknowledgement_due: [{ request_id: receipt.request_id, recorded_at: now }],
|
|
937
|
+
})
|
|
938
|
+
writeProjectSession(deps.cwd, deps.env, 'ack-session', now, 'codex')
|
|
939
|
+
|
|
940
|
+
expect(
|
|
941
|
+
await acknowledgeCommand(deps, receipt.request_id, {
|
|
942
|
+
text: 'I will deploy staging now.',
|
|
943
|
+
}),
|
|
944
|
+
).toBe(EXIT.ok)
|
|
945
|
+
expect(readSessionState('ack-session', deps.env).acknowledgement_due).toBeUndefined()
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('reports idempotent acknowledgement replay in concise human output', async () => {
|
|
949
|
+
const io = new CapturedIo()
|
|
950
|
+
const client = {
|
|
951
|
+
putAgentAcknowledgement: async () => ({
|
|
952
|
+
status: 'replayed' as const,
|
|
953
|
+
agent_acknowledgement: {
|
|
954
|
+
text: 'I will deploy staging now.',
|
|
955
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
956
|
+
},
|
|
957
|
+
}),
|
|
958
|
+
} as unknown as ApiClient
|
|
959
|
+
|
|
960
|
+
expect(
|
|
961
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, {
|
|
962
|
+
text: 'I will deploy staging now.',
|
|
963
|
+
}),
|
|
964
|
+
).toBe(EXIT.ok)
|
|
965
|
+
expect(io.outLines).toEqual([
|
|
966
|
+
`Agent Acknowledgement replayed for ${receipt.request_id} at 2026-08-13T12:01:00.000Z.`,
|
|
967
|
+
])
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
it.each([
|
|
971
|
+
{ text: undefined, message: '--text is required' },
|
|
972
|
+
{ text: ' ', message: '--text is required' },
|
|
973
|
+
{ text: 'x'.repeat(1001), message: 'at most 1000 characters' },
|
|
974
|
+
])('rejects invalid acknowledgement text before network: $message', async ({ text, message }) => {
|
|
975
|
+
const io = new CapturedIo()
|
|
976
|
+
let calls = 0
|
|
977
|
+
const client = {
|
|
978
|
+
putAgentAcknowledgement: async () => {
|
|
979
|
+
calls += 1
|
|
980
|
+
throw new Error('should not be reached')
|
|
981
|
+
},
|
|
982
|
+
} as unknown as ApiClient
|
|
983
|
+
|
|
984
|
+
expect(
|
|
985
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text }),
|
|
986
|
+
).toBe(EXIT.usage)
|
|
987
|
+
expect(calls).toBe(0)
|
|
988
|
+
expect(io.errLines.join('\n')).toContain(message)
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
it.each([
|
|
992
|
+
[new ApiCallError(409, 'conflict', 'A different acknowledgement is already recorded.'), EXIT.failed, 'conflict'],
|
|
993
|
+
[new ApiCallError(409, 'reply_not_enabled', 'Agent Acknowledgements are disabled.'), EXIT.failed, 'reply_not_enabled'],
|
|
994
|
+
[new ApiCallError(409, 'conflict', 'No user reply has been recorded yet.'), EXIT.failed, 'No user reply'],
|
|
995
|
+
[new ApiCallError(404, 'not_found', 'This request is unavailable or its content was purged.'), EXIT.failed, 'purged'],
|
|
996
|
+
[new ApiCallError(401, 'machine_revoked', 'This machine was revoked.'), EXIT.auth, 'machine_revoked'],
|
|
997
|
+
[new NetworkError('Could not reach the service'), EXIT.network, 'Could not reach'],
|
|
998
|
+
] as const)(
|
|
999
|
+
'maps stable acknowledgement failure %#',
|
|
1000
|
+
async (error, expectedExit, expectedCopy) => {
|
|
1001
|
+
const io = new CapturedIo()
|
|
1002
|
+
const client = {
|
|
1003
|
+
putAgentAcknowledgement: async () => {
|
|
1004
|
+
throw error
|
|
1005
|
+
},
|
|
1006
|
+
} as unknown as ApiClient
|
|
1007
|
+
|
|
1008
|
+
expect(
|
|
1009
|
+
await acknowledgeCommand(makeDeps(io, client), receipt.request_id, { text: 'Next work.' }),
|
|
1010
|
+
).toBe(expectedExit)
|
|
1011
|
+
expect(io.errLines.join('\n')).toContain(expectedCopy)
|
|
1012
|
+
},
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
it('reports required-but-not-yet-due state before any user reply', async () => {
|
|
1016
|
+
const io = new CapturedIo()
|
|
1017
|
+
const client = {
|
|
1018
|
+
replies: async () => replyResponse([]),
|
|
1019
|
+
} as unknown as ApiClient
|
|
1020
|
+
|
|
1021
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.noReply)
|
|
1022
|
+
expect(io.outLines).toContain(
|
|
1023
|
+
'Agent Acknowledgement: required after a user reply; no reply is recorded yet.',
|
|
1024
|
+
)
|
|
1025
|
+
expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
it('exposes disabled acknowledgement state without a follow-up command', async () => {
|
|
1029
|
+
const io = new CapturedIo()
|
|
1030
|
+
const client = {
|
|
1031
|
+
replies: async () => replyResponse([reply], { required: false }),
|
|
1032
|
+
} as unknown as ApiClient
|
|
1033
|
+
|
|
1034
|
+
expect(await repliesCommand(makeDeps(io, client), receipt.request_id, {})).toBe(EXIT.ok)
|
|
1035
|
+
expect(io.outLines).toContain('Agent Acknowledgement: not required for this request.')
|
|
1036
|
+
expect(io.outLines.join('\n')).not.toContain('notifai acknowledge')
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
it('exposes an existing acknowledgement without repeating the follow-up command', async () => {
|
|
1040
|
+
const io = new CapturedIo()
|
|
1041
|
+
const acknowledgement = {
|
|
1042
|
+
text: 'I will deploy staging now.',
|
|
1043
|
+
created_at: '2026-08-13T12:01:00.000Z',
|
|
1044
|
+
}
|
|
1045
|
+
const client = {
|
|
1046
|
+
replies: async () => replyResponse([reply], { acknowledgement }),
|
|
1047
|
+
} as unknown as ApiClient
|
|
1048
|
+
|
|
1049
|
+
expect(
|
|
1050
|
+
await repliesCommand(makeDeps(io, client), receipt.request_id, { json: true }),
|
|
1051
|
+
).toBe(EXIT.ok)
|
|
1052
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1053
|
+
agent_acknowledgement_required: true,
|
|
1054
|
+
agent_acknowledgement: acknowledgement,
|
|
1055
|
+
acknowledgement_command: null,
|
|
1056
|
+
})
|
|
1057
|
+
})
|
|
1058
|
+
|
|
1059
|
+
it('close exposes the acknowledgement requirement and exact follow-up command', async () => {
|
|
1060
|
+
const io = new CapturedIo()
|
|
1061
|
+
const client = {
|
|
1062
|
+
closeReplies: async () => replyResponse([reply]),
|
|
1063
|
+
} as unknown as ApiClient
|
|
1064
|
+
|
|
1065
|
+
expect(await closeCommand(makeDeps(io, client), receipt.request_id, { json: true })).toBe(
|
|
1066
|
+
EXIT.ok,
|
|
1067
|
+
)
|
|
1068
|
+
expect(JSON.parse(io.outLines[0] ?? '{}')).toMatchObject({
|
|
1069
|
+
request_id: receipt.request_id,
|
|
1070
|
+
agent_acknowledgement_required: true,
|
|
1071
|
+
agent_acknowledgement: null,
|
|
1072
|
+
acknowledgement_command: `notifai acknowledge ${receipt.request_id} --text <text>`,
|
|
1073
|
+
})
|
|
1074
|
+
})
|
|
1075
|
+
|
|
835
1076
|
it('passes the replies cursor and prints replies for later retrieval', async () => {
|
|
836
1077
|
const io = new CapturedIo()
|
|
837
1078
|
let requested: { waitSeconds: number; afterSeq: number } | undefined
|
|
@@ -851,7 +1092,11 @@ describe('command contracts', () => {
|
|
|
851
1092
|
|
|
852
1093
|
expect(await repliesCommand(deps, receipt.request_id, { after: 7 })).toBe(EXIT.ok)
|
|
853
1094
|
expect(requested).toEqual({ waitSeconds: 0, afterSeq: 7 })
|
|
854
|
-
expect(io.outLines).toEqual([
|
|
1095
|
+
expect(io.outLines).toEqual([
|
|
1096
|
+
'reply from iPhone: yes, after the migration',
|
|
1097
|
+
'Agent Acknowledgement required.',
|
|
1098
|
+
`next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
|
|
1099
|
+
])
|
|
855
1100
|
expect(readLogRecords(deps.env, { event: ['reply.received'] }).records).toHaveLength(1)
|
|
856
1101
|
})
|
|
857
1102
|
|
|
@@ -876,6 +1121,8 @@ describe('command contracts', () => {
|
|
|
876
1121
|
expect(io.outLines).toEqual([
|
|
877
1122
|
`pending request ${receipt.request_id}`,
|
|
878
1123
|
'reply from iPhone: yes, after the migration',
|
|
1124
|
+
'Agent Acknowledgement required.',
|
|
1125
|
+
`next: Run \`notifai acknowledge ${receipt.request_id} --text <text>\` with concrete text saying what you will do because of the reply.`,
|
|
879
1126
|
])
|
|
880
1127
|
})
|
|
881
1128
|
|
|
@@ -2156,7 +2403,7 @@ describe('init', () => {
|
|
|
2156
2403
|
platform: 'ios' as const,
|
|
2157
2404
|
permission_status: 'authorized',
|
|
2158
2405
|
registration_healthy: true,
|
|
2159
|
-
reply_protocol_version:
|
|
2406
|
+
reply_protocol_version: 2,
|
|
2160
2407
|
last_seen_at: '2026-08-05T18:00:00.000Z',
|
|
2161
2408
|
}
|
|
2162
2409
|
|
package/src/commands.ts
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
|
|
6
6
|
import {
|
|
7
|
+
AGENT_ACKNOWLEDGEMENT_MAX_LENGTH,
|
|
7
8
|
CAPABILITIES_V1,
|
|
8
9
|
QUESTION_TEXT_MAX_LENGTH,
|
|
9
10
|
REPLY_MAX_QUESTIONS,
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
import type { Tone } from './ui/theme.js'
|
|
66
67
|
import {
|
|
67
68
|
WAITER_CEILING_SECONDS,
|
|
69
|
+
clearAcknowledgementObligation,
|
|
68
70
|
handleSessionEnd,
|
|
69
71
|
handleStop,
|
|
70
72
|
handleUserPromptSubmit,
|
|
@@ -685,6 +687,7 @@ export async function sendCommand(
|
|
|
685
687
|
title: flags.title,
|
|
686
688
|
overall: receipt.overall,
|
|
687
689
|
replayed: receipt.replayed,
|
|
690
|
+
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
688
691
|
deliveries: receipt.deliveries.length,
|
|
689
692
|
wait_seconds: waitSeconds,
|
|
690
693
|
exit: receiptExit,
|
|
@@ -715,6 +718,9 @@ export async function sendCommand(
|
|
|
715
718
|
type: 'reply_result',
|
|
716
719
|
request_id: receipt.request_id,
|
|
717
720
|
replies: [],
|
|
721
|
+
agent_acknowledgement_required: receipt.agent_acknowledgement_required,
|
|
722
|
+
agent_acknowledgement: null,
|
|
723
|
+
acknowledgement_command: null,
|
|
718
724
|
degraded: false,
|
|
719
725
|
})
|
|
720
726
|
: JSON.stringify(receipt, null, 2),
|
|
@@ -733,15 +739,15 @@ export async function sendCommand(
|
|
|
733
739
|
recordReplies(deps, receipt.request_id, result.response.replies)
|
|
734
740
|
if (flags.json) {
|
|
735
741
|
deps.io.out(
|
|
736
|
-
JSON.stringify(
|
|
737
|
-
type: 'reply_result',
|
|
738
|
-
request_id: receipt.request_id,
|
|
739
|
-
replies: result.response.replies,
|
|
740
|
-
degraded: result.degraded,
|
|
741
|
-
}),
|
|
742
|
+
JSON.stringify(replyResultJson(result.response, result.degraded)),
|
|
742
743
|
)
|
|
743
|
-
} else if (result.response.replies.length > 0)
|
|
744
|
-
|
|
744
|
+
} else if (result.response.replies.length > 0) {
|
|
745
|
+
printReplies(deps, result.response.replies)
|
|
746
|
+
printAcknowledgementStatus(deps, result.response)
|
|
747
|
+
} else {
|
|
748
|
+
printNoReply(deps, receipt.request_id, result.response.reply_expires_at)
|
|
749
|
+
printAcknowledgementStatus(deps, result.response)
|
|
750
|
+
}
|
|
745
751
|
if (result.degraded) {
|
|
746
752
|
log(deps).error('cli.error', {
|
|
747
753
|
kind: 'network',
|
|
@@ -893,12 +899,14 @@ export async function repliesCommand(
|
|
|
893
899
|
})
|
|
894
900
|
recordReplies(deps, requestId, result.response.replies)
|
|
895
901
|
if (flags.json) {
|
|
896
|
-
jsonBodies.push(
|
|
902
|
+
jsonBodies.push(replyResultJson(result.response, result.degraded))
|
|
897
903
|
} else if (result.response.replies.length > 0) {
|
|
898
904
|
if (flags.pending === true) deps.io.out(`pending request ${requestId}`)
|
|
899
905
|
printReplies(deps, result.response.replies)
|
|
906
|
+
printAcknowledgementStatus(deps, result.response)
|
|
900
907
|
} else {
|
|
901
908
|
printNoReply(deps, requestId, result.response.reply_expires_at)
|
|
909
|
+
printAcknowledgementStatus(deps, result.response)
|
|
902
910
|
}
|
|
903
911
|
anyReplies ||= result.response.replies.length > 0
|
|
904
912
|
if (result.degraded) degradedRequestIds.push(requestId)
|
|
@@ -996,7 +1004,13 @@ export async function waitForReply(
|
|
|
996
1004
|
return {
|
|
997
1005
|
response:
|
|
998
1006
|
lastResponse ??
|
|
999
|
-
({
|
|
1007
|
+
({
|
|
1008
|
+
request_id: requestId,
|
|
1009
|
+
reply_expires_at: null,
|
|
1010
|
+
agent_acknowledgement_required: false,
|
|
1011
|
+
agent_acknowledgement: null,
|
|
1012
|
+
replies: [],
|
|
1013
|
+
} satisfies ListRepliesResponse),
|
|
1000
1014
|
timedOut: true,
|
|
1001
1015
|
// A poll succeeded at some point, so we do not throw — but the last thing
|
|
1002
1016
|
// we know is that we could not reach the server. Reporting that as a plain
|
|
@@ -1036,6 +1050,56 @@ function isNonNegativeInteger(value: number): boolean {
|
|
|
1036
1050
|
return Number.isInteger(value) && value >= 0
|
|
1037
1051
|
}
|
|
1038
1052
|
|
|
1053
|
+
function acknowledgementCommand(
|
|
1054
|
+
requestId: string,
|
|
1055
|
+
required: boolean,
|
|
1056
|
+
acknowledgement: ListRepliesResponse['agent_acknowledgement'],
|
|
1057
|
+
hasReply = true,
|
|
1058
|
+
): string | null {
|
|
1059
|
+
return required && acknowledgement === null && hasReply
|
|
1060
|
+
? `notifai acknowledge ${requestId} --text <text>`
|
|
1061
|
+
: null
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function replyResultJson(response: ListRepliesResponse, degraded: boolean): object {
|
|
1065
|
+
return {
|
|
1066
|
+
type: 'reply_result',
|
|
1067
|
+
request_id: response.request_id,
|
|
1068
|
+
reply_expires_at: response.reply_expires_at,
|
|
1069
|
+
replies: response.replies,
|
|
1070
|
+
agent_acknowledgement_required: response.agent_acknowledgement_required,
|
|
1071
|
+
agent_acknowledgement: response.agent_acknowledgement,
|
|
1072
|
+
acknowledgement_command: acknowledgementCommand(
|
|
1073
|
+
response.request_id,
|
|
1074
|
+
response.agent_acknowledgement_required,
|
|
1075
|
+
response.agent_acknowledgement,
|
|
1076
|
+
response.replies.length > 0,
|
|
1077
|
+
),
|
|
1078
|
+
degraded,
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function printAcknowledgementStatus(deps: CommandDeps, response: ListRepliesResponse): void {
|
|
1083
|
+
if (!response.agent_acknowledgement_required) {
|
|
1084
|
+
deps.io.out('Agent Acknowledgement: not required for this request.')
|
|
1085
|
+
return
|
|
1086
|
+
}
|
|
1087
|
+
if (response.agent_acknowledgement !== null) {
|
|
1088
|
+
deps.io.out(
|
|
1089
|
+
`Agent Acknowledgement: recorded at ${response.agent_acknowledgement.created_at}: ${response.agent_acknowledgement.text}`,
|
|
1090
|
+
)
|
|
1091
|
+
return
|
|
1092
|
+
}
|
|
1093
|
+
if (response.replies.length > 0) {
|
|
1094
|
+
deps.io.out('Agent Acknowledgement required.')
|
|
1095
|
+
deps.io.out(
|
|
1096
|
+
`next: Run \`${acknowledgementCommand(response.request_id, true, null)}\` with concrete text saying what you will do because of the reply.`,
|
|
1097
|
+
)
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
deps.io.out('Agent Acknowledgement: required after a user reply; no reply is recorded yet.')
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1039
1103
|
function recordReplies(deps: CommandDeps, requestId: string, replies: readonly ReplyView[]): void {
|
|
1040
1104
|
const logger = log(deps)
|
|
1041
1105
|
for (const reply of replies) {
|
|
@@ -2123,14 +2187,95 @@ function hookActivationAdvice(installations: Installation[]): string {
|
|
|
2123
2187
|
return `${advice.join('. ')}.`
|
|
2124
2188
|
}
|
|
2125
2189
|
|
|
2190
|
+
/** Record the one Agent Acknowledgement associated with a replied-to request. */
|
|
2191
|
+
export async function acknowledgeCommand(
|
|
2192
|
+
deps: CommandDeps,
|
|
2193
|
+
requestId: string,
|
|
2194
|
+
flags: { text?: string; json?: boolean },
|
|
2195
|
+
): Promise<number> {
|
|
2196
|
+
const text = flags.text?.trim() ?? ''
|
|
2197
|
+
if (text.length === 0) {
|
|
2198
|
+
deps.io.err('--text is required and must contain non-whitespace text.')
|
|
2199
|
+
return EXIT.usage
|
|
2200
|
+
}
|
|
2201
|
+
if (text.length > AGENT_ACKNOWLEDGEMENT_MAX_LENGTH) {
|
|
2202
|
+
deps.io.err(
|
|
2203
|
+
`--text must be at most ${AGENT_ACKNOWLEDGEMENT_MAX_LENGTH} characters after trimming.`,
|
|
2204
|
+
)
|
|
2205
|
+
return EXIT.usage
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
const logger = log(deps)
|
|
2209
|
+
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2210
|
+
logger.info('acknowledgement.attempted', {
|
|
2211
|
+
request_id: requestId,
|
|
2212
|
+
text,
|
|
2213
|
+
characters: text.length,
|
|
2214
|
+
})
|
|
2215
|
+
const authed = authedClient(deps, config)
|
|
2216
|
+
if (!authed) return EXIT.auth
|
|
2217
|
+
try {
|
|
2218
|
+
const result = await authed.client.putAgentAcknowledgement(requestId, { text })
|
|
2219
|
+
const output = {
|
|
2220
|
+
request_id: requestId,
|
|
2221
|
+
outcome: result.status,
|
|
2222
|
+
acknowledgement: result.agent_acknowledgement,
|
|
2223
|
+
agent_acknowledgement_required: true,
|
|
2224
|
+
}
|
|
2225
|
+
logger.info('acknowledgement.outcome', {
|
|
2226
|
+
request_id: requestId,
|
|
2227
|
+
outcome: result.status,
|
|
2228
|
+
text: result.agent_acknowledgement.text,
|
|
2229
|
+
created_at: result.agent_acknowledgement.created_at,
|
|
2230
|
+
agent_acknowledgement_required: true,
|
|
2231
|
+
})
|
|
2232
|
+
const sessionId = readProjectSession(deps.cwd, deps.env, (deps.now ?? Date.now)())
|
|
2233
|
+
if (sessionId !== null) {
|
|
2234
|
+
clearAcknowledgementObligation(sessionId, deps.env, requestId)
|
|
2235
|
+
}
|
|
2236
|
+
if (flags.json) deps.io.out(JSON.stringify(output, null, 2))
|
|
2237
|
+
else {
|
|
2238
|
+
deps.io.out(
|
|
2239
|
+
`Agent Acknowledgement ${result.status} for ${requestId} at ${result.agent_acknowledgement.created_at}.`,
|
|
2240
|
+
)
|
|
2241
|
+
}
|
|
2242
|
+
return EXIT.ok
|
|
2243
|
+
} catch (err) {
|
|
2244
|
+
return reportError(deps, err, { operation: 'agent_acknowledgement', request_id: requestId })
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2126
2248
|
/** Retire a question so a late answer is rejected rather than silently lost. */
|
|
2127
|
-
export async function closeCommand(
|
|
2249
|
+
export async function closeCommand(
|
|
2250
|
+
deps: CommandDeps,
|
|
2251
|
+
requestId: string,
|
|
2252
|
+
flags: { json?: boolean } = {},
|
|
2253
|
+
): Promise<number> {
|
|
2128
2254
|
const config = loadLoggedConfig(deps, { cwd: deps.cwd, env: deps.env })
|
|
2129
2255
|
const authed = authedClient(deps, config)
|
|
2130
2256
|
if (!authed) return EXIT.auth
|
|
2131
2257
|
try {
|
|
2132
|
-
await authed.client.closeReplies(requestId)
|
|
2133
|
-
|
|
2258
|
+
const response = await authed.client.closeReplies(requestId)
|
|
2259
|
+
if (flags.json) {
|
|
2260
|
+
deps.io.out(
|
|
2261
|
+
JSON.stringify(
|
|
2262
|
+
{
|
|
2263
|
+
...response,
|
|
2264
|
+
acknowledgement_command: acknowledgementCommand(
|
|
2265
|
+
response.request_id,
|
|
2266
|
+
response.agent_acknowledgement_required,
|
|
2267
|
+
response.agent_acknowledgement,
|
|
2268
|
+
response.replies.length > 0,
|
|
2269
|
+
),
|
|
2270
|
+
},
|
|
2271
|
+
null,
|
|
2272
|
+
2,
|
|
2273
|
+
),
|
|
2274
|
+
)
|
|
2275
|
+
} else {
|
|
2276
|
+
deps.io.out(`Closed the reply window for ${requestId}.`)
|
|
2277
|
+
if (response.replies.length > 0) printAcknowledgementStatus(deps, response)
|
|
2278
|
+
}
|
|
2134
2279
|
return EXIT.ok
|
|
2135
2280
|
} catch (err) {
|
|
2136
2281
|
return reportError(deps, err)
|