Merge remote-tracking branch 'origin/master' into xtr/agent-loop-message-machine
# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
116
packages/telemetry/session-telemetry/tests/redact.spec.ts
Normal file
116
packages/telemetry/session-telemetry/tests/redact.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The `telemetry/record` waterfall contract: pass-through when no listener is
|
||||
* mounted, listener stacking and replacement, ops-record coverage, the
|
||||
* untouched canonical log, and the fail-closed containment of a throwing rule.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
TelemetryCoordinator,
|
||||
type TelemetryBackend,
|
||||
type TelemetryRecord,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const FIXTURE_SECRET = 'sk-fixture1234567890'
|
||||
|
||||
class CollectingBackend implements TelemetryBackend {
|
||||
records: TelemetryRecord[] = []
|
||||
emit(record: TelemetryRecord): void {
|
||||
this.records.push(record)
|
||||
}
|
||||
async shutdown(): Promise<void> {}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const backend = new CollectingBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
return { ctx, backend, fiber }
|
||||
}
|
||||
|
||||
describe('telemetry/record waterfall', () => {
|
||||
it('passes records through unchanged when no listener is mounted', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = ctx.sessions.create(SessionId('w'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const body = backend.records[0]!.body as { content: { text: string }[] }
|
||||
expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`)
|
||||
})
|
||||
|
||||
it('applies a mounted rule to every outbound record, ops records included', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
const record = next()
|
||||
return { ...record, body: { scrubbed: true } }
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('rule'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(backend.records[0]!.body).toEqual({ scrubbed: true })
|
||||
// The dispose-time shutdown ops record passes through the same waterfall.
|
||||
await fiber.dispose()
|
||||
const ops = backend.records.filter(record => record.channel === 'ops')
|
||||
expect(ops).toHaveLength(1)
|
||||
expect(ops[0]!.body).toEqual({ scrubbed: true })
|
||||
})
|
||||
|
||||
it('keeps the canonical log untouched by a mounted rule', async () => {
|
||||
const { ctx } = await setup()
|
||||
ctx.on('telemetry/record', (_record, next) => ({ ...next(), body: null }))
|
||||
const session = ctx.sessions.create(SessionId('log'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const logged = session.events[0]!.data as { content: { text: string }[] }
|
||||
expect(logged.content[0]!.text).toBe(FIXTURE_SECRET)
|
||||
})
|
||||
|
||||
it('stacks listeners outermost-first around next()', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const order: string[] = []
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
order.push('outer-before')
|
||||
const record = next()
|
||||
order.push('outer-after')
|
||||
return { ...record, attributes: { ...record.attributes, outer: 1 } }
|
||||
})
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
order.push('inner')
|
||||
const record = next()
|
||||
return { ...record, attributes: { ...record.attributes, inner: 1 } }
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('stack'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(order).toEqual(['outer-before', 'inner', 'outer-after'])
|
||||
expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 })
|
||||
})
|
||||
|
||||
it('a listener that skips next() replaces everything beneath it', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const inner = { called: false }
|
||||
ctx.on('telemetry/record', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord))
|
||||
ctx.on('telemetry/record', (_record, next) => {
|
||||
inner.called = true
|
||||
return next()
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('veto'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(backend.records[0]!.body).toBe('replaced')
|
||||
expect(inner.called).toBe(false)
|
||||
})
|
||||
|
||||
it('a throwing rule withholds the record fail-closed without disturbing the log', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
ctx.on('telemetry/record', () => {
|
||||
throw new Error('rule exploded')
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('closed'))
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(backend.records).toHaveLength(0)
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
425
packages/telemetry/session-telemetry/tests/telemetry.spec.ts
Normal file
425
packages/telemetry/session-telemetry/tests/telemetry.spec.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Coordinator semantics against a bare fake backend — the RFC's named unit
|
||||
* tier for the seam: adoption (fresh, seeded, re-adoption via the handoff
|
||||
* cursor), the fixed chunk projection, deep-copy isolation, turn-latency and
|
||||
* dispose-ordering pins, failure containment, and the `agent/error` relay.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Test-only merged event proving unknown types flow through unchanged.
|
||||
* @mode emit
|
||||
* @param payload - opaque test payload
|
||||
*/
|
||||
'telemetry-test/opaque': { payload: { nested: string[] } }
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBackend implements TelemetryBackend {
|
||||
records: TelemetryRecord[] = []
|
||||
calls: string[] = []
|
||||
emitError: Error | undefined
|
||||
rejectSeq: number | undefined
|
||||
shutdownError: Error | undefined
|
||||
shutdownResolved = false
|
||||
|
||||
emit(record: TelemetryRecord): void {
|
||||
if (this.emitError) throw this.emitError
|
||||
if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) {
|
||||
throw new Error(`backend rejected seq ${this.rejectSeq}`)
|
||||
}
|
||||
this.records.push(record)
|
||||
this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`)
|
||||
}
|
||||
|
||||
flush = vi.fn()
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.calls.push('shutdown')
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
if (this.shutdownError) throw this.shutdownError
|
||||
this.shutdownResolved = true
|
||||
}
|
||||
|
||||
ledger(): TelemetryRecord[] {
|
||||
return this.records.filter(r => r.channel === 'ledger')
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(backend: FakeBackend = new FakeBackend()) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
return { ctx, backend, fiber }
|
||||
}
|
||||
|
||||
function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session {
|
||||
return ctx.sessions.create(SessionId(id), { meta: {} })
|
||||
}
|
||||
|
||||
function appendTurn(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('TelemetryCoordinator capture', () => {
|
||||
it('hands every appended event over with envelope identity and cloned body', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx, 'cap')
|
||||
appendTurn(session)
|
||||
|
||||
const start = backend.ledger()[0]!
|
||||
const message = backend.ledger()[1]!
|
||||
expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 })
|
||||
expect(start.time).toBe(session.events[0]!.time)
|
||||
expect(start.severity).toBe('info')
|
||||
expect(message.attributes['event.seq']).toBe(1)
|
||||
// Deep-copy isolation: mutating the handed-off body never reaches the log.
|
||||
;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered'
|
||||
const logged = session.events[1] as SessionEvent<'user/message'>
|
||||
expect(logged.data.content[0]).toMatchObject({ text: 'hello' })
|
||||
})
|
||||
|
||||
it('stamps header facts on every record when present', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const parent = SessionId('parent')
|
||||
const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } })
|
||||
appendTurn(session)
|
||||
for (const record of backend.ledger()) {
|
||||
expect(record.attributes['session.cwd']).toBe('/tmp/proj')
|
||||
expect(record.attributes['session.parent_id']).toBe('parent')
|
||||
}
|
||||
})
|
||||
|
||||
it('maps outcome flags to severity, unknown types falling through as info', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' })
|
||||
session.append('telemetry-test/opaque', { payload: { nested: [] } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } })
|
||||
const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
|
||||
expect(severities).toEqual([
|
||||
['turn/start', 'info'],
|
||||
['tool/result', 'error'],
|
||||
['tool/result', 'info'],
|
||||
['telemetry-test/opaque', 'info'],
|
||||
['turn/end', 'error'],
|
||||
])
|
||||
})
|
||||
|
||||
it('passes unknown merged event types through unchanged', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } })
|
||||
const record = backend.ledger()[0]!
|
||||
expect(record.attributes['event.type']).toBe('telemetry-test/opaque')
|
||||
expect(record.severity).toBe('info')
|
||||
expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } })
|
||||
})
|
||||
|
||||
it('ships only the first chunk of each (turn, step), per session', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const a = liveSession(ctx, 'a')
|
||||
const b = liveSession(ctx, 'b')
|
||||
const chunk = (s: Session, turn: number, step: number, text: string) =>
|
||||
s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } })
|
||||
chunk(a, 1, 1, 'a11-first')
|
||||
chunk(a, 1, 1, 'a11-second')
|
||||
chunk(a, 1, 2, 'a12-first')
|
||||
chunk(b, 1, 1, 'b11-first')
|
||||
chunk(b, 1, 1, 'b11-second')
|
||||
const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text])
|
||||
expect(shipped).toEqual([
|
||||
['a', 'a11-first'],
|
||||
['a', 'a12-first'],
|
||||
['b', 'b11-first'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator adoption', () => {
|
||||
it('starts export at the construction boundary: seeded history never re-exports', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'seed-parent')
|
||||
appendTurn(parent)
|
||||
const child = ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} })
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
// The live parent (no constructor seed) replays in full; the child's
|
||||
// inherited prefix already left the process under another identity (the
|
||||
// parent's id here; the same id in a previous process for a resume) and
|
||||
// must not be re-exported — only its live suffix ships.
|
||||
const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']])
|
||||
expect(seqs).toEqual(expect.arrayContaining([['seed-parent', 0], ['seed-parent', 1]]))
|
||||
expect(seqs.filter(([id]) => id === 'seeded')).toEqual([])
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]))
|
||||
.toEqual(expect.arrayContaining([['seeded', 2]]))
|
||||
})
|
||||
|
||||
it('resume shape: a full-log seed exports nothing yet still rebuilds the chunk projection', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const donor = ctx.sessions.create(SessionId('donor'), { meta: {} })
|
||||
donor.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
donor.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
|
||||
const resumed = ctx.sessions.create(SessionId('resumed'), { seed: [...donor.events], meta: {} })
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
const ofResumed = () => backend.ledger()
|
||||
.filter(r => r.attributes['session.id'] === 'resumed')
|
||||
.map(r => r.attributes['event.seq'])
|
||||
expect(ofResumed()).toEqual([])
|
||||
// The seed fed the projection: the (turn 1, step 1) first chunk already
|
||||
// shipped from the original process, so its continuation is re-dropped…
|
||||
resumed.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'continuation' } })
|
||||
expect(ofResumed()).toEqual([])
|
||||
// …while a new step's first chunk exports normally.
|
||||
resumed.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'next step' } })
|
||||
expect(ofResumed()).toEqual([3])
|
||||
})
|
||||
|
||||
it('stamps session.seed_length from the header so receivers can stitch fork streams', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const parent = liveSession(ctx, 'stitch-parent')
|
||||
appendTurn(parent)
|
||||
const child = ctx.sessions.create(SessionId('stitch-child'), {
|
||||
seed: [...parent.events],
|
||||
meta: { parentSession: SessionId('stitch-parent'), seedLength: 2 },
|
||||
})
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
child.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const record = backend.ledger().find(r => r.attributes['session.id'] === 'stitch-child')!
|
||||
expect(record.attributes['session.parent_id']).toBe('stitch-parent')
|
||||
expect(record.attributes['session.seed_length']).toBe(2)
|
||||
})
|
||||
|
||||
it('adopts exactly once when created fires after the sweep', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// The enter/announce window: prepare+enter puts the session in the store
|
||||
// (visible to the constructor sweep) before `session/created` fires, so a
|
||||
// coordinator loaded inside that window sees the session twice — sweep
|
||||
// first, created second. The second adoption must be a no-op.
|
||||
const session = ctx.sessions.prepare(SessionId('overlap'))
|
||||
appendTurn(session)
|
||||
ctx.sessions.enter(session)
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
ctx.sessions.announce(session)
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const { ctx, fiber } = await setup(backend)
|
||||
const session = liveSession(ctx, 'hmr')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } })
|
||||
expect(backend.ledger()).toHaveLength(2)
|
||||
|
||||
await fiber.dispose()
|
||||
// The reload window: appends while no telemetry listener is registered.
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const second = new FakeBackend()
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry-2',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, second),
|
||||
})
|
||||
// Only the window events past the cursor are re-handed, and the mid-step
|
||||
// continuation is re-dropped because ≤cursor events rebuilt the projection.
|
||||
expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
|
||||
})
|
||||
|
||||
it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx, 'partial')
|
||||
appendTurn(session)
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The backend rejects exactly the middle historical event: fail-closed
|
||||
// must withhold THAT record only — an adoption replay that dies on the
|
||||
// first contained failure would silently skip the rest of the log while
|
||||
// the session stays marked adopted.
|
||||
backend.rejectSeq = 1
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2])
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-hands the full log when no cursor survived (fresh session object)', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = liveSession(ctx, 'fresh')
|
||||
appendTurn(session)
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TelemetryCoordinator lifecycle and containment', () => {
|
||||
it('forwards session/flush as a hint without awaiting backend work', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx)
|
||||
let settled = false
|
||||
backend.flush.mockImplementation(() => {
|
||||
// The backend may kick off arbitrary async work; the loop's parallel must not wait for it.
|
||||
void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true })
|
||||
})
|
||||
await ctx.parallel('session/flush', session)
|
||||
expect(backend.flush).toHaveBeenCalledTimes(1)
|
||||
expect(settled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores flush hints for sessions it never adopted', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} })
|
||||
await ctx.parallel('session/flush', stranger)
|
||||
expect(backend.flush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits no marker for a session whose announcement was vetoed before adoption', async () => {
|
||||
const backend = new FakeBackend()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
// A listener registered BEFORE the coordinator vetoes publication: the
|
||||
// store still emits the paired `session/disposed` for rollback, but the
|
||||
// coordinator never saw `session/created` — a marker for a session the
|
||||
// receiver saw no activity from would be noise, not signal.
|
||||
ctx.on('session/created', () => {
|
||||
throw new Error('vetoed by an earlier listener')
|
||||
})
|
||||
await ctx.plugin({
|
||||
name: 'fake-telemetry',
|
||||
inject: ['sessions'],
|
||||
apply: (inner: Context) => void new TelemetryCoordinator(inner, backend),
|
||||
})
|
||||
expect(() => ctx.sessions.create(SessionId('vetoed'), { meta: {} })).toThrow('vetoed')
|
||||
expect(backend.records.filter(r => r.channel === 'ops')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
liveSession(ctx, 's1')
|
||||
liveSession(ctx, 's2')
|
||||
await fiber.dispose()
|
||||
expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown'])
|
||||
expect(backend.shutdownResolved).toBe(true)
|
||||
const ops = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2'])
|
||||
expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true)
|
||||
expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true)
|
||||
})
|
||||
|
||||
it('emits the shutdown marker at the session’s own disposal edge, then retires it', async () => {
|
||||
const { ctx, backend, fiber } = await setup()
|
||||
liveSession(ctx, 'survivor')
|
||||
// A session owned by its own fiber: disposing the fiber detaches it from
|
||||
// the store and emits `session/disposed` — the authoritative termination
|
||||
// edge. The marker must ride THAT edge (receivers classify a session with
|
||||
// activity and no marker as crashed, so a normally closed session in a
|
||||
// long-running host must not look like a crash), and the session retires
|
||||
// from the adopted set so unload neither retains it nor re-marks it.
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.sessions.create(SessionId('ephemeral'), { meta: {} })
|
||||
}, { inject: ['sessions'] }))
|
||||
await owner.dispose()
|
||||
const atEdge = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(atEdge.map(r => r.attributes['session.id'])).toEqual(['ephemeral'])
|
||||
expect(atEdge[0]!.attributes['telemetry.op']).toBe('shutdown')
|
||||
await fiber.dispose()
|
||||
const ops = backend.records.filter(r => r.channel === 'ops')
|
||||
expect(ops.map(r => r.attributes['session.id'])).toEqual(['ephemeral', 'survivor'])
|
||||
})
|
||||
|
||||
it('warns instead of throwing when backend shutdown fails', async () => {
|
||||
const backend = new FakeBackend()
|
||||
backend.shutdownError = new Error('exporter unreachable')
|
||||
const { ctx, fiber } = await setup(backend)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
liveSession(ctx)
|
||||
await expect(fiber.dispose()).resolves.not.toThrow()
|
||||
expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('contains emit failures: the append succeeds and capture heals', async () => {
|
||||
const { ctx, backend } = await setup()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
const session = liveSession(ctx)
|
||||
backend.emitError = new Error('backend broke')
|
||||
expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
backend.emitError = undefined
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Error values', new TypeError('adapter exploded'), 'TypeError', 'adapter exploded'],
|
||||
['non-Error values', 'plain failure', 'Error', 'plain failure'],
|
||||
])('relays agent/error %s as an ops record with normalized identity', async (_label, error, name, message) => {
|
||||
const { ctx, backend } = await setup()
|
||||
const session = liveSession(ctx, 'erring')
|
||||
// Only the members the relay reads; the full Agent surface is irrelevant here.
|
||||
const agent = { id: 'agent-1', session } as Agent
|
||||
ctx.emit('agent/error', agent, 3, 2, error)
|
||||
const record = backend.records.find(r => r.channel === 'ops')!
|
||||
expect(record.severity).toBe('error')
|
||||
expect(record.attributes).toMatchObject({
|
||||
'telemetry.op': 'agent-error',
|
||||
'session.id': 'erring',
|
||||
'agent.id': 'agent-1',
|
||||
'error.name': name,
|
||||
turn: 3,
|
||||
step: 2,
|
||||
})
|
||||
expect(record.body).toEqual({ name, message })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user