From a1795bfb3cdc597e9493489fb0dc7fa1438a62cd Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 17:55:21 +0800 Subject: [PATCH 1/2] style: normalize Agent Trace source and documentation --- README.md | 6 +- scripts/check-public-naming.mjs | 2 +- src/entry.ts | 25 +++-- src/index.ts | 176 +++++++++++++++++++------------ src/model.ts | 12 ++- src/react-view.tsx | 159 +++++++++++++++++++++++----- src/session-store.ts | 179 ++++++++++++++++++++------------ src/types.ts | 22 +++- src/unavailable-store.ts | 12 ++- test/architecture-guard.test.ts | 42 ++++---- test/model.test.ts | 40 ++++++- test/plugin.test.ts | 8 +- test/session-store.test.ts | 128 ++++++++++++++++++----- test/view.test.tsx | 82 +++++++++++---- 14 files changed, 634 insertions(+), 259 deletions(-) diff --git a/README.md b/README.md index e0f0287..14aebf1 100644 --- a/README.md +++ b/README.md @@ -57,9 +57,9 @@ unavailable, the Timeline shows no rows and never falls back to another source. ## Configuration -| Setting | Default | Values | Meaning | -| --- | --- | --- | --- | -| `timelineWindowSize` | `500` | 50-500 | Bound the in-memory and rendered Timeline window. | +| Setting | Default | Values | Meaning | +| -------------------- | ------- | ------ | ------------------------------------------------- | +| `timelineWindowSize` | `500` | 50-500 | Bound the in-memory and rendered Timeline window. | Configuration applies on restart so the owning Cordis fiber replaces page state cleanly. Page unmount and plugin deactivation own their disposers. diff --git a/scripts/check-public-naming.mjs b/scripts/check-public-naming.mjs index 9f46249..22fbe8c 100644 --- a/scripts/check-public-naming.mjs +++ b/scripts/check-public-naming.mjs @@ -1,4 +1,4 @@ -import { readFile, readdir } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/src/entry.ts b/src/entry.ts index 1509f0f..a9e562f 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -11,8 +11,16 @@ export const TRACE_SESSION_HEADER_ACTION = Object.freeze({ group: 'action' as const, order: 10 as const, item: Object.freeze({ - label: Object.freeze({ namespace: 'agent-trace-showcase', key: 'action.open', fallback: 'Open Agent Trace Timeline' }), - ariaLabel: Object.freeze({ namespace: 'agent-trace-showcase', key: 'action.open', fallback: 'Open Agent Trace Timeline' }), + label: Object.freeze({ + namespace: 'agent-trace-showcase', + key: 'action.open', + fallback: 'Open Agent Trace Timeline', + }), + ariaLabel: Object.freeze({ + namespace: 'agent-trace-showcase', + key: 'action.open', + fallback: 'Open Agent Trace Timeline', + }), icon: 'host:history' as const, route: Object.freeze({ id: 'session.timeline' as const }), routeBehavior: 'toggle' as const, @@ -24,10 +32,11 @@ export interface SessionHeaderEntryAdapter { } export const STRUCTURED_SESSION_HEADER_ENTRY: SessionHeaderEntryAdapter = Object.freeze({ - register: (ctx: Context, contribution: typeof TRACE_SESSION_HEADER_ACTION) => ctx.slots.register({ - name: contribution.surface, - id: contribution.id, - group: contribution.group, - order: contribution.order, - }, contribution.item), + register: (ctx: Context, contribution: typeof TRACE_SESSION_HEADER_ACTION) => + ctx.slots.register({ + name: contribution.surface, + id: contribution.id, + group: contribution.group, + order: contribution.order, + }, contribution.item), }) diff --git a/src/index.ts b/src/index.ts index 7cbc69c..6abac05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,11 @@ import { type CordisXPluginPresentation, type CordisXRouteDefinitionV2, } from 'cordisx/contracts' -import { STRUCTURED_SESSION_HEADER_ENTRY, TRACE_SESSION_HEADER_ACTION, type SessionHeaderEntryAdapter } from './entry.js' +import { + type SessionHeaderEntryAdapter, + STRUCTURED_SESSION_HEADER_ENTRY, + TRACE_SESSION_HEADER_ACTION, +} from './entry.js' import { SessionTraceStore } from './session-store.js' import { UnavailableTraceStore } from './unavailable-store.js' import { createTraceReactPage } from './react-view.js' @@ -22,31 +26,40 @@ function text(key: string, fallback: string) { return Object.freeze({ namespace: 'agent-trace-showcase', key, fallback } as const) } -export const presentation = Object.freeze({ - name: text('plugin.name', 'Agent Trace Showcase'), - description: text('plugin.description', 'Read-only Timeline of Host-projected Agent session events.'), -} satisfies CordisXPluginPresentation) - -export const TRACE_SESSION_PAGE_METADATA = Object.freeze({ - $schema: CORDISX_PAGE_SCHEMA_V3, - schemaVersion: 3, - id: 'session.timeline', - title: text('page.timeline.title', 'Agent Trace Timeline'), - description: text('page.timeline.description', 'Inspect the read-only Host projection for the active Agent session.'), - icon: 'host:history', - chrome: 'body-only', -} satisfies CordisXPageMetadataV3) - -export const TRACE_SESSION_ROUTE_DEFINITION = Object.freeze({ - $schema: CORDISX_ROUTE_SCHEMA_V2, - schemaVersion: 2, - id: 'session.timeline', - path: '/sessions/:sessionId/agent-trace', - outlet: 'session.content', - page: 'session.timeline', - title: text('route.timeline.title', 'Open Agent Trace'), - description: text('route.timeline.description', 'Open the read-only Agent Trace Timeline for the active session.'), -} satisfies CordisXRouteDefinitionV2<'session.content'>) +export const presentation = Object.freeze( + { + name: text('plugin.name', 'Agent Trace Showcase'), + description: text('plugin.description', 'Read-only Timeline of Host-projected Agent session events.'), + } satisfies CordisXPluginPresentation, +) + +export const TRACE_SESSION_PAGE_METADATA = Object.freeze( + { + $schema: CORDISX_PAGE_SCHEMA_V3, + schemaVersion: 3, + id: 'session.timeline', + title: text('page.timeline.title', 'Agent Trace Timeline'), + description: text( + 'page.timeline.description', + 'Inspect the read-only Host projection for the active Agent session.', + ), + icon: 'host:history', + chrome: 'body-only', + } satisfies CordisXPageMetadataV3, +) + +export const TRACE_SESSION_ROUTE_DEFINITION = Object.freeze( + { + $schema: CORDISX_ROUTE_SCHEMA_V2, + schemaVersion: 2, + id: 'session.timeline', + path: '/sessions/:sessionId/agent-trace', + outlet: 'session.content', + page: 'session.timeline', + title: text('route.timeline.title', 'Open Agent Trace'), + description: text('route.timeline.description', 'Open the read-only Agent Trace Timeline for the active session.'), + } satisfies CordisXRouteDefinitionV2<'session.content'>, +) export interface Config { readonly timelineWindowSize: number @@ -59,44 +72,59 @@ export const Config = Schema.object({ export const configApplies = 'restart' as const -const CORDISX_PLUGIN_MANIFEST_SCHEMA_V5 = 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v5.schema.json' as const -const SESSION_ROUTE_SCOPE = Object.freeze({ - kind: 'host-route-param', - routeId: TRACE_SESSION_ROUTE_DEFINITION.id, - param: 'sessionId', -} as const satisfies HostRouteSessionScopeBinding) +const CORDISX_PLUGIN_MANIFEST_SCHEMA_V5 = + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/plugin-manifest.v5.schema.json' as const +const SESSION_ROUTE_SCOPE = Object.freeze( + { + kind: 'host-route-param', + routeId: TRACE_SESSION_ROUTE_DEFINITION.id, + param: 'sessionId', + } as const satisfies HostRouteSessionScopeBinding, +) function sessionCapability(name: 'sessions.get' | 'sessions.read' | 'sessions.subscribe') { - return Object.freeze({ - name, - required: false, - scope: Object.freeze({ sessionIds: SESSION_ROUTE_SCOPE }), - rationale: Object.freeze({ - title: text('permission.sessions.title', 'Read this Agent session'), - description: text('permission.sessions.description', 'Read permission-filtered durable events for the Agent session in the active Host route.'), - feature: text('permission.sessions.feature', 'Shows the read-only Agent Trace Timeline.'), - deniedBehavior: text('permission.sessions.denied', 'The Timeline stays empty and reports that live Session data is unavailable.'), - }), - security: Object.freeze({ - dataUse: 'ephemeral', - retention: 'runtime', - externalTransfer: false, - } as const), - } as const) + return Object.freeze( + { + name, + required: false, + scope: Object.freeze({ sessionIds: SESSION_ROUTE_SCOPE }), + rationale: Object.freeze({ + title: text('permission.sessions.title', 'Read this Agent session'), + description: text( + 'permission.sessions.description', + 'Read permission-filtered durable events for the Agent session in the active Host route.', + ), + feature: text('permission.sessions.feature', 'Shows the read-only Agent Trace Timeline.'), + deniedBehavior: text( + 'permission.sessions.denied', + 'The Timeline stays empty and reports that live Session data is unavailable.', + ), + }), + security: Object.freeze( + { + dataUse: 'ephemeral', + retention: 'runtime', + externalTransfer: false, + } as const, + ), + } as const, + ) } -export const manifest = Object.freeze({ - $schema: CORDISX_PLUGIN_MANIFEST_SCHEMA_V5, - schemaVersion: 5, - id: 'agent-trace-showcase', - name: 'Agent Trace Showcase', - capabilities: Object.freeze([ - sessionCapability('sessions.get'), - sessionCapability('sessions.read'), - sessionCapability('sessions.subscribe'), - ]), - services: Object.freeze([]), -} as const) +export const manifest = Object.freeze( + { + $schema: CORDISX_PLUGIN_MANIFEST_SCHEMA_V5, + schemaVersion: 5, + id: 'agent-trace-showcase', + name: 'Agent Trace Showcase', + capabilities: Object.freeze([ + sessionCapability('sessions.get'), + sessionCapability('sessions.read'), + sessionCapability('sessions.subscribe'), + ]), + services: Object.freeze([]), + } as const, +) export type AgentTraceShowcaseConfig = Config @@ -109,8 +137,12 @@ export function createTraceShowcaseStore( routeSessionId?: string, sessions?: SessionRegistry, ): TraceShowcaseStore { - if (routeSessionId === undefined) return new UnavailableTraceStore(undefined, config.timelineWindowSize, 'host-session-id-unavailable') - if (sessions === undefined) return new UnavailableTraceStore(routeSessionId, config.timelineWindowSize, 'session-service-unavailable') + if (routeSessionId === undefined) { + return new UnavailableTraceStore(undefined, config.timelineWindowSize, 'host-session-id-unavailable') + } + if (sessions === undefined) { + return new UnavailableTraceStore(routeSessionId, config.timelineWindowSize, 'session-service-unavailable') + } return new SessionTraceStore(sessions, routeSessionId, config.timelineWindowSize) } @@ -123,7 +155,9 @@ export function installAgentTraceShowcase( ): void { const config = configFrom(rawConfig) ctx.i18n.define({ - namespace: 'agent-trace-showcase', locale: 'en', default: true, + namespace: 'agent-trace-showcase', + locale: 'en', + default: true, messages: { 'plugin.name': 'Agent Trace Showcase', 'plugin.description': 'Read-only Timeline of Host-projected Agent session events.', @@ -133,13 +167,15 @@ export function installAgentTraceShowcase( 'page.timeline.title': 'Agent Trace Timeline', 'page.timeline.description': 'Inspect the read-only Host projection for the active Agent session.', 'permission.sessions.title': 'Read this Agent session', - 'permission.sessions.description': 'Read permission-filtered durable events for the Agent session in the active Host route.', + 'permission.sessions.description': + 'Read permission-filtered durable events for the Agent session in the active Host route.', 'permission.sessions.feature': 'Shows the read-only Agent Trace Timeline.', 'permission.sessions.denied': 'The Timeline stays empty and reports that live Session data is unavailable.', }, }) ctx.i18n.define({ - namespace: 'agent-trace-showcase', locale: 'zh-CN', + namespace: 'agent-trace-showcase', + locale: 'zh-CN', messages: { 'plugin.name': 'Agent Trace 展示', 'plugin.description': '查看 Host 投影的只读 Agent 会话时间线。', @@ -157,7 +193,9 @@ export function installAgentTraceShowcase( ctx.pages.register( TRACE_SESSION_PAGE_METADATA, defineReactPage(createTraceReactPage(sessionId => { - if (sessionId === undefined) return new UnavailableTraceStore(undefined, config.timelineWindowSize, 'host-session-id-unavailable') + if (sessionId === undefined) { + return new UnavailableTraceStore(undefined, config.timelineWindowSize, 'host-session-id-unavailable') + } return createTraceShowcaseStore(config, sessionId, ctx.sessions) })), ) @@ -172,4 +210,8 @@ export function apply(ctx: AgentTraceContext, config: unknown): void { export type * from './types.js' export { projectSessionEvent, SessionTraceStore } from './session-store.js' export { UnavailableTraceStore } from './unavailable-store.js' -export { STRUCTURED_SESSION_HEADER_ENTRY, TRACE_SESSION_HEADER_ACTION, type SessionHeaderEntryAdapter } from './entry.js' +export { + type SessionHeaderEntryAdapter, + STRUCTURED_SESSION_HEADER_ENTRY, + TRACE_SESSION_HEADER_ACTION, +} from './entry.js' diff --git a/src/model.ts b/src/model.ts index 4c51c6b..1385e64 100644 --- a/src/model.ts +++ b/src/model.ts @@ -7,7 +7,9 @@ export interface TraceFilters { } export const EMPTY_FILTERS: TraceFilters = Object.freeze({ - lane: 'all', type: 'all', phase: 'all', + lane: 'all', + type: 'all', + phase: 'all', }) export function filterTraceEvents(events: readonly TraceEvent[], filters: TraceFilters): readonly TraceEvent[] { @@ -19,9 +21,11 @@ export function filterTraceEvents(events: readonly TraceEvent[], filters: TraceF } export function orderTraceEvents(events: readonly TraceEvent[], order: 'sequence' | 'time'): readonly TraceEvent[] { - return [...events].sort(order === 'sequence' - ? (left, right) => left.seq - right.seq - : (left, right) => Date.parse(left.recordedAt) - Date.parse(right.recordedAt) || left.seq - right.seq) + return [...events].sort( + order === 'sequence' + ? (left, right) => left.seq - right.seq + : (left, right) => Date.parse(left.recordedAt) - Date.parse(right.recordedAt) || left.seq - right.seq, + ) } export interface TraceTurnGroup { diff --git a/src/react-view.tsx b/src/react-view.tsx index 503d406..fb85caa 100644 --- a/src/react-view.tsx +++ b/src/react-view.tsx @@ -25,21 +25,49 @@ function Detail({ event }: { readonly event: TraceEvent | undefined }) { if (event === undefined) return Select an event to inspect its public projection. const definition = event.definitionResolution const fields: readonly [string, unknown][] = [ - ['Event', event.id], ['Sequence', event.seq], ['Recorded', event.recordedAt], - ['Type', event.type], ['Semantic', event.semanticType], ['Phase', event.phase], - ['Session', event.sessionId], ['Turn', event.turnId], ['Step', event.stepId], ['Item', event.itemId], - ['Message', event.messageId], ['Tool call', event.toolCallId], ['Context', event.contextId], - ['Entity', definition?.identity.agentId], ['Entity revision', definition?.identity.revision], - ['Entity digest', definition?.digest], ['Definition name', definition?.definition.name], + ['Event', event.id], + ['Sequence', event.seq], + ['Recorded', event.recordedAt], + ['Type', event.type], + ['Semantic', event.semanticType], + ['Phase', event.phase], + ['Session', event.sessionId], + ['Turn', event.turnId], + ['Step', event.stepId], + ['Item', event.itemId], + ['Message', event.messageId], + ['Tool call', event.toolCallId], + ['Context', event.contextId], + ['Entity', definition?.identity.agentId], + ['Entity revision', definition?.identity.revision], + ['Entity digest', definition?.digest], + ['Definition name', definition?.definition.name], ['Source', `${event.source.kind}:${event.source.id}`], ] - return <>{event.summary}
{fields.filter(([, value]) => value !== undefined).map(([label, value]) =>
{label}
{String(value)}
)}
{event.payload === undefined ? null :
{JSON.stringify(event.payload, null, 2)}
} + return ( + <> + + {event.summary} + +
+ {fields.filter(([, value]) => value !== undefined).map(([label, value]) => ( + +
{label}
+
{String(value)}
+
+ ))} +
+ {event.payload === undefined ? null :
{JSON.stringify(event.payload, null, 2)}
} + + ) } export function createTraceReactPage(createStore: (sessionId: string) => TraceShowcaseStore) { return function AgentTracePage(props: CordisXReactPageProps) { const sessionId = props.params.sessionId - if (typeof sessionId !== 'string' || sessionId.length === 0) throw new Error('Agent Trace route requires a Host-issued session id') + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error('Agent Trace route requires a Host-issued session id') + } const store = useMemo(() => createStore(sessionId), [sessionId]) useEffect(() => () => store.dispose(), [store]) const [, refresh] = useState(0) @@ -54,27 +82,102 @@ export function createTraceReactPage(createStore: (sessionId: string) => TraceSh const [selectedId, setSelectedId] = useState() const types = [...new Set(snapshot.events.map(event => event.type))].sort() const phases = [...new Set(snapshot.events.flatMap(event => event.phase === undefined ? [] : [event.phase]))].sort() - const events = orderTraceEvents(filterTraceEvents(snapshot.events, filters), order).slice(-snapshot.range.renderedLimit) + const events = orderTraceEvents(filterTraceEvents(snapshot.events, filters), order).slice( + -snapshot.range.renderedLimit, + ) const selected = snapshot.events.find(event => event.id === selectedId) - const update = (key: Key, value: TraceFilters[Key]) => setFilters(current => ({ ...current, [key]: value })) - return
-
- {snapshot.status.mode} - {' · '}{snapshot.status.completeness}{' · '}read-only{' · '}{snapshot.range.loaded}/{snapshot.range.totalAvailable ?? snapshot.range.loaded} events + const update = (key: Key, value: TraceFilters[Key]) => + setFilters(current => ({ ...current, [key]: value })) + return ( +
+ +
+ {snapshot.status.mode} + {' · '} + {snapshot.status.completeness} + {' · '}read-only{' · '} + {snapshot.range.loaded}/{snapshot.range.totalAvailable ?? snapshot.range.loaded} events +
+ + + + update('type', value)} + /> + update('lane', value as TraceFilters['lane'])} /> - update('phase', value as TraceFilters['phase'])} /> - - {snapshot.status.mode === 'unavailable' - ? - : events.length === 0 - ? - :
{groupTraceEvents(events).map(group => {group.events.map(event => setSelectedId(event.id)} onKeyDown={input => { if (input.key === 'Enter' || input.key === ' ') setSelectedId(event.id) }}>)})}
SeqLaneEventPhaseSummary
{group.label}
{event.seq}{event.lane}{event.semanticType}{event.phase ?? '—'}{event.summary} {event.source.label}
} - -
+ ) } } diff --git a/src/session-store.ts b/src/session-store.ts index cd9683c..2a2cc79 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -11,14 +11,7 @@ import type { UserMessage, } from '@cordisx/protocol/sessions/v1' import type { EntityDefinitionResolution } from '@cordisx/protocol/entities/v1' -import type { - TraceEvent, - TraceLane, - TracePhase, - TraceShowcaseStore, - TraceSnapshot, - TraceSource, -} from './types.js' +import type { TraceEvent, TraceLane, TracePhase, TraceShowcaseStore, TraceSnapshot, TraceSource } from './types.js' const SESSION_CONTRACT = 'cordisx.session-event/v1' const FIRST_CURSOR: SessionSeqCursor = -1 @@ -60,63 +53,98 @@ function toolSource(name: string): TraceSource { function lifecyclePhase(event: SessionEvent): TracePhase | undefined { switch (event.type) { case 'turn/start': - case 'step/start': return 'started' + case 'step/start': + return 'started' case 'turn/end': - case 'step/end': return 'completed' - case 'approval/asked': return 'requested' - case 'approval/decided': return event.data.outcome === 'unavailable' ? 'failed' : 'completed' - case 'session/end-seed': return 'closed' - default: return undefined + case 'step/end': + return 'completed' + case 'approval/asked': + return 'requested' + case 'approval/decided': + return event.data.outcome === 'unavailable' ? 'failed' : 'completed' + case 'session/end-seed': + return 'closed' + default: + return undefined } } function lane(event: SessionEvent): TraceLane { switch (event.type) { - case 'user/message': return 'input' + case 'user/message': + return 'input' case 'entity/definition-bound': - case 'agent/inbox/spliced': return 'injection' + case 'agent/inbox/spliced': + return 'injection' case 'tool/call': case 'tool/result': case 'approval/asked': - case 'approval/decided': return 'tools' - default: return 'model' + case 'approval/decided': + return 'tools' + default: + return 'model' } } function source(event: SessionEvent): TraceSource { switch (event.type) { - case 'user/message': return messageSource(event.sessionId, event.data) - case 'assistant/message': return modelSource(event.data.message.source.provider, event.data.message.source.model) - case 'request/header': return modelSource(event.data.header.config.provider, event.data.header.config.model) - case 'request/context': return modelSource(event.data.provider, event.data.model) - case 'tool/call': return toolSource(event.data.name) - case 'tool/result': return toolSource(event.data.message.source.callId) - default: return sessionSource(event.sessionId) + case 'user/message': + return messageSource(event.sessionId, event.data) + case 'assistant/message': + return modelSource(event.data.message.source.provider, event.data.message.source.model) + case 'request/header': + return modelSource(event.data.header.config.provider, event.data.header.config.model) + case 'request/context': + return modelSource(event.data.provider, event.data.model) + case 'tool/call': + return toolSource(event.data.name) + case 'tool/result': + return toolSource(event.data.message.source.callId) + default: + return sessionSource(event.sessionId) } } function summary(event: SessionEvent): string { switch (event.type) { - case 'turn/start': return `Turn ${event.data.turn} started.` - case 'turn/end': return `Turn ${event.data.turn} ended: ${event.data.reason.kind}.` - case 'step/start': return `Step ${event.data.step} started in turn ${event.data.turn}.` - case 'step/end': return `Step ${event.data.step} ended in turn ${event.data.turn}.` - case 'user/message': return textContent(event.data.content) ?? 'User message committed.' - case 'assistant/chunk': return `Assistant ${event.data.chunk.type} committed.` - case 'assistant/message': return textContent(event.data.message.content) ?? 'Assistant message committed.' - case 'tool/call': return `Tool call committed: ${event.data.name}.` - case 'tool/result': return `Tool result committed: ${event.data.message.source.callId}.` - case 'request/header': return `Request header committed for ${event.data.header.config.provider}/${event.data.header.config.model}.` - case 'request/context': return `Request context committed for ${event.data.provider}/${event.data.model}.` - case 'agent/inbox/spliced': return `Agent ${event.data.target} inbox changed.` - case 'approval/asked': return `Approval requested for ${event.data.toolName}.` - case 'approval/decided': return `Approval ${event.data.outcome}.` + case 'turn/start': + return `Turn ${event.data.turn} started.` + case 'turn/end': + return `Turn ${event.data.turn} ended: ${event.data.reason.kind}.` + case 'step/start': + return `Step ${event.data.step} started in turn ${event.data.turn}.` + case 'step/end': + return `Step ${event.data.step} ended in turn ${event.data.turn}.` + case 'user/message': + return textContent(event.data.content) ?? 'User message committed.' + case 'assistant/chunk': + return `Assistant ${event.data.chunk.type} committed.` + case 'assistant/message': + return textContent(event.data.message.content) ?? 'Assistant message committed.' + case 'tool/call': + return `Tool call committed: ${event.data.name}.` + case 'tool/result': + return `Tool result committed: ${event.data.message.source.callId}.` + case 'request/header': + return `Request header committed for ${event.data.header.config.provider}/${event.data.header.config.model}.` + case 'request/context': + return `Request context committed for ${event.data.provider}/${event.data.model}.` + case 'agent/inbox/spliced': + return `Agent ${event.data.target} inbox changed.` + case 'approval/asked': + return `Approval requested for ${event.data.toolName}.` + case 'approval/decided': + return `Approval ${event.data.outcome}.` case 'entity/definition-bound': { const { identity, digest, definition } = event.data.resolution - return `Session definition bound to ${definition.name ?? identity.agentId} (${identity.agentId}@${identity.revision}; ${digest}).` + return `Session definition bound to ${ + definition.name ?? identity.agentId + } (${identity.agentId}@${identity.revision}; ${digest}).` } - case 'session/end-seed': return 'Persisted Session seed ended.' - default: return unknownEvent(event) + case 'session/end-seed': + return 'Persisted Session seed ended.' + default: + return unknownEvent(event) } } @@ -136,8 +164,10 @@ function turnId(event: SessionEvent): string | undefined { case 'assistant/chunk': case 'assistant/message': case 'tool/call': - case 'tool/result': return String(event.data.turn) - default: return undefined + case 'tool/result': + return String(event.data.turn) + default: + return undefined } } @@ -148,8 +178,10 @@ function stepId(event: SessionEvent): string | undefined { case 'assistant/chunk': case 'assistant/message': case 'tool/call': - case 'tool/result': return String(event.data.step) - default: return undefined + case 'tool/result': + return String(event.data.step) + default: + return undefined } } @@ -168,15 +200,15 @@ export function projectSessionEvent(event: SessionEvent): TraceEvent | undefined const messageId = event.type === 'user/message' ? event.data.id : event.type === 'assistant/message' - ? event.data.message.id - : event.type === 'tool/result' - ? event.data.message.id - : undefined + ? event.data.message.id + : event.type === 'tool/result' + ? event.data.message.id + : undefined const toolCallId = event.type === 'tool/call' ? event.data.callId : event.type === 'tool/result' - ? event.data.message.source.callId - : undefined + ? event.data.message.source.callId + : undefined const definitionResolution: EntityDefinitionResolution | undefined = event.type === 'entity/definition-bound' ? event.data.resolution : undefined @@ -236,7 +268,9 @@ export class SessionTraceStore implements TraceShowcaseStore { this.startPromise = this.start() } - getSnapshot(): TraceSnapshot { return this.snapshot } + getSnapshot(): TraceSnapshot { + return this.snapshot + } subscribe(listener: () => void): () => void { if (this.disposed) return () => {} @@ -258,7 +292,7 @@ export class SessionTraceStore implements TraceShowcaseStore { } private notify(): void { - if (!this.disposed) for (const listener of this.listeners) listener() + if (!this.disposed) { for (const listener of this.listeners) listener() } } private fail(diagnostic: string): void { @@ -277,7 +311,9 @@ export class SessionTraceStore implements TraceShowcaseStore { mode: 'available', completeness: 'partial', contractVersion: SESSION_CONTRACT, - diagnostics: Object.freeze([`Showing the latest ${this.windowSize} permission-filtered durable Session facts.`]), + diagnostics: Object.freeze([ + `Showing the latest ${this.windowSize} permission-filtered durable Session facts.`, + ]), readOnly: true, }), range: Object.freeze({ @@ -305,10 +341,12 @@ export class SessionTraceStore implements TraceShowcaseStore { } private acceptReadPage(page: SessionEventPage, snapshotSeq: SessionSeqCursor): void { - if (page.sessionId !== this.sessionId + if ( + page.sessionId !== this.sessionId || page.sessionGeneration !== this.sessionGeneration || page.afterSeq !== this.cursor - || page.snapshotSeq !== snapshotSeq) { + || page.snapshotSeq !== snapshotSeq + ) { throw new SessionProjectionError('session-read-fence-violation') } this.acceptEvents(page.events) @@ -326,9 +364,11 @@ export class SessionTraceStore implements TraceShowcaseStore { ): void { if (this.disposed || this.subscription !== subscription) return this.subscription = undefined - if (closed.sessionId !== this.sessionId + if ( + closed.sessionId !== this.sessionId || closed.sessionGeneration !== this.sessionGeneration - || closed.subscriptionGeneration !== this.subscriptionGeneration) { + || closed.subscriptionGeneration !== this.subscriptionGeneration + ) { this.fail('session-subscription-close-fence-violation') return } @@ -350,14 +390,16 @@ export class SessionTraceStore implements TraceShowcaseStore { private acceptSubscriptionPage(page: SessionSubscriptionPage): void { if (this.disposed) return const replayThrough = this.replayThrough ?? page.replayThrough - if (page.sessionId !== this.sessionId + if ( + page.sessionId !== this.sessionId || page.sessionGeneration !== this.sessionGeneration || (this.subscriptionGeneration !== undefined && page.subscriptionGeneration !== this.subscriptionGeneration) || (this.replayThrough !== undefined && page.replayThrough !== this.replayThrough) || (this.phase === 'live' && page.phase !== 'live') || (page.phase === 'replay' && page.events.some(event => event.seq > replayThrough)) || (page.phase === 'live' && this.phase === 'replay' && this.cursor !== replayThrough) - || (page.phase === 'live' && page.events.some(event => event.seq <= replayThrough))) { + || (page.phase === 'live' && page.events.some(event => event.seq <= replayThrough)) + ) { this.fail('session-subscription-fence-violation') throw new SessionProjectionError('session-subscription-fence-violation') } @@ -390,9 +432,11 @@ export class SessionTraceStore implements TraceShowcaseStore { return } const snapshot = snapshotResult.snapshot - if (snapshot.sessionId !== this.sessionId + if ( + snapshot.sessionId !== this.sessionId || snapshot.sessionGeneration !== session.generation - || snapshot.header.id !== this.sessionId) { + || snapshot.header.id !== this.sessionId + ) { throw new SessionProjectionError('session-snapshot-fence-violation') } while (this.cursor < snapshot.snapshotSeq) { @@ -417,11 +461,14 @@ export class SessionTraceStore implements TraceShowcaseStore { return } const subscription = subscribeResult.subscription - if (subscription.sessionId !== this.sessionId + if ( + subscription.sessionId !== this.sessionId || subscription.sessionGeneration !== this.sessionGeneration - || (this.subscriptionGeneration !== undefined && subscription.subscriptionGeneration !== this.subscriptionGeneration) + || (this.subscriptionGeneration !== undefined + && subscription.subscriptionGeneration !== this.subscriptionGeneration) || (this.replayThrough !== undefined && subscription.replayThrough !== this.replayThrough) - || this.cursor < subscription.replayThrough) { + || this.cursor < subscription.replayThrough + ) { await subscription.unsubscribe() throw new SessionProjectionError('session-subscription-handle-fence-violation') } diff --git a/src/types.ts b/src/types.ts index 955ee06..4c3c641 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,10 +2,24 @@ import type { EntityDefinitionResolution } from '@cordisx/protocol/entities/v1' export type TraceLane = 'input' | 'model' | 'tools' | 'injection' export type TracePhase = - | 'opened' | 'resumed' | 'started' | 'updated' | 'completed' - | 'requested' | 'permission' | 'queued' | 'claimed' | 'projected' - | 'forwarded' | 'registered' | 'evaluated' | 'released' - | 'failed' | 'expired' | 'cancelled' | 'closed' + | 'opened' + | 'resumed' + | 'started' + | 'updated' + | 'completed' + | 'requested' + | 'permission' + | 'queued' + | 'claimed' + | 'projected' + | 'forwarded' + | 'registered' + | 'evaluated' + | 'released' + | 'failed' + | 'expired' + | 'cancelled' + | 'closed' export interface TraceSource { readonly kind: 'session' | 'plugin' | 'model' | 'tool' diff --git a/src/unavailable-store.ts b/src/unavailable-store.ts index 2d171ee..66ed568 100644 --- a/src/unavailable-store.ts +++ b/src/unavailable-store.ts @@ -8,14 +8,20 @@ export class UnavailableTraceStore implements TraceShowcaseStore { ...(sessionId === undefined ? {} : { sessionId }), events: Object.freeze([]), status: Object.freeze({ - mode: 'unavailable', completeness: 'unavailable', diagnostics: Object.freeze([diagnostic]), + mode: 'unavailable', + completeness: 'unavailable', + diagnostics: Object.freeze([diagnostic]), readOnly: true, }), range: Object.freeze({ loaded: 0, renderedLimit: windowSize }), }) } - getSnapshot(): TraceSnapshot { return this.snapshot } - subscribe(): () => void { return () => {} } + getSnapshot(): TraceSnapshot { + return this.snapshot + } + subscribe(): () => void { + return () => {} + } dispose(): void {} } diff --git a/test/architecture-guard.test.ts b/test/architecture-guard.test.ts index 10f1c45..278f30d 100644 --- a/test/architecture-guard.test.ts +++ b/test/architecture-guard.test.ts @@ -1,4 +1,4 @@ -import { readFile, readdir } from 'node:fs/promises' +import { readdir, readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' async function sourceText(): Promise { @@ -9,29 +9,33 @@ async function sourceText(): Promise { describe('architecture guard', () => { it('contains no legacy service, concrete loop, private Host, DOM, or raw bridge dependency', async () => { const source = await sourceText() - for (const forbidden of [ - 'ctx.agentEvents', - 'ctx.agentHistory', - 'ctx.agentLoop', - 'ctx.entities', - 'agentEvents', - 'agentHistory', - 'electronBridge', - 'app-server', - 'tdesign', - 'TDesign', - 'querySelector', - 'getElementById', - 'packages/cli/src', - 'packages/host', - 'EntityRegistry', - ]) expect(source).not.toContain(forbidden) + for ( + const forbidden of [ + 'ctx.agentEvents', + 'ctx.agentHistory', + 'ctx.agentLoop', + 'ctx.entities', + 'agentEvents', + 'agentHistory', + 'electronBridge', + 'app-server', + 'tdesign', + 'TDesign', + 'querySelector', + 'getElementById', + 'packages/cli/src', + 'packages/host', + 'EntityRegistry', + ] + ) expect(source).not.toContain(forbidden) }) it('uses only public CordisX package entrypoints', async () => { const source = await sourceText() const imports = [...source.matchAll(/from ['"]([^'"]+)['"]/g)].map(match => match[1]) const cordisxImports = imports.filter(value => value?.startsWith('cordisx')) - expect(cordisxImports.every(value => ['cordisx/react', 'cordisx/ui', 'cordisx/contracts'].includes(value!))).toBe(true) + expect(cordisxImports.every(value => ['cordisx/react', 'cordisx/ui', 'cordisx/contracts'].includes(value!))).toBe( + true, + ) }) }) diff --git a/test/model.test.ts b/test/model.test.ts index 7cf0f5c..e1821eb 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -5,9 +5,43 @@ import type { TraceEvent } from '../src/types.js' describe('Timeline model', () => { const source = Object.freeze({ kind: 'session', id: 'session-a', label: 'Session authority' } as const) const events: readonly TraceEvent[] = Object.freeze([ - Object.freeze({ id: 'session:session-a:0', sessionId: 'session-a', seq: 0, recordedAt: '2026-01-01T00:00:00.000Z', lane: 'model', type: 'turn/start', semanticType: 'turn/start', phase: 'started', summary: 'Turn 1 started.', source }), - Object.freeze({ id: 'session:session-a:1', sessionId: 'session-a', seq: 1, recordedAt: '2026-01-01T00:00:01.000Z', lane: 'tools', type: 'tool/call', semanticType: 'tool/call', summary: 'Tool call committed.', source, turnId: '1' }), - Object.freeze({ id: 'session:session-a:2', sessionId: 'session-a', seq: 2, recordedAt: '2026-01-01T00:00:02.000Z', lane: 'model', type: 'turn/end', semanticType: 'turn/end', phase: 'completed', summary: 'Turn 1 ended.', source, turnId: '1' }), + Object.freeze({ + id: 'session:session-a:0', + sessionId: 'session-a', + seq: 0, + recordedAt: '2026-01-01T00:00:00.000Z', + lane: 'model', + type: 'turn/start', + semanticType: 'turn/start', + phase: 'started', + summary: 'Turn 1 started.', + source, + }), + Object.freeze({ + id: 'session:session-a:1', + sessionId: 'session-a', + seq: 1, + recordedAt: '2026-01-01T00:00:01.000Z', + lane: 'tools', + type: 'tool/call', + semanticType: 'tool/call', + summary: 'Tool call committed.', + source, + turnId: '1', + }), + Object.freeze({ + id: 'session:session-a:2', + sessionId: 'session-a', + seq: 2, + recordedAt: '2026-01-01T00:00:02.000Z', + lane: 'model', + type: 'turn/end', + semanticType: 'turn/end', + phase: 'completed', + summary: 'Turn 1 ended.', + source, + turnId: '1', + }), ]) it('filters and orders immutable projected rows without changing evidence', () => { diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 63e6e72..9d0b530 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -2,13 +2,13 @@ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import { - TRACE_SESSION_PAGE_METADATA, - TRACE_SESSION_ROUTE_DEFINITION, configFrom, createTraceShowcaseStore, inject, installAgentTraceShowcase, manifest, + TRACE_SESSION_PAGE_METADATA, + TRACE_SESSION_ROUTE_DEFINITION, } from '../src/index.js' describe('plugin boundary', () => { @@ -61,7 +61,9 @@ describe('plugin boundary', () => { pages: { register: pageRegister }, routes: { register: routeRegister }, slots: { register: vi.fn() }, - effect: vi.fn((effect: () => () => void) => { disposers.push(effect()) }), + effect: vi.fn((effect: () => () => void) => { + disposers.push(effect()) + }), } installAgentTraceShowcase(ctx as never, { timelineWindowSize: 100 }, { register: entryRegister }) diff --git a/test/session-store.test.ts b/test/session-store.test.ts index 1741b93..d1c2ee0 100644 --- a/test/session-store.test.ts +++ b/test/session-store.test.ts @@ -11,9 +11,10 @@ import type { SessionSubscriptionPage, } from '@cordisx/protocol/sessions/v1' import type { EntityDefinitionBoundSessionEvent } from '@cordisx/protocol/entities/v1' -import { SessionTraceStore, projectSessionEvent } from '../src/session-store.js' +import { projectSessionEvent, SessionTraceStore } from '../src/session-store.js' -const schema = 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-event.v1.schema.json' as const +const schema = + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-event.v1.schema.json' as const const persistedDigest = `sha256:${'a'.repeat(64)}` as const function event(seq: number, type: SessionEvent['type'], data: unknown): SessionEvent { @@ -47,14 +48,19 @@ function entityDefinitionBoundEvent(seq: number): EntityDefinitionBoundSessionEv identity, digest: persistedDigest, definition: { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/agent-definition.v1.schema.json', + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/agent-definition.v1.schema.json', contract: 'cordisx.agent-definition/v1', schemaVersion: 1, identity, name: 'Persisted Generalist', inherit: { - promptSections: 'none', rules: 'none', skills: 'none', - tools: 'none', mcpServers: 'none', runtimeDefaults: 'none', + promptSections: 'none', + rules: 'none', + skills: 'none', + tools: 'none', + mcpServers: 'none', + runtimeDefaults: 'none', }, promptSections: [{ sectionId: 'role', kind: 'role', text: 'Persisted role bytes.' }], }, @@ -66,7 +72,8 @@ function entityDefinitionBoundEvent(seq: number): EntityDefinitionBoundSessionEv function page(afterSeq: number, snapshotSeq: number, events: readonly SessionEvent[]): SessionEventPage { const nextAfterSeq = events.at(-1)?.seq ?? afterSeq return { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-event-page.v1.schema.json', + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-event-page.v1.schema.json', contract: 'cordisx.session-event-page/v1', schemaVersion: 1, sessionId: 'session-a', @@ -85,7 +92,8 @@ function subscriptionPage( events: readonly SessionEvent[], ): SessionSubscriptionPage { return { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-subscription-page.v1.schema.json', + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-subscription-page.v1.schema.json', contract: 'cordisx.session-subscription-page/v1', schemaVersion: 1, sessionId: 'session-a', @@ -99,7 +107,8 @@ function subscriptionPage( function subscriptionClosed(code: SessionSubscriptionCloseCode): SessionSubscriptionClosed { return { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-subscription-close.v1.schema.json', + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-subscription-close.v1.schema.json', contract: 'cordisx.session-subscription-close/v1', schemaVersion: 1, sessionId: 'session-a', @@ -121,7 +130,9 @@ function sessionHarness(input: { const replay = input.replay ?? [] let observer: SessionEventObserver | undefined let resolveClosed: (closed: SessionSubscriptionClosed) => void = () => {} - const closed = new Promise(resolve => { resolveClosed = resolve }) + const closed = new Promise(resolve => { + resolveClosed = resolve + }) const close = (code: SessionSubscriptionCloseCode) => resolveClosed(subscriptionClosed(code)) const unsubscribe = vi.fn(async () => { close('unsubscribed') @@ -149,7 +160,8 @@ function sessionHarness(input: { snapshot: vi.fn(async () => ({ status: 'available' as const, snapshot: { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-snapshot.v1.schema.json' as const, + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/session-snapshot.v1.schema.json' as const, contract: 'cordisx.session-snapshot/v1' as const, schemaVersion: 1 as const, sessionId: 'session-a', @@ -169,8 +181,22 @@ describe('SessionTraceStore', () => { it('pins immutable read pages, accepts atomic replay, then appends contiguous live facts', async () => { const initial = [ event(0, 'turn/start', { turn: 1 }), - event(1, 'user/message', { id: 'message-user', role: 'user', content: [{ type: 'text', text: 'Inspect this' }], source: { kind: 'user' } }), - event(2, 'assistant/message', { turn: 1, step: 1, message: { id: 'message-assistant', role: 'assistant', content: [{ type: 'text', text: 'Done' }], source: { kind: 'model', provider: 'openai', model: 'codex' } } }), + event(1, 'user/message', { + id: 'message-user', + role: 'user', + content: [{ type: 'text', text: 'Inspect this' }], + source: { kind: 'user' }, + }), + event(2, 'assistant/message', { + turn: 1, + step: 1, + message: { + id: 'message-assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Done' }], + source: { kind: 'model', provider: 'openai', model: 'codex' }, + }, + }), ] const harness = sessionHarness({ snapshotSeq: 2, @@ -219,7 +245,10 @@ describe('SessionTraceStore', () => { await harness.observer()!(subscriptionPage('live', 1, [ event(2, 'user/message', { - id: 'message-user', role: 'user', content: [{ type: 'text', text: 'Continue' }], source: { kind: 'user' }, + id: 'message-user', + role: 'user', + content: [{ type: 'text', text: 'Continue' }], + source: { kind: 'user' }, }), ])) expect(store.getSnapshot()).toMatchObject({ @@ -232,7 +261,12 @@ describe('SessionTraceStore', () => { it('fails closed and clears rows on a sequence or generation fence violation', async () => { const harness = sessionHarness({ snapshotSeq: 1, - pages: [page(-1, 1, [event(0, 'turn/start', { turn: 1 }), event(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } })])], + pages: [ + page(-1, 1, [ + event(0, 'turn/start', { turn: 1 }), + event(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]), + ], }) const store = new SessionTraceStore(harness.sessions, 'session-a', 50) await store.settled() @@ -242,13 +276,15 @@ describe('SessionTraceStore', () => { }) }) - it.each([ - 'route-replaced', - 'session-replaced', - 'plugin-generation-replaced', - 'permission-revoked', - 'connection-replaced', - ] satisfies readonly SessionSubscriptionCloseCode[])('fails closed when the Host terminates an active subscription with %s', async code => { + it.each( + [ + 'route-replaced', + 'session-replaced', + 'plugin-generation-replaced', + 'permission-revoked', + 'connection-replaced', + ] satisfies readonly SessionSubscriptionCloseCode[], + )('fails closed when the Host terminates an active subscription with %s', async code => { const harness = sessionHarness() const store = new SessionTraceStore(harness.sessions, 'session-a', 50) await store.settled() @@ -275,7 +311,12 @@ describe('SessionTraceStore', () => { }) it('skips only explicit ignorable extensions and rejects required unknown variants', async () => { - const ignorable = { ...event(0, 'turn/start', { turn: 1 }), type: 'vendor/hint', data: {}, ignorable: true } as unknown as SessionEvent + const ignorable = { + ...event(0, 'turn/start', { turn: 1 }), + type: 'vendor/hint', + data: {}, + ignorable: true, + } as unknown as SessionEvent const acceptedHarness = sessionHarness({ snapshotSeq: 0, pages: [page(-1, 0, [ignorable])] }) const accepted = new SessionTraceStore(acceptedHarness.sessions, 'session-a', 50) await accepted.settled() @@ -298,12 +339,18 @@ describe('SessionTraceStore', () => { it('keeps missing, unauthorized, and unavailable capabilities as honest empty states', async () => { const missing = new SessionTraceStore({ get: vi.fn(async () => undefined) }, 'session-a', 50) await missing.settled() - expect(missing.getSnapshot()).toMatchObject({ events: [], status: { mode: 'unavailable', diagnostics: ['session-missing-or-unauthorized'] } }) + expect(missing.getSnapshot()).toMatchObject({ + events: [], + status: { mode: 'unavailable', diagnostics: ['session-missing-or-unauthorized'] }, + }) const harness = sessionHarness({ subscribeUnavailable: true }) const unavailable = new SessionTraceStore(harness.sessions, 'session-a', 50) await unavailable.settled() - expect(unavailable.getSnapshot()).toMatchObject({ events: [], status: { mode: 'unavailable', diagnostics: ['session-subscribe-unavailable:host-unavailable'] } }) + expect(unavailable.getSnapshot()).toMatchObject({ + events: [], + status: { mode: 'unavailable', diagnostics: ['session-subscribe-unavailable:host-unavailable'] }, + }) }) }) @@ -337,11 +384,34 @@ describe('SessionEvent projection', () => { ['turn/end', { turn: 1, reason: { kind: 'completed' } }], ['step/start', { turn: 1, step: 1 }], ['step/end', { turn: 1, step: 1 }], - ['user/message', { id: 'user-1', role: 'user', content: [{ type: 'text', text: 'Hello' }], source: { kind: 'user' } }], + ['user/message', { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + source: { kind: 'user' }, + }], ['assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hi' } }], - ['assistant/message', { turn: 1, step: 1, message: { id: 'assistant-1', role: 'assistant', content: [{ type: 'text', text: 'Hi' }], source: { kind: 'model', provider: 'openai', model: 'codex' } } }], + ['assistant/message', { + turn: 1, + step: 1, + message: { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + source: { kind: 'model', provider: 'openai', model: 'codex' }, + }, + }], ['tool/call', { turn: 1, step: 1, callId: 'call-1', name: 'read_file', arguments: '{}' }], - ['tool/result', { turn: 1, step: 1, message: { id: 'tool-1', role: 'user', content: [{ type: 'tool-result', toolCallId: 'call-1', content: [{ type: 'text', text: 'ok' }] }], source: { kind: 'tool', callId: 'call-1' } } }], + ['tool/result', { + turn: 1, + step: 1, + message: { + id: 'tool-1', + role: 'user', + content: [{ type: 'tool-result', toolCallId: 'call-1', content: [{ type: 'text', text: 'ok' }] }], + source: { kind: 'tool', callId: 'call-1' }, + }, + }], ['request/header', { header: { config: { provider: 'openai', model: 'codex' } }, reason: 'initial' }], ['request/context', { provider: 'openai', model: 'codex', contextWindow: 128_000 }], ['agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [] }], @@ -357,7 +427,9 @@ describe('SessionEvent projection', () => { it('uses public SessionEvent data without manufacturing terminal causation', () => { expect(projectSessionEvent(event(0, 'agent/inbox/spliced', { - target: 'next-step', start: 0, inserted: [], + target: 'next-step', + start: 0, + inserted: [], }))).toMatchObject({ lane: 'injection', semanticType: 'agent/inbox/spliced', diff --git a/test/view.test.tsx b/test/view.test.tsx index d34ce76..4da2c4e 100644 --- a/test/view.test.tsx +++ b/test/view.test.tsx @@ -10,16 +10,29 @@ import type { TraceEvent, TraceShowcaseStore } from '../src/types.js' vi.mock('cordisx/ui', async () => { const React = await import('react') return { - Button: ({ children, ...props }: React.ButtonHTMLAttributes) => React.createElement('button', props, children), - EmptyState: ({ title, description }: { title: string; description: string }) => React.createElement('section', { 'data-empty-state': title }, description), + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => + React.createElement('button', props, children), + EmptyState: ({ title, description }: { title: string; description: string }) => + React.createElement('section', { 'data-empty-state': title }, description), Select: ({ options, onChange, ...props }: { options: ReadonlyArray<{ value: string; label: string }> onChange(value: string): void value: string 'aria-label': string - }) => React.createElement('select', { ...props, onChange: (event: React.ChangeEvent) => onChange(event.currentTarget.value) }, - options.map(option => React.createElement('option', { key: option.value, value: option.value }, option.label))), - Stack: ({ children, ...props }: React.HTMLAttributes & { direction?: string; gap?: string; align?: string; wrap?: boolean }) => { + }) => + React.createElement( + 'select', + { ...props, onChange: (event: React.ChangeEvent) => onChange(event.currentTarget.value) }, + options.map(option => React.createElement('option', { key: option.value, value: option.value }, option.label)), + ), + Stack: ( + { children, ...props }: React.HTMLAttributes & { + direction?: string + gap?: string + align?: string + wrap?: boolean + }, + ) => { const { direction: _direction, gap: _gap, align: _align, wrap: _wrap, ...divProps } = props return React.createElement('div', divProps, children) }, @@ -56,17 +69,29 @@ function availableStore(): TraceShowcaseStore { sessionId: 'session-a', events: Object.freeze([ Object.freeze({ - id: 'session:session-a:0', sessionId: 'session-a', seq: 0, - recordedAt: '2026-01-01T00:00:00.000Z', lane: 'model' as const, - type: 'turn/start', semanticType: 'turn/start', phase: 'started' as const, - summary: 'Turn 1 started.', turnId: '1', + id: 'session:session-a:0', + sessionId: 'session-a', + seq: 0, + recordedAt: '2026-01-01T00:00:00.000Z', + lane: 'model' as const, + type: 'turn/start', + semanticType: 'turn/start', + phase: 'started' as const, + summary: 'Turn 1 started.', + turnId: '1', source: Object.freeze({ kind: 'session' as const, id: 'session-a', label: 'Session authority' }), }), Object.freeze({ - id: 'session:session-a:1', sessionId: 'session-a', seq: 1, - recordedAt: '2026-01-01T00:00:01.000Z', lane: 'model' as const, - type: 'turn/end', semanticType: 'turn/end', phase: 'completed' as const, - summary: 'Turn 1 ended.', turnId: '1', + id: 'session:session-a:1', + sessionId: 'session-a', + seq: 1, + recordedAt: '2026-01-01T00:00:01.000Z', + lane: 'model' as const, + type: 'turn/end', + semanticType: 'turn/end', + phase: 'completed' as const, + summary: 'Turn 1 ended.', + turnId: '1', source: Object.freeze({ kind: 'session' as const, id: 'session-a', label: 'Session authority' }), }), ]), @@ -125,7 +150,9 @@ describe('Host-owned Agent Trace body', () => { const store = new UnavailableTraceStore('session-a', 100, 'session-service-unavailable') const root = await mount(dom, store) - expect(dom.window.document.querySelector('[data-empty-state="Session events unavailable"]')?.textContent).toContain('session-service-unavailable') + expect(dom.window.document.querySelector('[data-empty-state="Session events unavailable"]')?.textContent).toContain( + 'session-service-unavailable', + ) expect(dom.window.document.querySelectorAll('.cat-row')).toHaveLength(0) root.unmount() }) @@ -135,21 +162,32 @@ describe('Host-owned Agent Trace body', () => { const digest = `sha256:${'a'.repeat(64)}` as const const identity = { agentId: 'chatroom.generalist', revision: digest } const store = eventStore(Object.freeze({ - id: 'session:session-a:0', sessionId: 'session-a', seq: 0, - recordedAt: '2026-01-01T00:00:00.000Z', lane: 'injection', - type: 'entity/definition-bound', semanticType: 'entity/definition-bound', + id: 'session:session-a:0', + sessionId: 'session-a', + seq: 0, + recordedAt: '2026-01-01T00:00:00.000Z', + lane: 'injection', + type: 'entity/definition-bound', + semanticType: 'entity/definition-bound', summary: 'Session definition bound to Persisted Generalist.', source: Object.freeze({ kind: 'session', id: 'session-a', label: 'Host Session authority' }), definitionResolution: { identity, digest, definition: { - $schema: 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/agent-definition.v1.schema.json', - contract: 'cordisx.agent-definition/v1', schemaVersion: 1, - identity, name: 'Persisted Generalist', + $schema: + 'https://raw.githubusercontent.com/cordisx/cordisx-protocol/main/schemas/agent-definition.v1.schema.json', + contract: 'cordisx.agent-definition/v1', + schemaVersion: 1, + identity, + name: 'Persisted Generalist', inherit: { - promptSections: 'none', rules: 'none', skills: 'none', - tools: 'none', mcpServers: 'none', runtimeDefaults: 'none', + promptSections: 'none', + rules: 'none', + skills: 'none', + tools: 'none', + mcpServers: 'none', + runtimeDefaults: 'none', }, }, }, From 62afc0957d6b2938ad7c8a9647f887ec4cb25d05 Mon Sep 17 00:00:00 2001 From: YiJie Date: Sat, 5 Sep 2026 17:55:21 +0800 Subject: [PATCH 2/2] chore: add pinned dprint formatting checks --- .agents/rules/README.md | 3 + .github/workflows/quality.yml | 21 +++ dprint.json | 32 +++++ package-lock.json | 239 ++++++++++++++++++++++++++++++++++ package.json | 5 +- 5 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/quality.yml create mode 100644 dprint.json diff --git a/.agents/rules/README.md b/.agents/rules/README.md index 1089382..aa6de84 100644 --- a/.agents/rules/README.md +++ b/.agents/rules/README.md @@ -1,5 +1,8 @@ # Repository Rules +- Use `npm run format:check` to verify formatting and `npm run format` to apply + the [dprint configuration](../../dprint.json). Security manifests retain their original bytes. + ## Ownership and boundaries - Agent Trace consumes only documented, permission-scoped Host projections. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..97500f1 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,21 @@ +name: Formatting + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run format:check diff --git a/dprint.json b/dprint.json new file mode 100644 index 0000000..16a4888 --- /dev/null +++ b/dprint.json @@ -0,0 +1,32 @@ +{ + "indentWidth": 2, + "newLineKind": "lf", + "typescript": { + "module.sortImportDeclarations": "maintain", + "module.sortExportDeclarations": "maintain", + "quoteStyle": "preferSingle", + "jsx.quoteStyle": "preferDouble", + "semiColons": "asi" + }, + "markdown": { + "textWrap": "maintain" + }, + "includes": [ + "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json,jsonc,md,markdown}" + ], + // Exclude npm-generated lock and build outputs; maintained source and tests remain included. + "excludes": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "package-lock.json", + // Byte-addressed security artifacts: do not rewrite manifests or their recorded digests. + "runtime-manifest.json", + "cordisx.plugin.json" + ], + "plugins": [ + "npm:@dprint/typescript@0.96.1", + "npm:@dprint/json@0.23.0", + "npm:@dprint/markdown@0.23.3" + ] +} diff --git a/package-lock.json b/package-lock.json index 2ee5675..20eb93d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@cordisx/protocol": "github:cordisx/cordisx-protocol#c96c290697f9e802a68c6d3bb094fd27d8d00d1e", + "dprint": "0.57.1", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vitest": "^3.2.4" @@ -212,6 +213,216 @@ "@standard-schema/spec": "^1.1.0" } }, + "node_modules/@dprint/android-arm64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/android-arm64/-/android-arm64-0.57.1.tgz", + "integrity": "sha512-7Te1PWxMhkabwaOf7lJk30LINeGzoEo9ehYXIb1V/9fex+4vhOXTETKzZa9bxq66OWyriUx41CdEsxJ87eQacA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@dprint/android-x64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/android-x64/-/android-x64-0.57.1.tgz", + "integrity": "sha512-iVLMMwjNNcPg4JBt9nK2dq3D2SXqE9T8fzy0xlU3orgFYA4eR3WoFAiW/nlUekWwx0Kcnmzva1DOnjZuBLx7SA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@dprint/darwin-arm64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/darwin-arm64/-/darwin-arm64-0.57.1.tgz", + "integrity": "sha512-r7bmUmF5XZR5YmTidsmF/Zy0YIab8Etp7EElIsCnTk7JLm+kWFxdzH9URn9xk/hCBL2AjrMAtlTzxN4zrGAP+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@dprint/darwin-x64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/darwin-x64/-/darwin-x64-0.57.1.tgz", + "integrity": "sha512-DFc781ayZC3rjo2H9MKgWNdqfTXlHFb6yZvHluJfk0K8tunYfuongaRW03Iqz02TXweF8T8kicTy66KI51PQdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@dprint/linux-arm64-glibc": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-arm64-glibc/-/linux-arm64-glibc-0.57.1.tgz", + "integrity": "sha512-q/gJPtBFddV5p8tXvnGbBnuj1hxNsljyaYRouaHXx6jeEETHAX1B3Ei+sPPMA9fCzDl5LNeBwluYeKHELeaULg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-arm64-musl": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-arm64-musl/-/linux-arm64-musl-0.57.1.tgz", + "integrity": "sha512-d45SM0HPQYNsRqBVdWkOw8fZZ75DF/cEURrc5H1yy7ahSUNHIXGnXGlyGeeglF0GLnHfE82QvchnqmJqIh7GmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-loong64-glibc": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-loong64-glibc/-/linux-loong64-glibc-0.57.1.tgz", + "integrity": "sha512-wJzcOgHM8nekLY8Dr1nJGyseKjV6gMfuVS54d+g6PH0cw2sE0Zg2sT2Y6tJQK4tTTZBUrunIf+ZsG4lsbCc+MA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-loong64-musl": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-loong64-musl/-/linux-loong64-musl-0.57.1.tgz", + "integrity": "sha512-SfFuDt1BtnQ/YIhX1aRqXFDGSHkRSLtgs0VxATOlzu2KEu/ormOHfMabv4Fp6DzAS8V4Ti8smeIVaGy/ufCLzg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-ppc64-glibc": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-ppc64-glibc/-/linux-ppc64-glibc-0.57.1.tgz", + "integrity": "sha512-L6inHs05D2nq4Jv7tLoAJeKJn9R7QcI8aM/gLw7X1sQde8MeU8M4XWDejBVcMa6DnZtvW6xBoWdAVIX6pdq25g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-ppc64-musl": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-ppc64-musl/-/linux-ppc64-musl-0.57.1.tgz", + "integrity": "sha512-SF8vGoBhrI/32NurQ+E8HxTbH8UmU+YrYyQWS/WBo7Xd8SFVyQ/w7ynEVGBW6dt++hgwaRgElusss1AK511MGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-riscv64-glibc": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-riscv64-glibc/-/linux-riscv64-glibc-0.57.1.tgz", + "integrity": "sha512-vzrHAOTvFZG/2lzK9X2zGr1iCtPe/8AGnr6NJfLqvXz2SWOq8++BMIg1vDyepALWGRwwm4BpzQ6HXpw1msJRGw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-x64-glibc": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-x64-glibc/-/linux-x64-glibc-0.57.1.tgz", + "integrity": "sha512-I0wN9IC0EAJsjTcK9OA9rMRoy7YvlGSrKQRe/ovJbFydfH9/+URAdmpKKTJB2kgtfqNQo19svc6JvR+21qF1Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/linux-x64-musl": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/linux-x64-musl/-/linux-x64-musl-0.57.1.tgz", + "integrity": "sha512-ScD2Ybk2lAqCBUVstxyFC6+ebA+KYBgp6cYyjWAiJNQrib0vYaXa5vuAoMLP+zMX5rYuYfQdykQUsz2rokYwhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@dprint/win32-arm64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/win32-arm64/-/win32-arm64-0.57.1.tgz", + "integrity": "sha512-A0dx41rmeE1+RLmzzGYlVX1GKrU2CFmDT3dxR38ayYQYPLIJIUIGadTYsJmpinqRUUdJauCLIOUOU2OJRC6D+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@dprint/win32-x64": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/@dprint/win32-x64/-/win32-x64-0.57.1.tgz", + "integrity": "sha512-Tq2YkN41cXHBNHNuSTevo1QQ2qj6DbvW1bDDkusad1XA3tSuiedudFqLe1QV7W1n0369ofG6N36zJuGKJKWz2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1842,6 +2053,34 @@ "csstype": "^3.0.2" } }, + "node_modules/dprint": { + "version": "0.57.1", + "resolved": "https://registry.npmjs.org/dprint/-/dprint-0.57.1.tgz", + "integrity": "sha512-djAJgsVkqMOdSDkvw2Q519Bn9G9+TjrO8fa4kl4sW9ZsjAzWzEc87+ADjxn7njgWdF377EHKtrWS9U/IPJUl2w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "dprint": "bin.cjs" + }, + "optionalDependencies": { + "@dprint/android-arm64": "0.57.1", + "@dprint/android-x64": "0.57.1", + "@dprint/darwin-arm64": "0.57.1", + "@dprint/darwin-x64": "0.57.1", + "@dprint/linux-arm64-glibc": "0.57.1", + "@dprint/linux-arm64-musl": "0.57.1", + "@dprint/linux-loong64-glibc": "0.57.1", + "@dprint/linux-loong64-musl": "0.57.1", + "@dprint/linux-ppc64-glibc": "0.57.1", + "@dprint/linux-ppc64-musl": "0.57.1", + "@dprint/linux-riscv64-glibc": "0.57.1", + "@dprint/linux-x64-glibc": "0.57.1", + "@dprint/linux-x64-musl": "0.57.1", + "@dprint/win32-arm64": "0.57.1", + "@dprint/win32-x64": "0.57.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index f19136e..648100f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "test": "vitest run", "naming:check": "node scripts/check-public-naming.mjs", "check": "npm run naming:check && npm run typecheck && npm run build && npm test && npm run naming:check && npm run pack:check", - "pack:check": "npm pack --dry-run" + "pack:check": "npm pack --dry-run", + "format": "dprint fmt", + "format:check": "dprint check" }, "dependencies": { "@deepseek-ai/cordis": "4.0.1", @@ -30,6 +32,7 @@ }, "devDependencies": { "@cordisx/protocol": "github:cordisx/cordisx-protocol#c96c290697f9e802a68c6d3bb094fd27d8d00d1e", + "dprint": "0.57.1", "jsdom": "^26.1.0", "typescript": "^5.9.2", "vitest": "^3.2.4"