feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
import { z } from "zod";
|
feat(x/core): session layer with write-through index (stage 3)
SessionsImpl per session-design.md, constructor-injected and tested
entirely against a mocked ITurnRuntime:
- sendMessage: per-session lock, typed TurnNotSettledError while the
latest turn is non-terminal (terminal statuses including failed/
cancelled allow continuation), context as { previousTurnId } ref,
turn-file-first write ordering (a failed session append leaves a
benign orphan turn and no advance), denormalized agent/model on
turn_appended, default title from the first message.
- Dedicated respondToAskHuman endpoint (async_tool_result wrapper);
respondToPermission / deliverAsyncToolResult pass turn-runtime
rejections through. stopTurn aborts a live advance or cancels an
at-rest turn; resumeTurn re-enters idle turns (never at startup).
- In-memory index: startup scan (session files + each latest turn for
status; corrupt files yield errored entries without aborting),
write-through updates, deletion guard against late-settle
resurrection. Bus events (turn-event / index-changed) defined in
@x/shared as the renderer IPC contract.
- FSSessionRepo: date-partitioned append-only JSONL + list/delete;
deleteSession removes the session file only.
- runHeadlessTurn: standalone turns (sessionId null, auto permission,
no human) for background/knowledge/scheduled callers.
29 new tests covering the session-design §13 matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:57:20 +05:30
|
|
|
import {
|
|
|
|
|
ModelDescriptor,
|
|
|
|
|
type TurnStatus,
|
|
|
|
|
type TurnStreamEvent,
|
|
|
|
|
} from "./turns.js";
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
|
|
|
|
|
// Durable session contract for the session layer (see
|
|
|
|
|
// packages/core/docs/session-design.md). A session is an append-only chain
|
|
|
|
|
// of turn references plus presentation metadata; conversation content lives
|
|
|
|
|
// exclusively in turn files. Pure module shared by core and renderer.
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Durable events
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const SessionCreated = z.object({
|
|
|
|
|
type: z.literal("session_created"),
|
|
|
|
|
schemaVersion: z.literal(1),
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
title: z.string().optional(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// agentId/model are denormalized from the turn so the session index can fold
|
|
|
|
|
// without opening turn files; the turn file stays authoritative.
|
|
|
|
|
export const SessionTurnAppended = z.object({
|
|
|
|
|
type: z.literal("turn_appended"),
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
turnId: z.string(),
|
|
|
|
|
sessionSeq: z.number().int().positive(),
|
|
|
|
|
agentId: z.string(),
|
|
|
|
|
model: ModelDescriptor,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const SessionTitleChanged = z.object({
|
|
|
|
|
type: z.literal("title_changed"),
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
ts: z.string(),
|
|
|
|
|
title: z.string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export const SessionEvent = z.discriminatedUnion("type", [
|
|
|
|
|
SessionCreated,
|
|
|
|
|
SessionTurnAppended,
|
|
|
|
|
SessionTitleChanged,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Derived session state
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export class SessionCorruptionError extends Error {
|
|
|
|
|
constructor(message: string) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = "SessionCorruptionError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionTurnRef {
|
|
|
|
|
turnId: string;
|
|
|
|
|
sessionSeq: number;
|
|
|
|
|
agentId: string;
|
|
|
|
|
model: z.infer<typeof ModelDescriptor>;
|
|
|
|
|
ts: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionState {
|
|
|
|
|
definition: z.infer<typeof SessionCreated>;
|
|
|
|
|
title?: string;
|
|
|
|
|
turns: SessionTurnRef[];
|
|
|
|
|
latestTurnId?: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fail(message: string): never {
|
|
|
|
|
throw new SessionCorruptionError(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function reduceSession(
|
|
|
|
|
events: Array<z.infer<typeof SessionEvent>>,
|
|
|
|
|
): SessionState {
|
|
|
|
|
if (events.length === 0) {
|
|
|
|
|
fail("session log is empty");
|
|
|
|
|
}
|
|
|
|
|
const [first, ...rest] = events;
|
|
|
|
|
if (first.type !== "session_created") {
|
|
|
|
|
fail(`first event must be session_created, got ${first.type}`);
|
|
|
|
|
}
|
|
|
|
|
if (first.schemaVersion !== 1) {
|
|
|
|
|
fail(`unsupported session schema version: ${String(first.schemaVersion)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state: SessionState = {
|
|
|
|
|
definition: first,
|
|
|
|
|
title: first.title,
|
|
|
|
|
turns: [],
|
|
|
|
|
createdAt: first.ts,
|
|
|
|
|
updatedAt: first.ts,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (const event of rest) {
|
|
|
|
|
if (event.sessionId !== first.sessionId) {
|
|
|
|
|
fail(
|
|
|
|
|
`event sessionId ${event.sessionId} does not match session ${first.sessionId}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
switch (event.type) {
|
|
|
|
|
case "session_created":
|
|
|
|
|
fail("duplicate session_created event");
|
|
|
|
|
break;
|
|
|
|
|
case "turn_appended": {
|
|
|
|
|
const expectedSeq = state.turns.length + 1;
|
|
|
|
|
if (event.sessionSeq !== expectedSeq) {
|
|
|
|
|
fail(
|
|
|
|
|
`sessionSeq ${event.sessionSeq} out of order; expected ${expectedSeq}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (state.turns.some((t) => t.turnId === event.turnId)) {
|
|
|
|
|
fail(`duplicate turnId in session: ${event.turnId}`);
|
|
|
|
|
}
|
|
|
|
|
state.turns.push({
|
|
|
|
|
turnId: event.turnId,
|
|
|
|
|
sessionSeq: event.sessionSeq,
|
|
|
|
|
agentId: event.agentId,
|
|
|
|
|
model: event.model,
|
|
|
|
|
ts: event.ts,
|
|
|
|
|
});
|
|
|
|
|
state.latestTurnId = event.turnId;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case "title_changed":
|
|
|
|
|
state.title = event.title;
|
|
|
|
|
break;
|
|
|
|
|
default: {
|
|
|
|
|
const unknown: never = event;
|
|
|
|
|
fail(
|
|
|
|
|
`unknown session event type: ${(unknown as { type: string }).type}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.updatedAt = event.ts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Session index (in-memory projection; never a source of truth)
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// "none" = the session has no turns yet. The remaining values are the latest
|
|
|
|
|
// turn's derived status (deriveTurnStatus in turns.ts).
|
|
|
|
|
export type SessionLatestTurnStatus = "none" | TurnStatus;
|
|
|
|
|
|
|
|
|
|
export interface SessionIndexEntry {
|
|
|
|
|
sessionId: string;
|
|
|
|
|
title?: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
turnCount: number;
|
|
|
|
|
lastAgentId?: string;
|
|
|
|
|
lastModel?: z.infer<typeof ModelDescriptor>;
|
|
|
|
|
latestTurnId?: string;
|
|
|
|
|
latestTurnStatus: SessionLatestTurnStatus;
|
|
|
|
|
// Set when the session (or its latest turn) failed to load/validate; the
|
|
|
|
|
// entry is surfaced in an errored state instead of aborting the startup
|
|
|
|
|
// scan.
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
feat(x/core): session layer with write-through index (stage 3)
SessionsImpl per session-design.md, constructor-injected and tested
entirely against a mocked ITurnRuntime:
- sendMessage: per-session lock, typed TurnNotSettledError while the
latest turn is non-terminal (terminal statuses including failed/
cancelled allow continuation), context as { previousTurnId } ref,
turn-file-first write ordering (a failed session append leaves a
benign orphan turn and no advance), denormalized agent/model on
turn_appended, default title from the first message.
- Dedicated respondToAskHuman endpoint (async_tool_result wrapper);
respondToPermission / deliverAsyncToolResult pass turn-runtime
rejections through. stopTurn aborts a live advance or cancels an
at-rest turn; resumeTurn re-enters idle turns (never at startup).
- In-memory index: startup scan (session files + each latest turn for
status; corrupt files yield errored entries without aborting),
write-through updates, deletion guard against late-settle
resurrection. Bus events (turn-event / index-changed) defined in
@x/shared as the renderer IPC contract.
- FSSessionRepo: date-partitioned append-only JSONL + list/delete;
deleteSession removes the session file only.
- runHeadlessTurn: standalone turns (sessionId null, auto permission,
no human) for background/knowledge/scheduled callers.
29 new tests covering the session-design §13 matrix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:57:20 +05:30
|
|
|
// What the renderer's single feed consumer receives over IPC: live turn
|
|
|
|
|
// stream events (durable events + ephemeral deltas) and index updates.
|
|
|
|
|
// entry: null signals deletion.
|
|
|
|
|
export type SessionBusEvent =
|
|
|
|
|
| {
|
|
|
|
|
kind: "turn-event";
|
|
|
|
|
sessionId: string;
|
|
|
|
|
turnId: string;
|
|
|
|
|
event: TurnStreamEvent;
|
|
|
|
|
}
|
|
|
|
|
| {
|
|
|
|
|
kind: "index-changed";
|
|
|
|
|
sessionId: string;
|
|
|
|
|
entry: SessionIndexEntry | null;
|
|
|
|
|
};
|
|
|
|
|
|
feat(x/shared): turn + session event schemas and pure reducers (stage 1)
Durable contracts for the new turn/session runtime, shared by core and
renderer:
- turns.ts: zod schemas for all 16 durable turn events, TurnContext
(previousTurnId ref | inline messages), ModelRequest with contextRef
and current-turn-only messages, ephemeral delta types, reduceTurn
enforcing every spec invariant, and pure derivations
(deriveTurnStatus, turnTranscript, outstanding work).
- sessions.ts: session_created/turn_appended/title_changed schemas,
reduceSession, and the SessionIndexEntry projection.
- vitest wiring for packages/shared (config, build-excluded tests);
92 tests covering happy paths, recovery-shaped histories, and one
named test per corruption invariant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 07:46:07 +05:30
|
|
|
export function sessionIndexEntry(
|
|
|
|
|
state: SessionState,
|
|
|
|
|
latestTurnStatus: SessionLatestTurnStatus,
|
|
|
|
|
): SessionIndexEntry {
|
|
|
|
|
const lastTurn = state.turns[state.turns.length - 1];
|
|
|
|
|
return {
|
|
|
|
|
sessionId: state.definition.sessionId,
|
|
|
|
|
title: state.title,
|
|
|
|
|
createdAt: state.createdAt,
|
|
|
|
|
updatedAt: state.updatedAt,
|
|
|
|
|
turnCount: state.turns.length,
|
|
|
|
|
lastAgentId: lastTurn?.agentId,
|
|
|
|
|
lastModel: lastTurn?.model,
|
|
|
|
|
latestTurnId: state.latestTurnId,
|
|
|
|
|
latestTurnStatus,
|
|
|
|
|
};
|
|
|
|
|
}
|