fix: preserve message occurrence contracts
This commit is contained in:
@@ -121,12 +121,12 @@ Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/t
|
|||||||
|
|
||||||
### `agent/inbox/discard` — emit
|
### `agent/inbox/discard` — emit
|
||||||
|
|
||||||
Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item.
|
Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal, emits this after `agent/cancel-requested` when applicable and before aborting the active work. Fires once per drop with every dropped item.
|
||||||
|
|
||||||
```ts cordis-catalog
|
```ts cordis-catalog
|
||||||
/**
|
/**
|
||||||
* Pending inbox items were dropped without delivering them, so every
|
* Pending inbox items were dropped without delivering them, so every
|
||||||
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
|
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR
|
||||||
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
||||||
* emits this after `agent/cancel-requested` when applicable and before
|
* emits this after `agent/cancel-requested` when applicable and before
|
||||||
* aborting the active work. Fires once per drop with every dropped item.
|
* aborting the active work. Fires once per drop with every dropped item.
|
||||||
|
|||||||
@@ -1042,8 +1042,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
|||||||
name: 'agent/inbox/discard',
|
name: 'agent/inbox/discard',
|
||||||
mode: 'emit',
|
mode: 'emit',
|
||||||
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[]): void',
|
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[]): void',
|
||||||
jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||||
summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.',
|
summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'agent/inbox/enqueue',
|
name: 'agent/inbox/enqueue',
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ declare module 'cordis' {
|
|||||||
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: UserMessage): void
|
||||||
/**
|
/**
|
||||||
* Pending inbox items were dropped without delivering them, so every
|
* Pending inbox items were dropped without delivering them, so every
|
||||||
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
|
* enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR
|
||||||
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
* `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,
|
||||||
* emits this after `agent/cancel-requested` when applicable and before
|
* emits this after `agent/cancel-requested` when applicable and before
|
||||||
* aborting the active work. Fires once per drop with every dropped item.
|
* aborting the active work. Fires once per drop with every dropped item.
|
||||||
|
|||||||
@@ -224,8 +224,16 @@ function assertToolResultRewrite(
|
|||||||
}
|
}
|
||||||
const originalRest = { ...original.data } as Record<string, unknown>
|
const originalRest = { ...original.data } as Record<string, unknown>
|
||||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||||
originalRest['message'] = { ...original.data.message, content: null }
|
const originalResult = original.data.message.content[0]
|
||||||
replacementRest['message'] = { ...event.data.message, content: null }
|
const replacementResult = event.data.message.content[0]
|
||||||
|
originalRest['message'] = {
|
||||||
|
...original.data.message,
|
||||||
|
content: [{ ...originalResult, content: null }],
|
||||||
|
}
|
||||||
|
replacementRest['message'] = {
|
||||||
|
...event.data.message,
|
||||||
|
content: [{ ...replacementResult, content: null }],
|
||||||
|
}
|
||||||
if (!isDeepEqualJson(originalRest, replacementRest)) {
|
if (!isDeepEqualJson(originalRest, replacementRest)) {
|
||||||
throw new Error('tool/result surface replacement may change only content')
|
throw new Error('tool/result surface replacement may change only content')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,30 @@ describe('foldSurface tool-result rewrites', () => {
|
|||||||
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
expect(() => foldSurface(events)).toThrow(/may change only content/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['toolCallId', { toolCallId: CallId('changed') }],
|
||||||
|
['isError', { isError: true }],
|
||||||
|
] as const)('rejects a replacement that changes the result block %s', (_field, patch) => {
|
||||||
|
const original = toolResultEvent(0, 'original')
|
||||||
|
const data = original.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
|
||||||
|
const result = data.message.content[0]
|
||||||
|
const replacement = {
|
||||||
|
...original,
|
||||||
|
seq: 1,
|
||||||
|
time: 1,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
message: freezeMessage({
|
||||||
|
...data.message,
|
||||||
|
content: [{ ...result, ...patch }] as [typeof result],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||||
|
sourceEventSeqs: [0],
|
||||||
|
} as SessionEvent
|
||||||
|
expect(() => foldSurface([original, replacement])).toThrow(/may change only content/)
|
||||||
|
})
|
||||||
|
|
||||||
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
|
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
|
||||||
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
|
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
|
||||||
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
|
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
|
||||||
|
|||||||
@@ -421,29 +421,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-session inbox mirror serving the mux-open queue snapshot (the same
|
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
|
||||||
* refresh-recovery baseline as pending questions). Keyed by the stable
|
* (the same refresh-recovery baseline as pending questions). Each terminal
|
||||||
* MessageId: every enqueued id receives exactly one terminal
|
* inbox event retires one matching occurrence, so repeated sends of the same
|
||||||
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
|
* identified message remain visible until every occurrence is claimed.
|
||||||
* the mirror needs no consumption heuristics or sweeps beyond disposal.
|
|
||||||
*/
|
*/
|
||||||
const queuedMirror = new Map<SessionId, Map<MessageId, { message: UserMessage; steering: boolean }>>()
|
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
|
||||||
ctx.effect(() => {
|
ctx.effect(() => {
|
||||||
const retire = (agent: Agent, id: MessageId): void => {
|
const retire = (agent: Agent, id: MessageId): void => {
|
||||||
const entries = queuedMirror.get(agent.id)
|
const entries = queuedMirror.get(agent.id)
|
||||||
if (entries === undefined) return
|
if (entries === undefined) return
|
||||||
entries.delete(id)
|
const index = entries.findIndex(entry => entry.message.id === id)
|
||||||
if (entries.size === 0) queuedMirror.delete(agent.id)
|
if (index !== -1) entries.splice(index, 1)
|
||||||
|
if (entries.length === 0) queuedMirror.delete(agent.id)
|
||||||
}
|
}
|
||||||
const disposers = [
|
const disposers = [
|
||||||
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
|
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
|
||||||
let entries = queuedMirror.get(agent.id)
|
let entries = queuedMirror.get(agent.id)
|
||||||
if (entries === undefined) {
|
if (entries === undefined) {
|
||||||
entries = new Map<MessageId, { message: UserMessage; steering: boolean }>()
|
entries = []
|
||||||
queuedMirror.set(agent.id, entries)
|
queuedMirror.set(agent.id, entries)
|
||||||
}
|
}
|
||||||
const steering = placement === 'steering'
|
const steering = placement === 'steering'
|
||||||
entries.set(message.id, { message, steering })
|
entries.push({ message, steering })
|
||||||
broadcast({
|
broadcast({
|
||||||
type: 'session/queued',
|
type: 'session/queued',
|
||||||
sessionId: agent.id,
|
sessionId: agent.id,
|
||||||
@@ -1119,7 +1119,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
// in arrival order per session; a reconnecting client rebuilds its
|
// in arrival order per session; a reconnecting client rebuilds its
|
||||||
// queue view from these alone.
|
// queue view from these alone.
|
||||||
for (const [sessionId, entries] of queuedMirror) {
|
for (const [sessionId, entries] of queuedMirror) {
|
||||||
for (const entry of entries.values()) {
|
for (const entry of entries) {
|
||||||
queue.push(frame({
|
queue.push(frame({
|
||||||
type: 'session/queued',
|
type: 'session/queued',
|
||||||
sessionId,
|
sessionId,
|
||||||
|
|||||||
@@ -293,6 +293,24 @@ describe('session/queued frames', () => {
|
|||||||
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retires repeated sends of one message identity by occurrence', async () => {
|
||||||
|
const ctx = await harness()
|
||||||
|
const api = createApiProxy(ctx, DEFAULTS)
|
||||||
|
const agent = stubAgent(ctx)
|
||||||
|
const repeated = inboxMessage('m-repeat', 'same prompt')
|
||||||
|
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
|
||||||
|
ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued')
|
||||||
|
ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'))
|
||||||
|
ctx.emit('agent/inbox/dequeue', agent, repeated)
|
||||||
|
|
||||||
|
const abort = new AbortController()
|
||||||
|
const frames = await collect<MuxFrame>(
|
||||||
|
api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort)
|
||||||
|
expect(frames.filter(f => f.type === 'session/queued')).toEqual([
|
||||||
|
{ type: 'session/queued', sessionId: agent.id, message: repeated, steering: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
it('retires mirror entries on a batch discard (cancel path)', async () => {
|
||||||
const ctx = await harness()
|
const ctx = await harness()
|
||||||
const api = createApiProxy(ctx, DEFAULTS)
|
const api = createApiProxy(ctx, DEFAULTS)
|
||||||
|
|||||||
Reference in New Issue
Block a user