merge dev into feature/apps-v1

Union resolutions — dev's changes kept, apps-v1 additions appended:
- use_case.ts: dev's meeting_prep + our app_llm_generate/app_copilot_run
- ipc.ts: dev's HttpAuthRequestSchema import + our AppSummarySchema
- shared/index.ts: dev's channels export + our rowboatApp export
- builtin-tools app-navigation: dev's read-view/open-item actions + our open-app
- chat-conversation: dev's read-view/open-item labels + our open-app labels
- sidebar: dev's nav-workspaces tour id + our Apps entry
- App.tsx: dev's open-item handler + our open-app handler
This commit is contained in:
Gagan 2026-07-05 01:08:35 +05:30
commit dc28b0b868
186 changed files with 29957 additions and 1357 deletions

View file

@ -1,14 +1,19 @@
{
"name": "@x/shared",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc",
"dev": "tsc -w"
},
"dependencies": {
"zod": "^4.2.1"
}
}
"name": "@x/shared",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -w",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"zod": "^4.2.1"
},
"devDependencies": {
"vitest": "catalog:"
}
}

View file

@ -107,6 +107,10 @@ export const GmailAttachmentSchema = z.object({
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative().optional(),
savedPath: z.string(),
// Gmail identifiers used to fetch the attachment on demand when it hasn't
// been downloaded to disk yet (e.g. attachments on search results).
messageId: z.string().optional(),
attachmentId: z.string().optional(),
});
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
@ -124,6 +128,14 @@ export const GmailThreadMessageSchema = z.object({
bodyHeight: z.number().int().positive().optional(),
attachments: z.array(GmailAttachmentSchema).optional(),
messageIdHeader: z.string().optional(),
// Set on the unsent draft message within a thread (used by the Drafts view).
isDraft: z.boolean().optional(),
// The draft's own stored In-Reply-To / References headers. Only set on
// draft messages: a Drafts-view pseudo-thread contains just the draft, so
// the composer can't rebuild the reply chain from thread messages and must
// reuse what the draft already carries.
inReplyToHeader: z.string().optional(),
referencesHeader: z.string().optional(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
@ -134,6 +146,9 @@ export const GmailThreadSchema = EmailBlockSchema.extend({
unread: z.boolean().optional(),
importance: z.enum(['important', 'other']).optional(),
gmail_draft: z.string().optional(),
// Gmail-side draft id, present on entries returned by the Drafts list so the
// composer can update/delete that exact draft.
draftId: z.string().optional(),
messages: z.array(GmailThreadMessageSchema),
});

View file

@ -14,6 +14,15 @@ export const BrowserStateSchema = z.object({
tabs: z.array(BrowserTabStateSchema),
});
// HTTP basic/proxy auth challenge raised by a page in the embedded browser.
// Defined once here so main, preload, and renderer share one shape.
export const HttpAuthRequestSchema = z.object({
requestId: z.string(),
host: z.string(),
isProxy: z.boolean(),
realm: z.string().optional(),
});
export const BrowserPageElementSchema = z.object({
index: z.number().int().positive(),
tagName: z.string(),
@ -134,6 +143,7 @@ export const BrowserControlResultSchema = z.object({
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
export type BrowserState = z.infer<typeof BrowserStateSchema>;
export type HttpAuthRequest = z.infer<typeof HttpAuthRequestSchema>;
export type BrowserPageElement = z.infer<typeof BrowserPageElementSchema>;
export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;

View file

@ -0,0 +1,54 @@
import { z } from "zod";
// ---------------------------------------------------------------------------
// Mobile messaging channels (WhatsApp / Telegram bridges).
//
// Each channel links the user's OWN account/bot to the local app: WhatsApp
// connects as a linked device (QR pairing), Telegram long-polls the user's
// own bot token. There is no central relay — the desktop app must be running.
// ---------------------------------------------------------------------------
export const WhatsAppChannelConfig = z.object({
enabled: z.boolean(),
// Extra sender phone numbers (digits only, no '+') allowed to drive the
// bridge. The linked account itself (self-chat) is always allowed.
allowFrom: z.array(z.string()),
});
export const TelegramChannelConfig = z.object({
enabled: z.boolean(),
botToken: z.string(),
// Telegram chat ids (numeric strings) allowed to drive the bridge.
// Unknown senders are told their chat id so it can be added here.
allowFrom: z.array(z.string()),
});
export const ChannelsConfig = z.object({
whatsapp: WhatsAppChannelConfig,
telegram: TelegramChannelConfig,
});
export const DEFAULT_CHANNELS_CONFIG: z.infer<typeof ChannelsConfig> = {
whatsapp: { enabled: false, allowFrom: [] },
telegram: { enabled: false, botToken: "", allowFrom: [] },
};
export const WhatsAppChannelStatus = z.object({
state: z.enum(["disabled", "starting", "qr", "connected", "error"]),
// PNG data URL of the pairing QR (present while state === "qr").
qrDataUrl: z.string().optional(),
// Linked account phone number (digits) once connected.
self: z.string().optional(),
error: z.string().optional(),
});
export const TelegramChannelStatus = z.object({
state: z.enum(["disabled", "starting", "polling", "error"]),
botUsername: z.string().optional(),
error: z.string().optional(),
});
export const ChannelsStatus = z.object({
whatsapp: WhatsAppChannelStatus,
telegram: TelegramChannelStatus,
});

View file

@ -53,6 +53,11 @@ export const CodeRunEvent = z.discriminatedUnion("type", [
priority: z.string().optional(),
})),
}),
z.object({
type: z.literal("usage"),
used: z.number().nonnegative(),
size: z.number().positive(),
}),
z.object({
type: z.literal("permission"),
ask: PermissionAsk,

View file

@ -19,5 +19,6 @@ export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export * as codeSessions from './code-sessions.js';
export * as channels from './channels.js';
export * as rowboatApp from './rowboat-app.js';
export { PrefixLogger };

View file

@ -13,16 +13,19 @@ import {
BackgroundTaskSummarySchema,
TriggersSchema,
} from './background-task.js';
import { UserMessageContent } from './message.js';
import { UserMessage, UserMessageContent } from './message.js';
import { RequestedAgent, type TurnEvent } from './turns.js';
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { AppSummarySchema } from './rowboat-app.js';
import { BrowserStateSchema } from './browser-control.js';
import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
import { ChannelsConfig, ChannelsStatus } from './channels.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -206,6 +209,56 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'gmail:saveDraft': {
req: z.object({
// Existing Gmail draft to update; omitted on first save (creates a new one).
draftId: z.string().min(1).optional(),
threadId: z.string().min(1).optional(),
// Recipients may be blank for a draft (unlike a send).
to: z.string().optional(),
cc: z.string().optional(),
bcc: z.string().optional(),
subject: z.string(),
bodyHtml: z.string(),
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
mimeType: z.string(),
contentBase64: z.string(),
}),
)
.optional(),
}),
res: z.object({
draftId: z.string().optional(),
error: z.string().optional(),
}),
},
'gmail:deleteDraft': {
req: z.object({ draftId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:getDrafts': {
req: z.object({}),
res: z.object({
threads: z.array(GmailThreadSchema),
error: z.string().optional(),
}),
},
'gmail:search': {
req: z.object({
query: z.string(),
limit: z.number().int().positive().optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
error: z.string().optional(),
}),
},
'gmail:getConnectionStatus': {
req: z.object({}),
res: z.object({
@ -236,7 +289,15 @@ const ipcSchemas = {
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:markThreadRead': {
req: z.object({ threadId: z.string().min(1) }),
req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:downloadAttachment': {
req: z.object({
messageId: z.string().min(1),
savedPath: z.string().min(1),
attachmentId: z.string().optional(),
}),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:saveMessageHeight': {
@ -376,6 +437,90 @@ const ipcSchemas = {
req: z.null(),
res: z.null(),
},
// ── New runtime: sessions + turns (session-design.md) ────────────────────
// Turn-mutating calls return quickly; the renderer follows progress through
// the sessions:events feed and the shared reduceTurn reducer.
'sessions:create': {
req: z.object({ title: z.string().optional() }),
res: z.object({ sessionId: z.string() }),
},
'sessions:list': {
req: z.object({}),
res: z.object({ sessions: z.array(z.custom<SessionIndexEntry>()) }),
},
'sessions:get': {
req: z.object({ sessionId: z.string() }),
res: z.custom<SessionState>(),
},
'sessions:getTurn': {
// Events are strictly validated at the repository read; typed via
// z.custom to avoid re-validating potentially large logs per IPC hop.
req: z.object({ turnId: z.string() }),
res: z.custom<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>(),
},
'sessions:sendMessage': {
req: z.object({
sessionId: z.string(),
input: UserMessage,
config: z.object({
agent: RequestedAgent,
autoPermission: z.boolean().optional(),
maxModelCalls: z.number().int().positive().optional(),
}),
}),
res: z.object({ turnId: z.string() }),
},
'sessions:respondToPermission': {
req: z.object({
turnId: z.string(),
toolCallId: z.string(),
decision: z.enum(['allow', 'deny']),
metadata: z.json().optional(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:respondToAskHuman': {
req: z.object({
turnId: z.string(),
toolCallId: z.string(),
answer: z.string(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:stopTurn': {
req: z.object({
turnId: z.string(),
reason: z.string().optional(),
}),
res: z.object({ success: z.literal(true) }),
},
'sessions:resumeTurn': {
req: z.object({ sessionId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:setTitle': {
req: z.object({ sessionId: z.string(), title: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:downloadLog': {
// Concatenates the session's turn logs into one JSONL for debugging.
req: z.object({ sessionId: z.string() }),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'sessions:delete': {
req: z.object({ sessionId: z.string() }),
res: z.object({ success: z.literal(true) }),
},
'sessions:events': {
// Typed via z.custom so the renderer's `on` handler is typed without
// runtime validation (the broadcast path bypasses preload validation,
// like runs:events).
req: z.custom<SessionBusEvent>(),
res: z.null(),
},
'services:events': {
req: ServiceEvent,
res: z.null(),
@ -506,6 +651,12 @@ const ipcSchemas = {
}),
res: z.null(),
},
// Bring the main app window to the foreground (e.g. the assistant navigated
// the UI during a call while the user was in another app).
'app:focusMainWindow': {
req: z.null(),
res: z.object({}),
},
'app:takeMeetingNotes': {
req: z.object({
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
@ -807,6 +958,28 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
'channels:getConfig': {
req: z.null(),
res: ChannelsConfig,
},
'channels:setConfig': {
req: ChannelsConfig,
res: z.object({ success: z.literal(true) }),
},
'channels:getStatus': {
req: z.null(),
res: ChannelsStatus,
},
'channels:whatsappLogout': {
req: z.null(),
res: z.object({ success: z.literal(true) }),
},
// Push: main → renderer status updates (QR rotation, connect/disconnect).
'channels:status': {
req: ChannelsStatus,
res: z.null(),
},
'slack:getConfig': {
req: z.null(),
res: z.object({
@ -1276,6 +1449,34 @@ const ipcSchemas = {
mimeType: z.string(),
}),
},
// Streaming TTS: main starts the synthesis and pushes audio chunks over
// 'voice:tts-chunk' as they arrive, so playback can begin on the first
// chunk instead of after the full body (~0.5-1s earlier first-audio).
'voice:synthesizeStreamStart': {
req: z.object({
requestId: z.string(),
text: z.string(),
}),
res: z.object({
ok: z.boolean(),
error: z.string().optional(),
}),
},
'voice:synthesizeStreamCancel': {
req: z.object({ requestId: z.string() }),
res: z.object({}),
},
// Push channel: main → renderer with streaming TTS audio. `done: true`
// (possibly with a final chunk) ends the stream; `error` aborts it.
'voice:tts-chunk': {
req: z.object({
requestId: z.string(),
chunkBase64: z.string().optional(),
done: z.boolean(),
error: z.string().optional(),
}),
res: z.null(),
},
// Ensures the OS-level microphone permission is settled before capturing.
// On first-ever use (macOS) the permission is 'not-determined'; resolving
// the native prompt up front prevents the in-flight getUserMedia from
@ -1286,6 +1487,79 @@ const ipcSchemas = {
granted: z.boolean(),
}),
},
// Same as ensureMicAccess but for the camera — settles the macOS TCC
// permission before video mode calls getUserMedia({ video: true }).
'voice:ensureCameraAccess': {
req: z.null(),
res: z.object({
granted: z.boolean(),
}),
},
// Video-mode popout: show/hide the small always-on-top window (user +
// mascot tiles) that floats over everything for the duration of a screen
// share, Meet-style.
'video:setPopout': {
req: z.object({ show: z.boolean() }),
res: z.object({}),
},
// Main-window renderer pushes the current call state; the main process
// caches it and relays to the popout window (replayed on popout load).
'video:popoutState': {
req: z.object({
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(),
screenSharing: z.boolean(),
// Live transcript of the in-progress utterance.
interimText: z.string().nullable(),
}),
res: z.object({}),
},
// Popout window → fetch the latest cached call state on mount. The
// did-finish-load replay can race the React listener registration, and the
// popout must never guess (a wrong camera-on default flashes the user's
// video before the first state push corrects it).
'video:getPopoutState': {
req: z.null(),
res: z.object({
state: z
.object({
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(),
screenSharing: z.boolean(),
interimText: z.string().nullable(),
})
.nullable(),
}),
},
// Popout control bar → main process → relayed to the app window, which
// executes the action on the live call. 'expand' additionally focuses the
// main app window (handled in the main process).
'video:popoutAction': {
req: z.object({
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
}),
res: z.object({}),
},
// Push channel: main → popout window with the latest call state.
'video:popout-state': {
req: z.object({
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
cameraOn: z.boolean(),
screenSharing: z.boolean(),
interimText: z.string().nullable(),
}),
res: z.null(),
},
// Push channel: main → app window with a popout control-bar action.
'video:popout-action': {
req: z.object({
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
}),
res: z.null(),
},
'meeting:checkScreenPermission': {
req: z.null(),
res: z.object({
@ -1306,6 +1580,47 @@ const ipcSchemas = {
notes: z.string(),
}),
},
// Resolve a meeting's attendees against the knowledge base — returns each
// attendee's existing person note (or null). Deterministic, no LLM; powers
// the ambient "Next up" prep card.
'meeting-prep:resolve': {
req: z.object({
attendees: z.array(z.object({
email: z.string().optional(),
displayName: z.string().optional(),
self: z.boolean().optional(),
})),
// When provided, the response includes any pre-generated prep note for
// this calendar event (matched by the eventId stamped in frontmatter).
eventId: z.string().optional(),
}),
res: z.object({
attendees: z.array(z.object({
label: z.string(),
email: z.string().optional(),
displayName: z.string().optional(),
note: z.object({
path: z.string(),
name: z.string(),
role: z.string().optional(),
organization: z.string().optional(),
markdown: z.string(),
}).nullable(),
})),
organizations: z.array(z.object({
path: z.string(),
name: z.string(),
markdown: z.string(),
})),
// The pre-generated prep note (brief + path), if one exists for eventId.
prepNote: z.object({
path: z.string(),
brief: z.string(),
}).nullable(),
matchedCount: z.number().int().nonnegative(),
unmatchedCount: z.number().int().nonnegative(),
}),
},
// Inline task schedule classification
'export:note': {
req: z.object({
@ -1598,6 +1913,30 @@ const ipcSchemas = {
req: BrowserStateSchema,
res: z.null(),
},
// HTTP basic/proxy auth challenge from a page in the embedded browser
// (main → renderer push). The renderer shows a credential prompt and
// answers via browser:httpAuthResponse.
'browser:httpAuthRequest': {
req: HttpAuthRequestSchema,
res: z.null(),
},
// Main → renderer: a pending auth challenge was resolved without the
// renderer answering (timed out, or its tab/window was destroyed), so the
// renderer must drop the corresponding dialog from its queue.
'browser:httpAuthResolved': {
req: z.object({ requestId: z.string() }),
res: z.null(),
},
// Renderer → main. Omit username to cancel the challenge; provide it (even
// empty, for token-style auth) to submit credentials.
'browser:httpAuthResponse': {
req: z.object({
requestId: z.string(),
username: z.string().optional(),
password: z.string().optional(),
}),
res: z.object({ ok: z.boolean() }),
},
// Billing channels
'billing:getInfo': {
req: z.null(),

View file

@ -44,8 +44,20 @@ export const UserAttachmentPart = z.object({
lineNumber: z.number().int().min(1).optional(), // 1-indexed line in source file (for editor-context references)
});
// Any single part of a user message (text or attachment)
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
// An inline image within a content array (e.g. a live webcam frame from
// video mode). Unlike attachments, image parts carry their data inline as
// base64 and are sent to the model as real multimodal image parts rather
// than a file-path reference.
export const UserImagePart = z.object({
type: z.literal("image"),
data: z.string(), // base64-encoded image bytes (no data: prefix)
mediaType: z.string(), // MIME type ("image/jpeg")
source: z.enum(["camera", "screen"]).optional(),
capturedAt: z.string().optional(), // ISO timestamp of capture
});
// Any single part of a user message (text, attachment, or inline image)
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart, UserImagePart]);
// Named type for user message content — used everywhere instead of repeating the union
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);

View file

@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import {
SessionCorruptionError,
SessionCreated,
SessionEvent,
SessionTurnAppended,
reduceSession,
sessionIndexEntry,
} from "./sessions.js";
type SEvent = z.infer<typeof SessionEvent>;
const SESSION_ID = "2026-07-02T09-00-00Z-0000042-000";
const MODEL = { provider: "openai", model: "gpt-test" };
function sessionCreated(
overrides: Partial<z.infer<typeof SessionCreated>> = {},
): z.infer<typeof SessionCreated> {
return {
type: "session_created",
schemaVersion: 1,
sessionId: SESSION_ID,
ts: "2026-07-02T09:00:00Z",
...overrides,
};
}
function turnAppended(
sessionSeq: number,
turnId: string,
overrides: Partial<z.infer<typeof SessionTurnAppended>> = {},
): z.infer<typeof SessionTurnAppended> {
return {
type: "turn_appended",
sessionId: SESSION_ID,
ts: `2026-07-02T09:0${sessionSeq}:00Z`,
turnId,
sessionSeq,
agentId: "copilot",
model: MODEL,
...overrides,
};
}
function titleChanged(title: string, ts = "2026-07-02T09:05:00Z") {
return {
type: "title_changed" as const,
sessionId: SESSION_ID,
ts,
title,
};
}
function expectCorruption(events: SEvent[], match: string | RegExp): void {
expect(() => reduceSession(events)).toThrowError(SessionCorruptionError);
expect(() => reduceSession(events)).toThrowError(match);
}
describe("schemas", () => {
it("session events round-trip through the SessionEvent schema", () => {
const events: SEvent[] = [
sessionCreated({ title: "Hello" }),
turnAppended(1, "turn-1"),
titleChanged("Renamed"),
];
for (const event of events) {
expect(SessionEvent.parse(event)).toEqual(event);
}
});
it("rejects non-positive sessionSeq at the schema level", () => {
expect(() => SessionEvent.parse(turnAppended(0, "turn-0"))).toThrowError();
});
});
describe("reduceSession", () => {
it("reduces a created-only log", () => {
const state = reduceSession([sessionCreated()]);
expect(state.definition.sessionId).toBe(SESSION_ID);
expect(state.turns).toEqual([]);
expect(state.latestTurnId).toBeUndefined();
expect(state.title).toBeUndefined();
expect(state.createdAt).toBe("2026-07-02T09:00:00Z");
expect(state.updatedAt).toBe("2026-07-02T09:00:00Z");
});
it("folds turns in order with denormalized metadata", () => {
const state = reduceSession([
sessionCreated(),
turnAppended(1, "turn-1"),
turnAppended(2, "turn-2", { agentId: "researcher", model: { provider: "anthropic", model: "claude-x" } }),
]);
expect(state.turns).toHaveLength(2);
expect(state.turns[0]).toMatchObject({ turnId: "turn-1", sessionSeq: 1, agentId: "copilot" });
expect(state.turns[1]).toMatchObject({
turnId: "turn-2",
sessionSeq: 2,
agentId: "researcher",
model: { provider: "anthropic", model: "claude-x" },
});
expect(state.latestTurnId).toBe("turn-2");
expect(state.updatedAt).toBe("2026-07-02T09:02:00Z");
});
it("folds titles: creation default then last change wins", () => {
const withDefault = reduceSession([sessionCreated({ title: "First message…" })]);
expect(withDefault.title).toBe("First message…");
const renamed = reduceSession([
sessionCreated({ title: "First message…" }),
titleChanged("Better title"),
titleChanged("Best title", "2026-07-02T09:06:00Z"),
]);
expect(renamed.title).toBe("Best title");
expect(renamed.updatedAt).toBe("2026-07-02T09:06:00Z");
});
it("rejects an empty log", () => {
expectCorruption([], /empty/);
});
it("rejects a log not starting with session_created", () => {
expectCorruption([turnAppended(1, "turn-1")], /first event must be session_created/);
});
it("rejects duplicate session_created", () => {
expectCorruption([sessionCreated(), sessionCreated()], /duplicate session_created/);
});
it("rejects mismatched session ids", () => {
expectCorruption(
[sessionCreated(), { ...turnAppended(1, "turn-1"), sessionId: "other" }],
/does not match session/,
);
});
it("rejects unsupported schema versions", () => {
const bad = { ...sessionCreated(), schemaVersion: 2 } as unknown as SEvent;
expectCorruption([bad], /unsupported session schema version/);
});
it("rejects unknown event types", () => {
const bad = { type: "wat", sessionId: SESSION_ID, ts: "t" } as unknown as SEvent;
expectCorruption([sessionCreated(), bad], /unknown session event type/);
});
it("rejects a first turn not at sessionSeq 1", () => {
expectCorruption([sessionCreated(), turnAppended(2, "turn-2")], /out of order; expected 1/);
});
it("rejects gapped sessionSeq", () => {
expectCorruption(
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(3, "turn-3")],
/out of order; expected 2/,
);
});
it("rejects duplicate sessionSeq", () => {
expectCorruption(
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(1, "turn-1b")],
/out of order; expected 2/,
);
});
it("rejects duplicate turn ids", () => {
expectCorruption(
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(2, "turn-1")],
/duplicate turnId/,
);
});
});
describe("sessionIndexEntry", () => {
it("projects an entry with last-turn metadata and the provided status", () => {
const state = reduceSession([
sessionCreated({ title: "T" }),
turnAppended(1, "turn-1"),
turnAppended(2, "turn-2", { agentId: "researcher" }),
]);
expect(sessionIndexEntry(state, "suspended")).toEqual({
sessionId: SESSION_ID,
title: "T",
createdAt: "2026-07-02T09:00:00Z",
updatedAt: "2026-07-02T09:02:00Z",
turnCount: 2,
lastAgentId: "researcher",
lastModel: MODEL,
latestTurnId: "turn-2",
latestTurnStatus: "suspended",
});
});
it("projects an empty session with status none", () => {
const state = reduceSession([sessionCreated()]);
const entry = sessionIndexEntry(state, "none");
expect(entry.turnCount).toBe(0);
expect(entry.lastAgentId).toBeUndefined();
expect(entry.latestTurnStatus).toBe("none");
});
});

View file

@ -0,0 +1,206 @@
import { z } from "zod";
import {
ModelDescriptor,
type TurnStatus,
type TurnStreamEvent,
} from "./turns.js";
// 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;
}
// 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;
};
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,
};
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
globals: false,
clearMocks: true,
restoreMocks: true,
},
});