diff --git a/.changeset/notice-delivery-stage4-runtime.md b/.changeset/notice-delivery-stage4-runtime.md new file mode 100644 index 000000000..b2692bc8a --- /dev/null +++ b/.changeset/notice-delivery-stage4-runtime.md @@ -0,0 +1,5 @@ +--- +"@agent-bundle/runtime": patch +--- + +Add the #99 stage-4 delivery substrate to the notice ledger: recipient-scoped explicit `acknowledge()` (new `acknowledged` state, the strongest evidenced outcome), optional `retryBudget`/`nextAttemptAt` publish fields with re-attempt semantics evaluated only on admitted events (never an implied timer), a `signalAvailability()` ledger verb recording wire-level `resources/updated` signals as availability receipts (never delivery), and the pure delivery-route selector over per-host advertisements with a typed unavailable outcome. `delivered` remains deliberately absent from the state union because no pinned host supplies cross-actor delivery evidence (2026-09-02 survey on #99); pre-existing durable notices replay unchanged because the new fields are optional and never materialized by parse. diff --git a/.changeset/record-deferred-native-events.md b/.changeset/record-deferred-native-events.md new file mode 100644 index 000000000..6308ca790 --- /dev/null +++ b/.changeset/record-deferred-native-events.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Record dated deferral rows for every explicitly deferred native host callback from the #258 v2 tracker in a new `deferredNativeEvents` capability-table section: Claude host/UI/protocol callbacks (Setup, UserPromptExpansion, PostToolBatch, MessageDisplay, InstructionsLoaded, CwdChanged, DirectoryAdded, Pre/PostModelSwitch, Elicitation and ElicitationResult, WorktreeCreate/Remove, Notification which belongs to #99 delivery evidence, and the policy_settings exception to config/change blocking), Cursor tab/thought callbacks and the tool-selector native variants, and Codex Interrupt. A pin test enforces the list and the dated reasons. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index ce9d51f4e..0a50ce851 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -12,6 +12,68 @@ "source": "https://code.claude.com/docs/en/discover-plugins", "state": "supported" }, + "deferredNativeEvents": { + "ConfigChange-policy_settings": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: ConfigChange block decisions cannot stop policy_settings changes; recorded so the config/change family's deny channel is not overstated.", + "state": "unavailable" + }, + "CwdChanged": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: host environment callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "DirectoryAdded": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: host workspace-registration callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "Elicitation": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: MCP protocol callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "ElicitationResult": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: MCP protocol callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "InstructionsLoaded": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: host context-load callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "MessageDisplay": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: UI display callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "Notification": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: delivery-channel surface tracked by #99 delivery evidence, not an application event route (#258 defer list).", + "state": "unavailable" + }, + "PostModelSwitch": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: model-lifecycle callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "PostToolBatch": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: batch-scheduling callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "PreModelSwitch": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: model-lifecycle callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "Setup": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: one-time init/maintenance callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "UserPromptExpansion": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: command-expansion callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "WorktreeCreate": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: worktree-provisioning callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + }, + "WorktreeRemove": { + "reason": "retrieved 2026-09-02 from https://code.claude.com/docs/en/hooks: worktree-provisioning callback outside the canonical application-event vocabulary (#258 defer list).", + "state": "unavailable" + } + }, "hooks": { "config": "hooks/hooks.json", "events": { diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index 714a15101..5d1c90e30 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -12,6 +12,12 @@ "source": "https://developers.openai.com/codex/cli/reference", "state": "supported" }, + "deferredNativeEvents": { + "Interrupt": { + "reason": "retrieved 2026-09-02: generated Interrupt schema exists in the codex repository, but release documentation and a pinned CLI revision do not yet agree on its contract (#258 defer list).", + "state": "unavailable" + } + }, "hooks": { "config": "hooks/hooks.json", "events": { diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index de9555911..12fbf7727 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -12,6 +12,52 @@ "source": "https://cursor.com/docs/plugins", "state": "supported" }, + "deferredNativeEvents": { + "afterAgentResponse": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list).", + "state": "unavailable" + }, + "afterAgentThought": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native/deferred until a canonical consumer is accepted (#258 defer list).", + "state": "unavailable" + }, + "afterFileEdit": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: agent-edit variant overlapping canonical tool/after per the #258 selector rule; a dedicated family requires fixture evidence that the generic tool route loses information.", + "state": "unavailable" + }, + "afterMCPExecution": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with mcp selector per the #258 selector rule.", + "state": "unavailable" + }, + "afterShellExecution": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/after with shell selector per the #258 selector rule.", + "state": "unavailable" + }, + "afterTabFileEdit": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list).", + "state": "unavailable" + }, + "beforeMCPExecution": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with mcp selector per the #258 selector rule.", + "state": "unavailable" + }, + "beforeReadFile": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with file.read selector per the #258 selector rule.", + "state": "unavailable" + }, + "beforeShellExecution": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: native variant of canonical tool/before with shell selector per the #258 selector rule.", + "state": "unavailable" + }, + "beforeSubmitPrompt-cloud": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: cloud/desktop availability differences are capability dimensions; cloud rows stay scoped per surface rather than universal-support claims (#258 defer list).", + "state": "unavailable" + }, + "beforeTabFileRead": { + "reason": "retrieved 2026-09-02 from https://cursor.com/docs/hooks: Tab-surface callback, native/deferred until a canonical consumer is accepted (#258 defer list).", + "state": "unavailable" + } + }, "hooks": { "config": "hooks/hooks.json", "events": { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 138d43c79..bba20514c 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -1022,3 +1022,33 @@ it('reports evidence-backed installation support only for real host targets', () expect(registry.supports(target, 'install')).toBe(false); } }); + +it('pins dated deferral rows for every explicitly deferred native callback from #258', async () => { + const { readFile } = await import('node:fs/promises'); + const tables = { + claude: JSON.parse(await readFile(new URL('../src/adapters/capabilities/claude-2.1.250.json', import.meta.url), 'utf8')) as Record, + codex: JSON.parse(await readFile(new URL('../src/adapters/capabilities/codex-0.147.0.json', import.meta.url), 'utf8')) as Record, + cursor: JSON.parse(await readFile(new URL('../src/adapters/capabilities/cursor-2026-08-28.json', import.meta.url), 'utf8')) as Record, + }; + const expected = { + claude: [ + 'ConfigChange-policy_settings', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult', + 'InstructionsLoaded', 'MessageDisplay', 'Notification', 'PostModelSwitch', 'PostToolBatch', + 'PreModelSwitch', 'Setup', 'UserPromptExpansion', 'WorktreeCreate', 'WorktreeRemove', + ], + codex: ['Interrupt'], + cursor: [ + 'afterAgentResponse', 'afterAgentThought', 'afterFileEdit', 'afterMCPExecution', 'afterShellExecution', + 'afterTabFileEdit', 'beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', + 'beforeSubmitPrompt-cloud', 'beforeTabFileRead', + ], + } as const; + for (const [host, names] of Object.entries(expected)) { + const deferred = tables[host as keyof typeof tables].deferredNativeEvents as Record; + expect(Object.keys(deferred).sort()).toEqual([...names].sort()); + for (const name of names) { + expect(deferred[name]!.state).toMatch(/^(unavailable|prohibited)$/u); + expect(deferred[name]!.reason).toMatch(/2026-09-02/u); + } + } +}); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index db564b511..c435973b0 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -210,10 +210,10 @@ it('records observed capability versions and rehashes schema snapshots against p } if (target === 'claude') { - expect(sha256Hex(capability)).toBe('59e190a26734a6bcca0f22ceee6151ebdd73c690eddb979a78b6b25f0136cb45'); + expect(sha256Hex(capability)).toBe('98938d9685411227acee422f90d2e5bb51f33da96ba499aa2ad7b5d3422b37f1'); } if (target === 'cursor') { - expect(sha256Hex(capability)).toBe('52b1fb8776ffe0848e9ee8c35a604052c59fb292cabbfbaf7a482cd03ce3d918'); + expect(sha256Hex(capability)).toBe('e36e84f829f1c69562647a4bc26ecfbaea2dbd0559f194ed7f52bbf133688fe5'); const pluginSchema = JSON.parse(await readFile( new URL('../src/adapters/schemas/cursor/plugin.schema.json', import.meta.url), 'utf8', diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index cf1ff110d..d662b83cc 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -70,12 +70,14 @@ const failedLedger = (failure: AgentStateError): AgentNoticeLedger => { openRequest: async () => Object.freeze({ close: () => undefined, handle: Object.freeze({ + acknowledge: reject, inbox: reject, publish: reject, read: reject, }), }), read: reject, + signalAvailability: reject, withdraw: reject, }); }; diff --git a/packages/rsc-runtime/src/notices/contract.ts b/packages/rsc-runtime/src/notices/contract.ts index ce4602494..cf6ed350b 100644 --- a/packages/rsc-runtime/src/notices/contract.ts +++ b/packages/rsc-runtime/src/notices/contract.ts @@ -14,9 +14,18 @@ export const AGENT_NOTICE_STATES = Object.freeze([ 'expired', 'unavailable', 'withdrawn', + 'acknowledged', ] as const); -/** V1 contains only states the framework can evidence without host claims. */ +/** + * V1 contains only states the framework can evidence without host claims. + * Channel evidence that does not transfer ownership (inbox exposure, wire-level + * resource-updated signals) is recorded as receipts on the notice instead of a + * state: `available` and `read` from the #99 taxonomy map onto the + * `availability` and `exposure` receipts, and `delivered` is deliberately + * absent because no pinned host supplies cross-actor delivery evidence + * (2026-09-02 survey on #99). + */ export type AgentNoticeState = (typeof AGENT_NOTICE_STATES)[number]; export type AgentNoticePriority = 'low' | 'normal' | 'high'; @@ -50,10 +59,29 @@ export interface AgentNoticeExposure { readonly lastInvocationId: string; } +/** + * Wire-level availability evidence: a `notifications/resources/updated` signal + * was sent for the inbox resource on a live session. Protocol write success is + * only availability, never delivery (#99). + */ +export interface AgentNoticeAvailability { + readonly channel: 'mcp-resource-updated'; + readonly count: number; + readonly firstAt: string; + readonly lastAt: string; +} + +export interface AgentNoticeAcknowledgement { + readonly acknowledgedAt: string; + readonly invocationId: string; +} + export type AgentNoticeUnavailableReason = 'delivery-authorization-unavailable'; export interface AgentNotice { + readonly acknowledgement?: AgentNoticeAcknowledgement; readonly attempts: readonly AgentNoticeAttemptReceipt[]; + readonly availability?: AgentNoticeAvailability; readonly content: AgentDocumentSnapshot; readonly createdAt: string; readonly dedupeKey?: string; @@ -61,8 +89,12 @@ export interface AgentNotice { readonly expiresAt?: string; readonly exposure?: AgentNoticeExposure; readonly id: string; + /** Admissions before this instant leave the notice pending (V1: evaluated only on admitted events, never by an implied timer). */ + readonly nextAttemptAt?: string; readonly priority: AgentNoticePriority; readonly recipient: AgentRecipient; + /** Maximum next-event attempt receipts before admission stops re-attempting; absent means 1. */ + readonly retryBudget?: number; readonly state: AgentNoticeState; readonly unavailableAt?: string; readonly unavailableReason?: AgentNoticeUnavailableReason; @@ -78,8 +110,11 @@ export interface AgentNoticePublishInput { readonly content: AgentDocumentSnapshot; readonly dedupeKey?: string; readonly expiresAt?: string; + readonly nextAttemptAt?: string; readonly priority: AgentNoticePriority; readonly recipient: AgentRecipient; + /** Defaults to 1 (single next-event attempt). */ + readonly retryBudget?: number; } export interface AgentNoticePublishOptions { @@ -109,7 +144,7 @@ export type AgentNoticeAuthorizationDecision = export interface AgentNoticeAuthorizationRequest { readonly noticeId?: string; - readonly phase: 'deliver' | 'publish' | 'read'; + readonly phase: 'acknowledge' | 'deliver' | 'publish' | 'read'; readonly principal: AgentNoticePrincipal; readonly recipient: AgentRecipient; } @@ -124,6 +159,8 @@ export interface AgentNoticeDelivery { } export interface AgentNoticesHandle { + /** Recipient-scoped explicit acknowledgement; the strongest evidenced state. */ + acknowledge(id: string): Promise; inbox(): Promise; publish(input: AgentNoticePublishInput, options: AgentNoticePublishOptions): Promise; read(): Promise; @@ -140,10 +177,18 @@ export interface AgentNoticeRequestLease { close(): void; } +export interface AgentNoticeAvailabilitySignalOptions { + readonly at: string; + readonly idempotencyKey: string; + readonly noticeIds: readonly string[]; +} + export interface AgentNoticeLedger { expire(options: AgentNoticeExpiryOptions): Promise; openRequest(request: AgentNoticeRequest): Promise; read(): Promise; + /** Records a wire-level resources/updated signal; availability, never delivery. */ + signalAvailability(options: AgentNoticeAvailabilitySignalOptions): Promise; withdraw(id: string, options: AgentNoticeWithdrawOptions): Promise; } diff --git a/packages/rsc-runtime/src/notices/index.ts b/packages/rsc-runtime/src/notices/index.ts index 7ff2ba26a..19112e18d 100644 --- a/packages/rsc-runtime/src/notices/index.ts +++ b/packages/rsc-runtime/src/notices/index.ts @@ -13,10 +13,13 @@ export { } from './contract.js'; export type { AgentNotice, + AgentNoticeAcknowledgement, AgentNoticeAttemptReceipt, AgentNoticeAuthorizationDecision, AgentNoticeAuthorizationRequest, AgentNoticeAuthorizer, + AgentNoticeAvailability, + AgentNoticeAvailabilitySignalOptions, AgentNoticeDelivery, AgentNoticeErrorCode, AgentNoticeExposure, @@ -35,6 +38,16 @@ export type { AgentNoticeWithdrawOptions, AgentRecipient, } from './contract.js'; +export { + AGENT_NOTICE_DELIVERY_ROUTES, + selectNoticeDeliveryRoutes, +} from './router.js'; +export type { + AgentNoticeDeliveryAdvertisement, + AgentNoticeDeliveryRoute, + AgentNoticeDeliveryRouteState, + AgentNoticeRouteSelection, +} from './router.js'; export { createAgentNoticeLedger, } from './ledger.js'; diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index 44a6bbbbe..25a252e86 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -18,6 +18,7 @@ import { type AgentNoticeAuthorizationDecision, type AgentNoticeAuthorizationRequest, type AgentNoticeAuthorizer, + type AgentNoticeAvailabilitySignalOptions, type AgentNoticeDelivery, type AgentNoticeExpiryOptions, type AgentNoticeLedger, @@ -187,6 +188,17 @@ const publishProgram = Effect.fnUntraced(function*( const dedupeKey = input.dedupeKey === undefined ? undefined : nonEmptyText(input.dedupeKey, 'Notice dedupe key'); + const retryBudget = input.retryBudget; + if (retryBudget !== undefined && (!Number.isInteger(retryBudget) || retryBudget < 1)) { + throw new AgentNoticeError('invalid-input', 'Notice retryBudget must be a positive integer'); + } + const nextAttemptAt = input.nextAttemptAt === undefined + ? undefined + : timestamp(input.nextAttemptAt, 'Notice nextAttemptAt'); + if (nextAttemptAt !== undefined && expiresAt !== undefined + && Date.parse(nextAttemptAt) >= Date.parse(expiresAt)) { + throw new AgentNoticeError('invalid-input', 'Notice nextAttemptAt must be earlier than expiresAt'); + } const id = `notice_${createHash('sha256') .update(canonicalJson({ idempotencyKey, recipient: target }), 'utf8') .digest('hex')}`; @@ -197,8 +209,10 @@ const publishProgram = Effect.fnUntraced(function*( ...(dedupeKey === undefined ? {} : { dedupeKey }), ...(expiresAt === undefined ? {} : { expiresAt }), id, + ...(nextAttemptAt === undefined ? {} : { nextAttemptAt }), priority: priority(input.priority), recipient: target, + ...(retryBudget === undefined ? {} : { retryBudget }), state: 'pending', }); return { dedupeKey, id, idempotencyKey, notice, target }; @@ -321,8 +335,10 @@ export const createAgentNoticeLedger = ( && notice.expiresAt !== undefined && Date.parse(notice.expiresAt) <= admissionTime); const candidates = before.state.notices.filter((notice) => - notice.state === 'pending' + (notice.state === 'pending' + || notice.state === 'attempted' && notice.attempts.length < (notice.retryBudget ?? 1)) && Date.parse(notice.createdAt) <= admissionTime + && (notice.nextAttemptAt === undefined || Date.parse(notice.nextAttemptAt) <= admissionTime) && (notice.expiresAt === undefined || Date.parse(notice.expiresAt) > admissionTime) && recipientMatchesPrincipal(notice.recipient, request.principal)); @@ -362,6 +378,55 @@ export const createAgentNoticeLedger = ( let closed = false; const handle: AgentNoticesHandle = Object.freeze({ + acknowledge(id: string) { + return runPromise(Effect.gen(function*() { + yield* noticeEffect(() => assertOpen(closed, request.signal)); + const noticeId = yield* noticeEffect(() => nonEmptyText(id, 'Notice id')); + const before = yield* storeEffect(() => store.read({ signal: request.signal })); + const target = before.state.notices.find((notice) => notice.id === noticeId); + if (target === undefined) { + return yield* Effect.fail(new AgentNoticeError('invalid-input', `Unknown notice ${noticeId}`)); + } + if (!recipientMatchesPrincipal(target.recipient, request.principal)) { + return yield* Effect.fail(new AgentNoticeError( + 'unauthorized', + 'Only the notice recipient may acknowledge it', + )); + } + const authorization = yield* authorizeEffect(options.authorize, { + noticeId, + phase: 'acknowledge', + principal: request.principal, + recipient: target.recipient, + }); + if (authorization.state === 'unavailable') { + return yield* Effect.fail(new AgentNoticeError( + 'unauthorized', + 'Notice acknowledgement authorization is unavailable', + )); + } + const committed = yield* storeEffect(() => store.dispatch( + 'acknowledged', + { + at: request.invocation.startedAt, + id: noticeId, + invocationId: request.invocation.id, + }, + { + idempotencyKey: `agent-notices:ack:${request.invocation.id}:${noticeId}`, + signal: request.signal, + }, + )); + const acknowledged = committed.state.notices.find((notice) => notice.id === noticeId); + if (acknowledged === undefined || acknowledged.state !== 'acknowledged') { + return yield* Effect.fail(new AgentNoticeError( + 'invalid-input', + `Notice ${noticeId} is not acknowledgeable from state ${acknowledged?.state ?? 'missing'}`, + )); + } + return acknowledged; + })); + }, inbox() { return runPromise(Effect.gen(function*() { yield* noticeEffect(() => assertOpen(closed, request.signal)); @@ -395,6 +460,26 @@ export const createAgentNoticeLedger = ( return snapshotFrom(snapshot.revision, snapshot.state); }, + signalAvailability(options: AgentNoticeAvailabilitySignalOptions): Promise { + return runPromise(Effect.gen(function*() { + const at = yield* noticeEffect(() => timestamp(options.at, 'Notice availability time')); + const idempotencyKey = yield* noticeEffect(() => + nonEmptyText(options.idempotencyKey, 'Notice availability idempotency key')); + const noticeIds = yield* noticeEffect(() => { + if (options.noticeIds.length === 0) { + throw new AgentNoticeError('invalid-input', 'Notice availability requires at least one notice id'); + } + return options.noticeIds.map((id) => nonEmptyText(id, 'Notice id')); + }); + const committed = yield* storeEffect(() => store.dispatch( + 'availability-signalled', + { at, channel: 'mcp-resource-updated', noticeIds }, + { idempotencyKey }, + )); + return snapshotFrom(committed.revision, committed.state); + })); + }, + withdraw(id: string, withdrawal: AgentNoticeWithdrawOptions): Promise { return runPromise(Effect.gen(function*() { const noticeId = yield* noticeEffect(() => nonEmptyText(id, 'Notice id')); diff --git a/packages/rsc-runtime/src/notices/router.ts b/packages/rsc-runtime/src/notices/router.ts new file mode 100644 index 000000000..2a9429973 --- /dev/null +++ b/packages/rsc-runtime/src/notices/router.ts @@ -0,0 +1,70 @@ +import { AgentNoticeError } from './contract.js'; + +/** + * Delivery routes from the #99 taxonomy. `current-response` is the only + * same-request route; the rest are cross-request. `directed-push` and + * `host-toast` exist in the vocabulary so adapters can advertise them + * honestly, but no pinned host supports either (2026-09-02 survey on #99). + */ +export const AGENT_NOTICE_DELIVERY_ROUTES = Object.freeze([ + 'current-response', + 'next-event', + 'mcp-inbox', + 'mcp-resource-updated', + 'directed-push', + 'host-toast', +] as const); + +export type AgentNoticeDeliveryRoute = (typeof AGENT_NOTICE_DELIVERY_ROUTES)[number]; + +export type AgentNoticeDeliveryRouteState = + | { readonly state: 'supported' } + | { readonly reason: string; readonly state: 'unavailable' }; + +export type AgentNoticeDeliveryAdvertisement = Readonly< + Record +>; + +/** Stable preference order for cross-request routes; all supported routes run. */ +const crossRequestPreference = Object.freeze([ + 'directed-push', + 'mcp-resource-updated', + 'mcp-inbox', + 'next-event', + 'host-toast', +] as const satisfies readonly AgentNoticeDeliveryRoute[]); + +export type AgentNoticeRouteSelection = + | { readonly kind: 'selected'; readonly routes: readonly AgentNoticeDeliveryRoute[] } + | { readonly kind: 'unavailable'; readonly reason: 'no-supported-cross-request-route' }; + +const validateAdvertisement = ( + advertisement: AgentNoticeDeliveryAdvertisement, +): void => { + for (const route of AGENT_NOTICE_DELIVERY_ROUTES) { + const entry = advertisement[route] as AgentNoticeDeliveryRouteState | undefined; + if (entry === undefined) { + throw new AgentNoticeError('invalid-input', `Delivery advertisement is missing route ${route}`); + } + if (entry.state === 'unavailable' && entry.reason.trim() === '') { + throw new AgentNoticeError('invalid-input', `Unavailable route ${route} requires a dated reason`); + } + } +}; + +/** + * Selects every supported cross-request route in stable preference order. + * A notice with no supported cross-request route stays pending until expiry + * with a typed unavailable outcome; the router never fabricates a channel. + */ +export const selectNoticeDeliveryRoutes = ( + advertisement: AgentNoticeDeliveryAdvertisement, +): AgentNoticeRouteSelection => { + validateAdvertisement(advertisement); + const routes = crossRequestPreference.filter( + (route) => advertisement[route].state === 'supported', + ); + return routes.length === 0 + ? Object.freeze({ kind: 'unavailable', reason: 'no-supported-cross-request-route' }) + : Object.freeze({ kind: 'selected', routes: Object.freeze(routes) }); +}; diff --git a/packages/rsc-runtime/src/notices/state.ts b/packages/rsc-runtime/src/notices/state.ts index e42035031..d47aa4ccf 100644 --- a/packages/rsc-runtime/src/notices/state.ts +++ b/packages/rsc-runtime/src/notices/state.ts @@ -69,8 +69,22 @@ const exposureSchema = z.object({ lastInvocationId: z.string().min(1), }).strict().readonly(); +const availabilitySchema = z.object({ + channel: z.literal('mcp-resource-updated'), + count: z.number().int().positive(), + firstAt: z.string().min(1), + lastAt: z.string().min(1), +}).strict().readonly(); + +const acknowledgementSchema = z.object({ + acknowledgedAt: z.string().min(1), + invocationId: z.string().min(1), +}).strict().readonly(); + const noticeSchema = z.object({ + acknowledgement: acknowledgementSchema.optional(), attempts: z.array(attemptSchema).readonly(), + availability: availabilitySchema.optional(), content: documentSchema, createdAt: z.string().min(1), dedupeKey: z.string().min(1).optional(), @@ -78,9 +92,14 @@ const noticeSchema = z.object({ expiresAt: z.string().min(1).optional(), exposure: exposureSchema.optional(), id: z.string().min(1), + nextAttemptAt: z.string().min(1).optional(), priority: z.enum(['low', 'normal', 'high']), recipient: recipientSchema, - state: z.enum(['pending', 'attempted', 'expired', 'unavailable', 'withdrawn']), + // Optional (not defaulted): parse must never materialize fields absent from + // stored heads or the journal head-vs-replay consistency check would diverge + // on state persisted before the retry contract. Absent means a budget of 1. + retryBudget: z.number().int().min(1).optional(), + state: z.enum(['pending', 'attempted', 'expired', 'unavailable', 'withdrawn', 'acknowledged']), unavailableAt: z.string().min(1).optional(), unavailableReason: z.literal('delivery-authorization-unavailable').optional(), withdrawnAt: z.string().min(1).optional(), @@ -91,6 +110,11 @@ export interface AgentNoticeLedgerState { } export const agentNoticeEventSchemas = { + acknowledged: z.object({ + at: z.string().min(1), + id: z.string().min(1), + invocationId: z.string().min(1), + }).strict(), admitted: z.object({ at: z.string().min(1), authorizedIds: z.array(z.string().min(1)), @@ -98,6 +122,11 @@ export const agentNoticeEventSchemas = { principal: principalSchema, unavailableIds: z.array(z.string().min(1)), }).strict(), + 'availability-signalled': z.object({ + at: z.string().min(1), + channel: z.literal('mcp-resource-updated'), + noticeIds: z.array(z.string().min(1)), + }).strict(), exposed: z.object({ at: z.string().min(1), channel: z.literal('mcp-inbox'), @@ -154,6 +183,7 @@ const transitionExpiry = (notice: AgentNotice, at: string): AgentNotice => { case 'expired': case 'unavailable': case 'withdrawn': + case 'acknowledged': return notice; default: { const exhaustive: never = notice.state; @@ -172,6 +202,64 @@ const transitionWithdrawal = (notice: AgentNotice, id: string, at: string): Agen case 'expired': case 'unavailable': case 'withdrawn': + case 'acknowledged': + return notice; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } +}; + +const transitionAcknowledgement = ( + notice: AgentNotice, + input: { readonly at: string; readonly id: string; readonly invocationId: string }, +): AgentNotice => { + if (notice.id !== input.id) return notice; + switch (notice.state) { + case 'pending': + case 'attempted': + return Object.freeze({ + ...notice, + acknowledgement: Object.freeze({ + acknowledgedAt: input.at, + invocationId: input.invocationId, + }), + state: 'acknowledged', + }); + case 'expired': + case 'unavailable': + case 'withdrawn': + case 'acknowledged': + return notice; + default: { + const exhaustive: never = notice.state; + return exhaustive; + } + } +}; + +const transitionAvailability = ( + notice: AgentNotice, + input: { readonly at: string; readonly noticeIds: ReadonlySet }, +): AgentNotice => { + if (!input.noticeIds.has(notice.id)) return notice; + switch (notice.state) { + case 'pending': + case 'attempted': + return Object.freeze({ + ...notice, + availability: Object.freeze({ + channel: 'mcp-resource-updated' as const, + count: (notice.availability?.count ?? 0) + 1, + firstAt: notice.availability?.firstAt ?? input.at, + lastAt: input.at, + }), + }); + case 'expired': + case 'unavailable': + case 'withdrawn': + case 'acknowledged': return notice; default: { const exhaustive: never = notice.state; @@ -192,17 +280,26 @@ const transitionAdmission = ( ): AgentNotice => { const current = transitionExpiry(notice, input.at); switch (current.state) { - case 'pending': { + case 'pending': + case 'attempted': { if (!recipientMatchesPrincipal(current.recipient, input.principal)) return current; + // Not due yet: only admitted events evaluate this; V1 never implies a timer. + if (current.nextAttemptAt !== undefined && Date.parse(input.at) < Date.parse(current.nextAttemptAt)) { + return current; + } + if (current.attempts.length >= (current.retryBudget ?? 1)) return current; if (input.unavailableIds.has(current.id)) { - return Object.freeze({ - ...current, - state: 'unavailable', - unavailableAt: input.at, - unavailableReason: 'delivery-authorization-unavailable', - }); + return current.state === 'pending' + ? Object.freeze({ + ...current, + state: 'unavailable', + unavailableAt: input.at, + unavailableReason: 'delivery-authorization-unavailable', + }) + : current; } if (!input.authorizedIds.has(current.id)) return current; + if (current.attempts.some((attempt) => attempt.invocationId === input.invocationId)) return current; const receipt = Object.freeze({ attemptedAt: input.at, channel: 'next-event' as const, @@ -214,10 +311,10 @@ const transitionAdmission = ( state: 'attempted', }); } - case 'attempted': case 'expired': case 'unavailable': case 'withdrawn': + case 'acknowledged': return current; default: { const exhaustive: never = current.state; @@ -252,6 +349,7 @@ const transitionExposure = ( case 'expired': case 'unavailable': case 'withdrawn': + case 'acknowledged': return notice; default: { const exhaustive: never = notice.state; @@ -311,6 +409,19 @@ export const agentNoticeStateDefinition = ( })), }; } + case 'acknowledged': + return { + notices: state.notices.map((notice) => transitionAcknowledgement(notice, event.payload)), + }; + case 'availability-signalled': { + const noticeIds = new Set(event.payload.noticeIds); + return { + notices: state.notices.map((notice) => transitionAvailability(notice, { + at: event.payload.at, + noticeIds, + })), + }; + } default: { const exhaustive: never = event; return exhaustive; diff --git a/packages/rsc-runtime/tests/notices-ledger.test.ts b/packages/rsc-runtime/tests/notices-ledger.test.ts index e26d40492..ef56fcae1 100644 --- a/packages/rsc-runtime/tests/notices-ledger.test.ts +++ b/packages/rsc-runtime/tests/notices-ledger.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from '@rstest/core'; import { AGENT_NOTICE_STATES, AgentNoticeError, + selectNoticeDeliveryRoutes, agentNoticeStateDefinition, createAgentNoticeLedger, type AgentNoticeAuthorizationRequest, @@ -68,10 +69,13 @@ describe('notice state taxonomy', () => { 'expired', 'unavailable', 'withdrawn', + 'acknowledged', ]); + // 'delivered' stays out until a pinned host supplies cross-actor delivery + // evidence (2026-09-02 survey on #99); 'read'/'available' remain receipts. expect(AGENT_NOTICE_STATES).not.toContain('delivered'); expect(AGENT_NOTICE_STATES).not.toContain('read'); - expect(AGENT_NOTICE_STATES).not.toContain('acknowledged'); + expect(AGENT_NOTICE_STATES).not.toContain('available'); const label = (state: AgentNoticeState): string => { switch (state) { @@ -80,6 +84,7 @@ describe('notice state taxonomy', () => { case 'expired': case 'unavailable': case 'withdrawn': + case 'acknowledged': return state; default: { const exhaustive: never = state; @@ -696,3 +701,172 @@ describe('next-event delivery', () => { await driver.close(); }); }); + +describe('notice delivery routing receipts (#99 stage 4)', () => { + const publishTo = async ( + ledger: Awaited>['ledger'], + extras: Record = {}, + ) => run(ledger, { + actorId: 'publisher', + id: 'publish-1', + kind: 'tool', + startedAt: '2026-09-01T19:00:00.000Z', + }, async () => (await agent()).notices!.publish({ + content: document('coordinate'), + priority: 'high', + recipient: { actor: { id: 'recipient' } }, + ...extras, + }, { idempotencyKey: 'publish:stage4' })); + + it('acknowledges a notice only for its recipient and records the invocation', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + + await expect(run(ledger, { + actorId: 'intruder', + id: 'ack-wrong', + kind: 'event', + startedAt: '2026-09-01T19:01:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id))) + .rejects.toMatchObject({ code: 'unauthorized' }); + + const acknowledged = await run(ledger, { + actorId: 'recipient', + id: 'ack-1', + kind: 'event', + startedAt: '2026-09-01T19:02:00.000Z', + }, async () => (await agent()).notices!.acknowledge(published.notice.id)); + expect(acknowledged.state).toBe('acknowledged'); + expect(acknowledged.acknowledgement).toEqual({ + acknowledgedAt: '2026-09-01T19:02:00.000Z', + invocationId: 'ack-1', + }); + + await expect(run(ledger, { + actorId: 'recipient', + id: 'ack-unknown', + kind: 'event', + startedAt: '2026-09-01T19:03:00.000Z', + }, async () => (await agent()).notices!.acknowledge('notice_missing'))) + .rejects.toMatchObject({ code: 'invalid-input' }); + await driver.close(); + }); + + it('re-attempts on later admitted events until the retry budget is exhausted', async () => { + const { driver, ledger } = await openLedger(); + await publishTo(ledger, { retryBudget: 2 }); + + const admit = (id: string, startedAt: string) => run(ledger, { + actorId: 'recipient', + id, + kind: 'event', + startedAt, + }, async () => (await agent()).notices!.read()); + + const first = await admit('event-1', '2026-09-01T19:05:00.000Z'); + expect(first).toHaveLength(1); + const second = await admit('event-2', '2026-09-01T19:06:00.000Z'); + expect(second).toHaveLength(1); + const third = await admit('event-3', '2026-09-01T19:07:00.000Z'); + expect(third).toHaveLength(0); + + const snapshot = await ledger.read(); + expect(snapshot.notices[0]?.attempts.map((attempt) => attempt.invocationId)) + .toEqual(['event-1', 'event-2']); + expect(snapshot.notices[0]?.state).toBe('attempted'); + await driver.close(); + }); + + it('defaults to a single attempt when no retry budget is published', async () => { + const { driver, ledger } = await openLedger(); + await publishTo(ledger); + const admit = (id: string, startedAt: string) => run(ledger, { + actorId: 'recipient', + id, + kind: 'event', + startedAt, + }, async () => (await agent()).notices!.read()); + expect(await admit('event-1', '2026-09-01T19:05:00.000Z')).toHaveLength(1); + expect(await admit('event-2', '2026-09-01T19:06:00.000Z')).toHaveLength(0); + const persisted = (await ledger.read()).notices[0]; + expect(persisted?.retryBudget).toBeUndefined(); + expect(persisted?.attempts).toHaveLength(1); + await driver.close(); + }); + + it('holds admission until nextAttemptAt without implying a timer', async () => { + const { driver, ledger } = await openLedger(); + await publishTo(ledger, { nextAttemptAt: '2026-09-01T20:00:00.000Z' }); + const admit = (id: string, startedAt: string) => run(ledger, { + actorId: 'recipient', + id, + kind: 'event', + startedAt, + }, async () => (await agent()).notices!.read()); + expect(await admit('early', '2026-09-01T19:30:00.000Z')).toHaveLength(0); + expect((await ledger.read()).notices[0]?.state).toBe('pending'); + expect(await admit('due', '2026-09-01T20:00:00.000Z')).toHaveLength(1); + await driver.close(); + }); + + it('records wire-level availability as a receipt without claiming delivery', async () => { + const { driver, ledger } = await openLedger(); + const published = await publishTo(ledger); + const snapshot = await ledger.signalAvailability({ + at: '2026-09-01T19:04:00.000Z', + idempotencyKey: 'availability:1', + noticeIds: [published.notice.id], + }); + const notice = snapshot.notices[0]; + expect(notice?.state).toBe('pending'); + expect(notice?.availability).toEqual({ + channel: 'mcp-resource-updated', + count: 1, + firstAt: '2026-09-01T19:04:00.000Z', + lastAt: '2026-09-01T19:04:00.000Z', + }); + await expect(ledger.signalAvailability({ + at: '2026-09-01T19:05:00.000Z', + idempotencyKey: 'availability:empty', + noticeIds: [], + })).rejects.toMatchObject({ code: 'invalid-input' }); + await driver.close(); + }); +}); + +describe('notice delivery route selection', () => { + const advertisement = (overrides: Partial> = {}) => ({ + 'current-response': { state: 'supported' as const }, + 'directed-push': { reason: '2026-09-02: no pinned host documents a directed cross-actor push API.', state: 'unavailable' as const }, + 'host-toast': { reason: '2026-09-02: no pinned host documents a plugin-facing toast API.', state: 'unavailable' as const }, + 'mcp-inbox': { state: 'supported' as const }, + 'mcp-resource-updated': { state: 'supported' as const }, + 'next-event': { state: 'supported' as const }, + ...overrides, + }); + + it('selects every supported cross-request route in stable preference order', () => { + expect(selectNoticeDeliveryRoutes(advertisement())).toEqual({ + kind: 'selected', + routes: ['mcp-resource-updated', 'mcp-inbox', 'next-event'], + }); + }); + + it('returns the typed unavailable outcome when no cross-request route is supported', () => { + const unavailable = { reason: '2026-09-02: unavailable.', state: 'unavailable' as const }; + expect(selectNoticeDeliveryRoutes(advertisement({ + 'mcp-inbox': unavailable, + 'mcp-resource-updated': unavailable, + 'next-event': unavailable, + }))).toEqual({ kind: 'unavailable', reason: 'no-supported-cross-request-route' }); + }); + + it('fails closed on incomplete advertisements and reasonless unavailability', () => { + const missing = advertisement(); + delete (missing as Record)['host-toast']; + expect(() => selectNoticeDeliveryRoutes(missing as never)).toThrow(/missing route host-toast/u); + expect(() => selectNoticeDeliveryRoutes(advertisement({ + 'directed-push': { reason: ' ', state: 'unavailable' }, + }))).toThrow(/requires a dated reason/u); + }); +});