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

@ -2,13 +2,10 @@ import { CronExpressionParser } from "cron-parser";
import container from "../di/container.js";
import { IAgentScheduleRepo } from "./repo.js";
import { IAgentScheduleStateRepo } from "./state-repo.js";
import { IRunsRepo } from "../runs/repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { AgentScheduleConfig, AgentScheduleEntry } from "@x/shared/dist/agent-schedule.js";
import { AgentScheduleState, AgentScheduleStateEntry } from "@x/shared/dist/agent-schedule-state.js";
import { MessageEvent } from "@x/shared/dist/runs.js";
import { createRun } from "../runs/runs.js";
import { startHeadlessAgent } from "../agents/headless-app.js";
import { withUseCase } from "../analytics/use_case.js";
import z from "zod";
const DEFAULT_STARTING_MESSAGE = "go";
@ -147,10 +144,7 @@ function shouldRunNow(
async function runAgent(
agentName: string,
entry: z.infer<typeof AgentScheduleEntry>,
stateRepo: IAgentScheduleStateRepo,
runsRepo: IRunsRepo,
agentRuntime: IAgentRuntime,
idGenerator: IMonotonicallyIncreasingIdGenerator
stateRepo: IAgentScheduleStateRepo
): Promise<void> {
console.log(`[AgentRunner] Starting agent: ${agentName}`);
@ -163,31 +157,24 @@ async function runAgent(
});
try {
// Create a new run via core (resolves agent + default model+provider).
const run = await createRun({
agentId: agentName,
useCase: 'copilot_chat',
subUseCase: 'scheduled',
});
console.log(`[AgentRunner] Created run ${run.id} for agent ${agentName}`);
// Add the starting message as a user message
// Start a standalone turn (resolves agent + default model/provider).
// Fire-and-forget like the old trigger(): the schedule state machine
// below runs on wall-clock; completion is observed only for logging.
// The signal caps a hung turn at the same TIMEOUT_MS the state-based
// watchdog uses.
const startingMessage = entry.startingMessage ?? DEFAULT_STARTING_MESSAGE;
const messageEvent: z.infer<typeof MessageEvent> = {
runId: run.id,
type: "message",
messageId: await idGenerator.next(),
message: {
role: "user",
content: startingMessage,
},
subflow: [],
};
await runsRepo.appendEvents(run.id, [messageEvent]);
console.log(`[AgentRunner] Sent starting message to agent ${agentName}: "${startingMessage}"`);
// Trigger the run
await agentRuntime.trigger(run.id);
const handle = await withUseCase(
{ useCase: 'copilot_chat', subUseCase: 'scheduled' },
() => startHeadlessAgent({
agentId: agentName,
message: startingMessage,
signal: AbortSignal.timeout(TIMEOUT_MS),
}),
);
console.log(`[AgentRunner] Started turn ${handle.turnId} for agent ${agentName}: "${startingMessage}"`);
void handle.done
.then((result) => console.log(`[AgentRunner] Turn ${handle.turnId} (${agentName}) settled: ${result.outcome.status}`))
.catch((error) => console.error(`[AgentRunner] Turn ${handle.turnId} (${agentName}) errored:`, error instanceof Error ? error.message : error));
// Calculate next run time
const nextRunAt = calculateNextRunAt(entry.schedule);
@ -264,9 +251,6 @@ async function checkForTimeouts(
async function pollAndRun(): Promise<void> {
const scheduleRepo = container.resolve<IAgentScheduleRepo>("agentScheduleRepo");
const stateRepo = container.resolve<IAgentScheduleStateRepo>("agentScheduleStateRepo");
const runsRepo = container.resolve<IRunsRepo>("runsRepo");
const agentRuntime = container.resolve<IAgentRuntime>("agentRuntime");
const idGenerator = container.resolve<IMonotonicallyIncreasingIdGenerator>("idGenerator");
// Load config and state
let config: z.infer<typeof AgentScheduleConfig>;
@ -314,7 +298,7 @@ async function pollAndRun(): Promise<void> {
if (shouldRunNow(entry, agentState)) {
// Run agent (don't await - let it run in background)
runAgent(agentName, entry, stateRepo, runsRepo, agentRuntime, idGenerator).catch((error) => {
runAgent(agentName, entry, stateRepo).catch((error) => {
console.error(`[AgentRunner] Unhandled error in runAgent for ${agentName}:`, error);
});
}

View file

@ -0,0 +1,25 @@
import container from "../di/container.js";
import {
type HeadlessAgentHandle,
type HeadlessAgentOptions,
type HeadlessAgentResult,
type IHeadlessAgentRunner,
} from "./headless.js";
function runner(): IHeadlessAgentRunner {
return container.resolve<IHeadlessAgentRunner>("headlessAgentRunner");
}
export function startHeadlessAgent(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentHandle> {
return runner().start(options);
}
export function runHeadlessAgent(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentResult & { turnId: string }> {
return runner().run(options);
}
export { toolInputPaths } from "./headless.js";

View file

@ -0,0 +1,300 @@
import { describe, expect, it, vi } from "vitest";
import type { z } from "zod";
import { reduceTurn, type TurnEvent } from "@x/shared/dist/turns.js";
import type {
CreateTurnInput,
ITurnRuntime,
TurnExecution,
TurnOutcome,
} from "../turns/api.js";
import {
HeadlessRunError,
HeadlessAgentRunner,
assistantText,
lastAssistantText,
toolInputPaths,
} from "./headless.js";
import type { IDefaultModelResolver } from "../models/default-model-resolver.js";
type TEvent = z.infer<typeof TurnEvent>;
const TURN_ID = "2026-07-02T10-00-00Z-0000001-000";
const TS = "2026-07-02T10:00:00Z";
const echoTool = {
toolId: "builtin:file-editText",
name: "file-editText",
description: "Edit",
inputSchema: {},
execution: "sync" as const,
requiresHuman: false,
};
function turnLog(opts: {
responseText?: string;
toolCalls?: Array<{ id: string; name: string; input: unknown; invoked: boolean }>;
failed?: string;
}): TEvent[] {
const events: TEvent[] = [
{
type: "turn_created",
schemaVersion: 1,
turnId: TURN_ID,
ts: TS,
sessionId: null,
agent: {
requested: { agentId: "worker" },
resolved: {
agentId: "worker",
systemPrompt: "SYS",
model: { provider: "fake", model: "m" },
tools: [echoTool],
},
},
context: [],
input: { role: "user", content: "go" },
config: { autoPermission: true, humanAvailable: false, maxModelCalls: 20 },
},
{
type: "model_call_requested",
turnId: TURN_ID,
ts: TS,
modelCallIndex: 0,
request: { messages: ["input"], parameters: {} },
},
];
const toolCalls = opts.toolCalls ?? [];
if (toolCalls.length > 0) {
events.push({
type: "model_call_completed",
turnId: TURN_ID,
ts: TS,
modelCallIndex: 0,
message: {
role: "assistant",
content: toolCalls.map((tc) => ({
type: "tool-call" as const,
toolCallId: tc.id,
toolName: tc.name,
arguments: tc.input as never,
})),
},
finishReason: "tool-calls",
usage: {},
});
for (const tc of toolCalls) {
if (!tc.invoked) continue;
events.push({
type: "tool_invocation_requested",
turnId: TURN_ID,
ts: TS,
toolCallId: tc.id,
toolId: "builtin:" + tc.name,
toolName: tc.name,
execution: "sync",
input: tc.input as never,
});
events.push({
type: "tool_result",
turnId: TURN_ID,
ts: TS,
toolCallId: tc.id,
toolName: tc.name,
source: "sync",
result: { output: "ok", isError: false },
});
}
}
if (opts.failed) {
events.push({
type: "model_call_failed",
turnId: TURN_ID,
ts: TS,
modelCallIndex: 0,
error: opts.failed,
});
events.push({ type: "turn_failed", turnId: TURN_ID, ts: TS, error: opts.failed, usage: {} });
} else if (opts.responseText !== undefined && toolCalls.length === 0) {
events.push({
type: "model_call_completed",
turnId: TURN_ID,
ts: TS,
modelCallIndex: 0,
message: { role: "assistant", content: opts.responseText },
finishReason: "stop",
usage: {},
});
events.push({
type: "turn_completed",
turnId: TURN_ID,
ts: TS,
output: { role: "assistant", content: opts.responseText },
finishReason: "stop",
usage: {},
});
}
return events;
}
class FakeRuntime implements ITurnRuntime {
createInputs: CreateTurnInput[] = [];
constructor(
private readonly log: TEvent[],
private readonly outcome: TurnOutcome,
) {}
async createTurn(input: CreateTurnInput): Promise<string> {
this.createInputs.push(input);
return TURN_ID;
}
advanceTurn(): TurnExecution {
return {
events: (async function* () {})(),
outcome: Promise.resolve(this.outcome),
};
}
async getTurn() {
return { turnId: TURN_ID, events: this.log };
}
}
const completedOutcome: TurnOutcome = {
status: "completed",
output: { role: "assistant", content: "done" },
finishReason: "stop",
usage: {},
};
function createRunner(
runtime: ITurnRuntime,
resolve = vi.fn(async () => ({
provider: "default-provider",
model: "default-model",
})),
): { runner: HeadlessAgentRunner; resolve: IDefaultModelResolver["resolve"] } {
return {
runner: new HeadlessAgentRunner({
turnRuntime: runtime,
defaultModelResolver: { resolve },
}),
resolve,
};
}
describe("headless agent helpers", () => {
it("assistantText handles string and parts content", () => {
expect(assistantText({ role: "assistant", content: "hi" })).toBe("hi");
expect(
assistantText({
role: "assistant",
content: [
{ type: "reasoning", text: "hmm" },
{ type: "text", text: "a" },
{ type: "text", text: "b" },
],
}),
).toBe("ab");
expect(assistantText({ role: "assistant", content: "" })).toBeNull();
});
it("lastAssistantText returns the final assistant text in the transcript", () => {
const state = reduceTurn(turnLog({ responseText: "the summary" }));
expect(lastAssistantText(state)).toBe("the summary");
expect(lastAssistantText(reduceTurn(turnLog({ failed: "boom" })))).toBeNull();
});
it("toolInputPaths collects paths of invoked calls only", () => {
const state = reduceTurn(
turnLog({
toolCalls: [
{ id: "a", name: "file-editText", input: { path: "notes/x.md" }, invoked: true },
{ id: "b", name: "file-editText", input: { path: "notes/y.md" }, invoked: true },
{ id: "c", name: "file-writeText", input: { path: "notes/z.md" }, invoked: true },
{ id: "d", name: "file-editText", input: { path: "never-ran.md" }, invoked: false },
],
}),
);
expect(toolInputPaths(state, ["file-editText"])).toEqual(
new Set(["notes/x.md", "notes/y.md"]),
);
expect(toolInputPaths(state, ["file-writeText"])).toEqual(new Set(["notes/z.md"]));
});
});
describe("HeadlessAgentRunner", () => {
it("creates a standalone auto-permission turn and returns the id before settling", async () => {
const runtime = new FakeRuntime(turnLog({ responseText: "the summary" }), completedOutcome);
const { runner, resolve } = createRunner(runtime);
const handle = await runner.start({
agentId: "worker",
message: "go",
model: "m",
provider: "fake",
});
expect(handle.turnId).toBe(TURN_ID);
expect(resolve).not.toHaveBeenCalled();
expect(runtime.createInputs[0]).toMatchObject({
agent: { agentId: "worker", overrides: { model: { provider: "fake", model: "m" } } },
sessionId: null,
context: [],
input: { role: "user", content: "go" },
config: { autoPermission: true, humanAvailable: false },
});
const result = await handle.done;
expect(result.outcome.status).toBe("completed");
expect(result.summary).toBe("the summary");
});
it("omits the model override when neither model nor provider is set", async () => {
const runtime = new FakeRuntime(turnLog({ responseText: "ok" }), completedOutcome);
const { runner, resolve } = createRunner(runtime);
await runner.start({ agentId: "worker", message: "go" });
expect(resolve).not.toHaveBeenCalled();
expect(runtime.createInputs[0].agent.overrides).toBeUndefined();
});
it("uses an injected default only for a partial model override", async () => {
const runtime = new FakeRuntime(turnLog({ responseText: "ok" }), completedOutcome);
const { runner, resolve } = createRunner(runtime);
await runner.start({
agentId: "worker",
message: "go",
model: "custom-model",
});
expect(resolve).toHaveBeenCalledOnce();
expect(runtime.createInputs[0].agent.overrides).toEqual({
model: { provider: "default-provider", model: "custom-model" },
});
});
it("throwOnError rejects done with HeadlessRunError on failed outcomes", async () => {
const runtime = new FakeRuntime(turnLog({ failed: "provider exploded" }), {
status: "failed",
error: "provider exploded",
usage: {},
});
const { runner } = createRunner(runtime);
const handle = await runner.start({
agentId: "worker",
message: "go",
throwOnError: true,
});
await expect(handle.done).rejects.toThrowError(HeadlessRunError);
await expect(handle.done).rejects.toThrowError("provider exploded");
});
it("without throwOnError a failed outcome resolves (old wait semantics)", async () => {
const runtime = new FakeRuntime(turnLog({ failed: "boom" }), {
status: "failed",
error: "boom",
usage: {},
});
const { runner } = createRunner(runtime);
const handle = await runner.start({ agentId: "worker", message: "go" });
const result = await handle.done;
expect(result.outcome.status).toBe("failed");
expect(result.summary).toBeNull();
});
});

View file

@ -0,0 +1,182 @@
import type { z } from "zod";
import type { AssistantMessage } from "@x/shared/dist/message.js";
import {
reduceTurn,
type TurnState,
} from "@x/shared/dist/turns.js";
import type { ITurnRuntime, TurnOutcome } from "../turns/api.js";
import type { IDefaultModelResolver } from "../models/default-model-resolver.js";
// Drop-in replacement for the old headless runs pattern
// (createRun → createMessage → waitForRunCompletion → extractAgentResponse):
// one standalone turn per invocation (sessionId null, automatic permissions,
// no human). HeadlessAgentRunner.start returns the turn id immediately (callers
// record it in pointer files / bus events before completion); `done` settles
// with the outcome, the reduced turn state, and the final assistant text.
export class HeadlessRunError extends Error {
constructor(
message: string,
readonly turnId: string,
readonly outcome: TurnOutcome,
) {
super(message);
this.name = "HeadlessRunError";
}
}
export interface HeadlessAgentOptions {
agentId: string;
message: string;
// Model id; when set without provider, the app-default provider applies.
model?: string;
provider?: string;
maxModelCalls?: number;
signal?: AbortSignal;
// Old waitForRunCompletion({ throwOnError: true }) semantics: `done`
// rejects with HeadlessRunError unless the turn completes.
throwOnError?: boolean;
}
export interface HeadlessAgentResult {
outcome: TurnOutcome;
state: TurnState;
// Last assistant text in the transcript (old extractAgentResponse).
summary: string | null;
}
export interface HeadlessAgentHandle {
turnId: string;
done: Promise<HeadlessAgentResult>;
}
export interface HeadlessAgentRunnerDependencies {
turnRuntime: ITurnRuntime;
defaultModelResolver: IDefaultModelResolver;
}
export interface IHeadlessAgentRunner {
start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle>;
run(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentResult & { turnId: string }>;
}
export function assistantText(
message: z.infer<typeof AssistantMessage>,
): string | null {
const content = message.content;
if (typeof content === "string") {
return content || null;
}
const text = content
.map((part) => (part.type === "text" ? part.text : ""))
.join("");
return text || null;
}
export function lastAssistantText(state: TurnState): string | null {
for (let i = state.modelCalls.length - 1; i >= 0; i--) {
const response = state.modelCalls[i].response;
if (response) {
const text = assistantText(response);
if (text) {
return text;
}
}
}
return null;
}
// Paths passed to the given file tools across the turn — replaces the old
// pattern of subscribing to the run bus for tool-invocation events. Only
// actually-invoked calls count (denied/unknown calls never ran).
export function toolInputPaths(
state: TurnState,
toolNames: string[],
): Set<string> {
const paths = new Set<string>();
for (const toolCall of state.toolCalls) {
if (!toolNames.includes(toolCall.toolName) || !toolCall.invocation) {
continue;
}
const input = toolCall.input as { path?: unknown } | null | undefined;
if (input && typeof input === "object" && typeof input.path === "string") {
paths.add(input.path);
}
}
return paths;
}
export class HeadlessAgentRunner implements IHeadlessAgentRunner {
private readonly turnRuntime: ITurnRuntime;
private readonly defaultModelResolver: IDefaultModelResolver;
constructor({
turnRuntime,
defaultModelResolver,
}: HeadlessAgentRunnerDependencies) {
this.turnRuntime = turnRuntime;
this.defaultModelResolver = defaultModelResolver;
}
async start(options: HeadlessAgentOptions): Promise<HeadlessAgentHandle> {
let modelOverride: { provider: string; model: string } | undefined;
if (options.model && options.provider) {
modelOverride = { provider: options.provider, model: options.model };
} else if (options.model || options.provider) {
const defaults = await this.defaultModelResolver.resolve();
modelOverride = {
provider: options.provider ?? defaults.provider,
model: options.model ?? defaults.model,
};
}
const turnId = await this.turnRuntime.createTurn({
agent: {
agentId: options.agentId,
...(modelOverride ? { overrides: { model: modelOverride } } : {}),
},
sessionId: null,
context: [],
input: { role: "user", content: options.message },
config: {
autoPermission: true,
humanAvailable: false,
...(options.maxModelCalls === undefined
? {}
: { maxModelCalls: options.maxModelCalls }),
},
});
const execution = this.turnRuntime.advanceTurn(turnId, undefined, {
signal: options.signal,
});
const done = execution.outcome.then(async (outcome) => {
const state = reduceTurn(
(await this.turnRuntime.getTurn(turnId)).events,
);
if (options.throwOnError && outcome.status !== "completed") {
throw new HeadlessRunError(
outcome.status === "failed"
? outcome.error
: `turn ${outcome.status}`,
turnId,
outcome,
);
}
return { outcome, state, summary: lastAssistantText(state) };
});
// The handle may be used fire-and-forget; rejections surface when awaited.
done.catch(() => undefined);
return { turnId, done };
}
async run(
options: HeadlessAgentOptions,
): Promise<HeadlessAgentResult & { turnId: string }> {
const handle = await this.start(options);
const result = await handle.done;
return { turnId: handle.turnId, ...result };
}
}

View file

@ -114,7 +114,7 @@ function filePermissionTargets(toolName: string, args: Record<string, unknown>):
}
}
async function getToolPermissionMetadata(
export async function getToolPermissionMetadata(
toolCall: z.infer<typeof ToolCallPart>,
underlyingTool: z.infer<typeof ToolAttachment>,
sessionAllowedCommands: Set<string>,
@ -172,7 +172,7 @@ async function getToolPermissionMetadata(
};
}
function loadUserWorkDir(runId: string): string | null {
export function loadUserWorkDir(runId: string): string | null {
try {
const file = workDirConfigFile(runId);
if (!fs.existsSync(file)) return null;
@ -185,7 +185,7 @@ function loadUserWorkDir(runId: string): string | null {
}
}
function loadAgentNotesContext(): string | null {
export function loadAgentNotesContext(): string | null {
const sections: string[] = [];
const userFile = path.join(AGENT_NOTES_DIR, 'user.md');
@ -327,6 +327,129 @@ If Middle pane state is note, the supplied path and content are available so you
If Middle pane state is browser, only the URL and page title are supplied; the page content itself is NOT included. If you need the page content to answer, use the browser tools available to you to read the page. The user may or may not be talking about this page. Only reference or act on this page when the user's message clearly relates to it, such as "this page", "this article", "what I'm looking at", "this site", or "summarize this". For unrelated questions, ignore this page entirely and answer normally. Do not mention that you can see the browser unless it is relevant to the answer.`;
export interface ComposeSystemInstructionsInput {
instructions: string;
agentNotesContext: string | null;
userWorkDir: string | null;
voiceInput: boolean;
voiceOutput: 'summary' | 'full' | null;
searchEnabled: boolean;
codeMode: 'claude' | 'codex' | null;
codeCwd: string | null;
// Optional so legacy callers (old streamAgent path) are unaffected.
videoMode?: boolean;
coachMode?: boolean;
}
// System-prompt assembly, extracted verbatim from streamAgent so the new turn
// runtime's agent resolver composes byte-identical prompts. Pure: callers
// load agent notes / work dir themselves.
export function composeSystemInstructions({
instructions,
agentNotesContext,
userWorkDir,
voiceInput,
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
videoMode,
coachMode,
}: ComposeSystemInstructionsInput): string {
let instructionsWithDateTime = `${instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
if (userWorkDir) {
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
if (voiceInput) {
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
}
if (videoMode) {
instructionsWithDateTime += `\n\n# Video Mode (Live Camera)
The user has turned on video mode: their webcam is on, and their messages arrive with a series of live webcam frames (ordered oldest to newest) captured while they were speaking or typing. The frames are a live view of the user themselves not a document or file to analyze.
How to use the frames:
- Use them for what visual awareness genuinely adds: the user's expressions, body language, posture, gestures, eye contact with the camera, visible energy, anything they hold up to the camera, and their surroundings when relevant.
- Compare across frames to notice change over time within a message (e.g. increasingly slouched, started smiling, looked away for most of the message).
- When the user practices something performative (a pitch, presentation, interview, talk) or asks for delivery feedback, give specific, actionable coaching grounded in what you actually see cite concrete observations ("in the last few frames you looked down and away from the camera") rather than generic advice. Cover posture, eye contact, facial expressiveness, gesturing, and visible energy.
- If they show something to the camera (an object, document, whiteboard), read or describe it and respond accordingly.
Driving the app:
- You can control the Rowboat app the user is looking at via the app-navigation tool (load the app-navigation skill first): open views, READ a view's contents as data (emails, background agents, chat history), and open specific items on their screen.
- When the user asks about anything that lives inside Rowboat ("what emails do I have?", "what agents are running?", "open the one from Arjun"), prefer driving over describing: read-view shows the view on their screen while returning its data, then answer out loud briefly; open-item when they pick one. Narrate as you act ("pulling up your inbox…"). Reading a view's data beats squinting at screen-share frames — it's exact.
Screen sharing:
- The user may also share their screen. Screen-share frames arrive in a separately labeled group after the webcam frames; they show the user's screen, not the user.
- When screen frames are present, treat them as the primary subject: the user is usually asking about, or working on, what's visible there. Read the screen carefully code, documents, error messages, UI state and help with it concretely.
- The LAST screen frame is the most current view of their screen; earlier ones show how it changed while they spoke.
- Frames are downscaled captures: small text may be hard to read. If something crucial is illegible, say what you need ("zoom into the error message" / "make that panel bigger") rather than guessing.
Etiquette:
- Do not narrate or list what you see in every response. Bring up visual observations only when relevant to the user's request or clearly worth mentioning.
- Never comment on the user's physical appearance, attractiveness, or personal attributes visual feedback is strictly about delivery, expression, and body language.
- Frames are periodic snapshots, not continuous video; moments between frames are missing. Don't claim certainty about motion you couldn't have seen.`;
}
if (coachMode) {
instructionsWithDateTime += `\n\n# Practice Session (Coach Mode)
The user started a practice session: they are rehearsing something performative a pitch, presentation, interview answer, or talk and want live coaching. You are their coach for this session.
How to coach:
- Watch and listen for delivery: pacing, filler words, rambling, structure, and (from the webcam frames) posture, eye contact with the camera, facial expressiveness, gesturing, and visible energy.
- After each take or answer, give brief, specific, actionable feedback: 2-3 concrete observations, quoting what you actually saw or heard ("you looked down during the ask", "the opening 'um, so, basically' undercuts your hook"). Then let them go again.
- If they are clearly mid-flow, keep any interjection to one short sentence or stay silent and save it for the break.
- Ask what they're practicing for at the start if it isn't obvious (investor pitch? interview? conference talk?) and tailor feedback to that audience.
- When they say they're done or wrap up, give a structured debrief: what's working, the top 3 things to improve, and one concrete drill or reframe to try next time.
- Be encouraging but honest vague praise wastes their rehearsal time. Never comment on physical appearance; delivery, expression, and body language only.`;
}
if (voiceOutput === 'summary') {
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
if (codeMode) {
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
The chip is the single source of truth for which agent runs:
- Do NOT carry over a different agent from earlier in this thread even if a previous run used the other agent, use **${agentDisplay}** now.
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
return instructionsWithDateTime;
}
export interface IAgentRuntime {
trigger(runId: string): Promise<void>;
}
@ -861,15 +984,27 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
providerOptions,
});
} else {
// New content parts array — collapse to text for LLM
// New content parts array — collapse text/attachments to text
// for the LLM; inline image parts (video-mode webcam and
// screen-share frames) are passed through as real multimodal
// image parts, grouped under labeled text headers so the
// model knows which images show the user vs their screen.
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
const attachmentLines: string[] = [];
type EncodedImagePart = { type: "image"; image: string; mediaType: string };
const cameraParts: EncodedImagePart[] = [];
const screenParts: EncodedImagePart[] = [];
const frameTimes: string[] = [];
for (const part of msg.content) {
if (part.type === "attachment") {
const sizeStr = part.size ? `, ${formatBytes(part.size)}` : '';
const lineStr = part.lineNumber ? ` (line ${part.lineNumber})` : '';
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
} else if (part.type === "image") {
const target = part.source === "screen" ? screenParts : cameraParts;
target.push({ type: "image", image: part.data, mediaType: part.mediaType });
if (part.capturedAt) frameTimes.push(part.capturedAt);
} else {
textSegments.push(part.text);
}
@ -883,11 +1018,38 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
}
}
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
const imageCount = cameraParts.length + screenParts.length;
if (imageCount > 0) {
const span = frameTimes.length >= 2
? ` spanning ${frameTimes[0]} to ${frameTimes[frameTimes.length - 1]}`
: frameTimes.length === 1
? ` captured at ${frameTimes[0]}`
: '';
const kinds: string[] = [];
if (cameraParts.length > 0) kinds.push(`${cameraParts.length} live webcam frame${cameraParts.length === 1 ? '' : 's'} of the user`);
if (screenParts.length > 0) kinds.push(`${screenParts.length} frame${screenParts.length === 1 ? '' : 's'} of the user's shared screen`);
textSegments.push(`[Video mode: ${kinds.join(' and ')} attached below, each group oldest to newest,${span ? span + ',' : ''} recorded while they composed this message.]`);
const content: Array<{ type: "text"; text: string } | EncodedImagePart> = [
{ type: "text", text: textSegments.join("\n") },
];
if (cameraParts.length > 0) {
content.push({ type: "text", text: "Webcam frames (oldest to newest):" }, ...cameraParts);
}
if (screenParts.length > 0) {
content.push({ type: "text", text: "Screen-share frames (oldest to newest):" }, ...screenParts);
}
result.push({
role: "user",
content,
providerOptions,
});
} else {
result.push({
role: "user",
content: textSegments.join("\n"),
providerOptions,
});
}
}
break;
}
@ -1423,70 +1585,17 @@ export async function* streamAgent({
loopLogger.log('running llm turn');
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
let instructionsWithDateTime = `${agent.instructions}\n\n${USER_CONTEXT_SYSTEM_INSTRUCTIONS}`;
// Inject Agent Notes context for copilot
if (state.agentName === 'copilot' || state.agentName === 'rowboatx') {
const agentNotesContext = loadAgentNotesContext();
if (agentNotesContext) {
instructionsWithDateTime += `\n\n${agentNotesContext}`;
}
const userWorkDir = loadUserWorkDir(runId);
if (userWorkDir) {
loopLogger.log('injecting user work directory', userWorkDir);
instructionsWithDateTime += `\n\n# User Work Directory
The user has chosen the following directory as their current **work directory**:
\`${userWorkDir}\`
Treat this as the **default location** for file operations whenever the user refers to files generically:
- "list the files", "show me what's in here", "what's the latest report" list or look in the work directory.
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
Do not announce the work directory unless it's relevant. Just use it.`;
}
}
if (voiceInput) {
loopLogger.log('voice input enabled, injecting voice input prompt');
instructionsWithDateTime += `\n\n# Voice Input\nThe user's message was transcribed from speech. Be aware that:\n- There may be transcription errors. Silently correct obvious ones (e.g. homophones, misheard words). If an error is genuinely ambiguous, briefly mention your interpretation (e.g. "I'm assuming you meant X").\n- Spoken messages are often long-winded. The user may ramble, repeat themselves, or correct something they said earlier in the same message. Focus on their final intent, not every word verbatim.`;
}
if (voiceOutput === 'summary') {
loopLogger.log('voice output enabled (summary mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output (MANDATORY — READ THIS FIRST)\nThe user has voice output enabled. THIS IS YOUR #1 PRIORITY: you MUST start your response with <voice></voice> tags. If your response does not begin with <voice> tags, the user will hear nothing — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. Do not start with markdown, headings, or any other text. The literal first characters of your response must be "<voice>".\n2. Place ALL <voice> tags at the BEGINNING of your response, before any detailed content. Do NOT intersperse <voice> tags throughout the response.\n3. Wrap EACH spoken sentence in its own separate <voice> tag so it can be spoken incrementally. Do NOT wrap everything in a single <voice> block.\n4. Use voice as a TL;DR and navigation aid — do NOT read the entire response aloud.\n5. After all <voice> tags, you may include detailed written content (markdown, tables, code, etc.) that will be shown visually but not spoken.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things: the Q2 roadmap timeline, hiring for the backend role, and the client demo next week.</voice>\n<voice>I've pulled out the key details and action items below — the demo prep notes are at the end.</voice>\n\n## Meeting with Alex — March 11\n### Roadmap\n- Agreed to push Q2 launch to April 15...\n(detailed written content continues)\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You have five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you requested and Taylor flagged a contract issue.</voice>\n<voice>There's also a warm intro from a VC partner connecting you with someone at a prospective customer.</voice>\n<voice>I've drafted responses for three of them. The details and drafts are below.</voice>\n\n(email blocks, tables, and detailed content follow)\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a pretty packed day — seven meetings starting with standup at 9.</voice>\n<voice>The big ones are your investor call at 11, lunch with a partner from your lead VC at 12:30, and a customer call at 4.</voice>\n<voice>Your only free block for deep work is 2:30 to 4.</voice>\n\n(calendar block with full event details follows)\n\nExample 4 — User asks: "draft an email to Sam with our metrics"\n\n<voice>Done — I've drafted the email to Sam with your latest WAU and churn numbers.</voice>\n<voice>Take a look at the draft below and send it when you're ready.</voice>\n\n(email block with draft follows)\n\nREMEMBER: If you do not start with <voice> tags, the user hears silence. Always speak first, then write.`;
} else if (voiceOutput === 'full') {
loopLogger.log('voice output enabled (full mode), injecting voice output prompt');
instructionsWithDateTime += `\n\n# Voice Output — Full Read-Aloud (MANDATORY — READ THIS FIRST)\nThe user wants your ENTIRE response spoken aloud. THIS IS YOUR #1 PRIORITY: every single sentence must be wrapped in <voice></voice> tags. If you write anything outside <voice> tags, the user will not hear it — which is a broken experience. NEVER skip this.\n\nRules:\n1. YOUR VERY FIRST OUTPUT MUST BE A <voice> TAG. No exceptions. The literal first characters of your response must be "<voice>".\n2. Wrap EACH sentence in its own separate <voice> tag so it can be spoken incrementally.\n3. Write your response in a natural, conversational style suitable for listening — no markdown headings, bullet points, or formatting symbols. Use plain spoken language.\n4. Structure the content as if you are speaking to the user directly. Use transitions like "first", "also", "one more thing" instead of visual formatting.\n5. EVERY sentence MUST be inside a <voice> tag. Do not leave ANY content outside <voice> tags. If it's not in a <voice> tag, the user cannot hear it.\n\n## Examples\n\nExample 1 — User asks: "what happened in my meeting with Alex yesterday?"\n\n<voice>Your meeting with Alex covered three main things.</voice>\n<voice>First, you discussed the Q2 roadmap timeline and agreed to push the launch to April.</voice>\n<voice>Second, you talked about hiring for the backend role — Alex will send over two candidates by Friday.</voice>\n<voice>And lastly, the client demo is next week on Thursday at 2pm, and you're handling the intro slides.</voice>\n\nExample 2 — User asks: "summarize my emails"\n\n<voice>You've got five new emails since this morning.</voice>\n<voice>Two are from your team — Jordan sent the RFC you asked for, and Taylor flagged a contract issue that needs your sign-off.</voice>\n<voice>There's a warm intro from a VC partner connecting you with an engineering lead at a potential customer.</voice>\n<voice>And someone from a prospective client wants to confirm your API tier before your call this afternoon.</voice>\n<voice>I've drafted replies for three of them — the metrics update, the intro, and the API question.</voice>\n<voice>The only one I left for you is Taylor's contract redline, since that needs your judgment on the liability cap.</voice>\n\nExample 3 — User asks: "what's on my calendar today?"\n\n<voice>You've got a packed day — seven meetings starting with standup at 9.</voice>\n<voice>The highlights are your investor call at 11, lunch with a VC partner at 12:30, and a customer call at 4.</voice>\n<voice>Your only open block for deep work is 2:30 to 4, so plan accordingly.</voice>\n<voice>Oh, and your 1-on-1 with your co-founder is at 5:30 — that's a walking meeting.</voice>\n\nExample 4 — User asks: "how are our metrics looking?"\n\n<voice>Metrics are looking strong this week.</voice>\n<voice>You hit 2,573 weekly active users, which is up 12% week over week.</voice>\n<voice>That means you've crossed the 2,500 milestone — worth calling out in your next investor update.</voice>\n<voice>Churn is down to 4.1%, improving month over month.</voice>\n<voice>The trailing 8-week compound growth rate is about 10%.</voice>\n\nREMEMBER: Start with <voice> immediately. No preamble, no markdown before it. Speak first.`;
}
if (searchEnabled) {
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
if (codeMode) {
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
instructionsWithDateTime += `\n\n# Code Mode (Active) — Agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). For EVERY coding task this turn, use **${agentDisplay}**, and narrate that agent ("Using ${agentDisplay} to …").
The chip is the single source of truth for which agent runs:
- Do NOT carry over a different agent from earlier in this thread even if a previous run used the other agent, use **${agentDisplay}** now.
- Do NOT switch agents based on an in-chat text request ("use codex", "switch to claude"). The agent only changes when the user toggles the chip; if they ask in chat, tell them to toggle the chip.
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
const composeCopilotContext = state.agentName === 'copilot' || state.agentName === 'rowboatx';
const instructionsWithDateTime = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: composeCopilotContext ? loadAgentNotesContext() : null,
userWorkDir: composeCopilotContext ? loadUserWorkDir(runId) : null,
voiceInput,
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
});
let streamError: string | null = null;
for await (const event of streamLlm(
model,

View file

@ -1,6 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session' | 'app_llm_generate' | 'app_copilot_run';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'meeting_prep' | 'knowledge_sync' | 'code_session' | 'app_llm_generate' | 'app_copilot_run';
export interface UseCaseContext {
useCase: UseCase;

View file

@ -114,7 +114,7 @@ ${codeModeEnabled
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
**App Control (drive the app):** You can drive the Rowboat UI the user is looking at open any view (email, meetings, background agents, chat history, knowledge, workspace, code, bases, graph), READ what a view contains (\`read-view\` returns emails / background agents / past chats as data while showing the view on screen), and open specific items (an email thread, a note, an agent, a past chat). When users ask to open, show, find, or ask about anything that lives inside Rowboat, load the \`app-navigation\` skill first — it documents the show-while-telling pattern. This matters most on calls: navigate so the user sees what you see, then answer briefly.
**Background Tasks (Self-Running Work):** Rowboat can run *background tasks* persistent instructions the agent fires on a schedule and/or in response to incoming emails / calendar events. A bg-task either maintains a snapshot in its \`index.md\` (digest, dashboard, rolling summary) or performs a recurring side-effect (send a Slack message, draft an email, post to a webhook, call an API). This is the flagship surface for *anything recurring*.
@ -295,7 +295,7 @@ ${runtimeContextPrompt}
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with full text, highlights, and metadata. The \`category\` parameter defaults to \`general\` (full web search) — only use a specific category like \`news\`, \`company\`, \`research paper\` etc. when the query is clearly about that type. For everyday queries (weather, restaurants, prices, how-to), use \`general\`.
- \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`app-navigation\` - Drive the app UI: open any view, read a view's contents (emails / background agents / chat history), open specific items (email thread, note, agent, past chat), filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.**
- \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.**
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
${composioToolsLine}

View file

@ -1,82 +1,91 @@
export const skill = String.raw`
# App Navigation Skill
# App Driving Skill
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly opening notes, switching views, filtering the knowledge base, and creating saved views.
You have the **app-navigation** tool: you can DRIVE the Rowboat app the user
is looking at open any view, read what a view contains, open specific items
(an email thread, a note, a background agent, a past chat), filter the
knowledge base, and manage saved views. Navigation happens on the USER'S
screen: when you open something, they watch it open.
## The core pattern: show while telling
When the user asks about something that lives inside Rowboat ("what emails do
I have?", "what background agents are running?", "open the note about Acme"),
don't answer blind. Drive:
1. **read-view** the relevant view this returns the actual data AND
navigates the user's screen to that view at the same time.
2. Answer from the returned data, concisely.
3. If they ask about one item ("open the one from Arjun"), **open-item** it
it appears on their screen and summarize what's in it if useful.
This matters most during a call: the user is talking to you hands-free and
watching the screen. Navigate so they see what you see, and keep spoken
answers short.
## Actions
### read-view read a view's contents (and show it)
Returns the same data the view renders; the app simultaneously navigates to
that view so the user sees it.
- ` + "`view: \"email\"`" + ` → latest important inbox threads: ` + "`{ threadId, subject, from, date, unread, summary }`" + `.
Pass ` + "`query`" + ` to search instead (sender name, subject words e.g. ` + "`query: \"from Arjun\"`" + ` or just ` + "`\"Arjun\"`" + `).
- ` + "`view: \"bg-tasks\"`" + ` → background agents: ` + "`{ name, slug, active, triggers, lastRunAt, lastRunSummary, lastRunError }`" + `.
- ` + "`view: \"chat-history\"`" + ` → past chats: ` + "`{ sessionId, title, updatedAt, turnCount }`" + `.
- ` + "`limit`" + ` (optional, default 15).
For notes, meetings, and live notes use the ` + "`file-*`" + ` tools (they are
markdown files in the workspace) and then open-note / open-item to show them.
### open-item open one specific thing on screen
- ` + "`kind: \"email-thread\"`" + ` + ` + "`threadId`" + ` (from read-view email)
- ` + "`kind: \"note\"`" + ` + ` + "`path`" + `
- ` + "`kind: \"bg-task\"`" + ` + ` + "`taskName`" + ` (from read-view bg-tasks; validated against real tasks)
- ` + "`kind: \"session\"`" + ` + ` + "`sessionId`" + ` (from read-view chat-history)
### open-view just switch the screen
` + "`view`" + `: ` + "`home | email | meetings | live-notes | bg-tasks | chat-history | knowledge | workspace | code | bases | graph`" + `
Use when the user asks to "go to"/"show" a view without a question to answer.
### open-note
Open a specific knowledge file in the editor pane.
Open a knowledge file in the editor. ` + "`path`" + `: full workspace-relative path
(e.g. ` + "`knowledge/People/John Smith.md`" + `). Use ` + "`file-grep`" + ` first if unsure
of the exact path.
**When to use:** When the user asks to see, open, or view a specific note (e.g., "open John's note", "show me the Acme project page").
### update-base-view / get-base-state / create-base
Knowledge-base table control (unchanged):
- ` + "`update-base-view`" + `: ` + "`filters`" + ` (` + "`set/add/remove/clear`" + ` of ` + "`{category, value}`" + `),
` + "`sort`" + ` (` + "`{field, dir}`" + `), ` + "`search`" + `. **Never pass ` + "`columns`" + ` unless the user
explicitly asks to change columns** it overrides their layout.
- ` + "`get-base-state`" + `: available filter categories/values and note count.
- ` + "`create-base`" + `: save the current view configuration under ` + "`name`" + `.
**Parameters:**
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
## Worked examples
**Tips:**
- Use ` + "`file-grep`" + ` first to find the exact path if you're unsure of the filename.
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
**"What emails do I have?"** (on a call)
1. ` + "`app-navigation({ action: \"read-view\", view: \"email\" })`" + ` — email view opens on their screen.
2. Speak the highlights: "You've got six new ones — the ones that matter are from Arjun about the deck and from Stripe about billing."
### open-view
Switch the UI to the graph or bases view.
**"Open the one from Arjun."**
1. Find Arjun's thread in the data you already have (or ` + "`read-view`" + ` with ` + "`query: \"Arjun\"`" + `).
2. ` + "`app-navigation({ action: \"open-item\", kind: \"email-thread\", threadId: \"...\" })`" + `
3. "It's open — he's asking whether Thursday works for the pitch review."
**When to use:** When the user asks to see the knowledge graph, view all notes, or open the bases/table view.
**"What background agents do I have?"**
1. ` + "`app-navigation({ action: \"read-view\", view: \"bg-tasks\" })`" + `
2. "Three: the inbox summarizer ran an hour ago, the meeting-prep agent is active, and the Linear digest failed its last run — want me to open that one?"
**Parameters:**
- ` + "`view`" + `: ` + "`\"graph\"`" + ` or ` + "`\"bases\"`" + `
**"Show me all active customers"**
1. ` + "`get-base-state`" + ` to see available categories, then
2. ` + "`update-base-view`" + ` with ` + "`filters.set: [{ category: \"relationship\", value: \"customer\" }]`" + `
### update-base-view
Change filters, columns, sort order, or search in the bases (table) view.
**When to use:** When the user asks to find, filter, sort, or search notes. Examples: "show me all active customers", "filter by topic=hiring", "sort by name", "search for pricing".
**Parameters:**
- ` + "`filters`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, ` + "`remove`" + `, or ` + "`clear`" + ` each takes an array of ` + "`{ category, value }`" + ` pairs.
- ` + "`set`" + `: Replace ALL current filters with these.
- ` + "`add`" + `: Append filters without removing existing ones.
- ` + "`remove`" + `: Remove specific filters.
- ` + "`clear: true`" + `: Remove all filters.
- ` + "`columns`" + `: Object with ` + "`set`" + `, ` + "`add`" + `, or ` + "`remove`" + ` each takes an array of column names (frontmatter keys).
- ` + "`sort`" + `: ` + "`{ field, dir }`" + ` where dir is ` + "`\"asc\"`" + ` or ` + "`\"desc\"`" + `.
- ` + "`search`" + `: Free-text search string.
**Tips:**
- If unsure what categories/values are available, call ` + "`get-base-state`" + ` first.
- For "show me X", prefer ` + "`filters.set`" + ` to start fresh rather than ` + "`filters.add`" + `.
- Categories come from frontmatter keys (e.g., relationship, status, topic, type).
- **CRITICAL: Do NOT pass ` + "`columns`" + ` unless the user explicitly asks to show/hide specific columns.** Omit the ` + "`columns`" + ` parameter entirely when only filtering, sorting, or searching. Passing ` + "`columns`" + ` will override the user's current column layout and can make the view appear empty.
### get-base-state
Retrieve information about what's in the knowledge base available filter categories, values, and note count.
**When to use:** When you need to know what properties exist before filtering, or when the user asks "what can I filter by?", "how many notes are there?", etc.
**Parameters:**
- ` + "`base_name`" + ` (optional): Name of a saved base to inspect.
### create-base
Save the current view configuration as a named base.
**When to use:** When the user asks to save a filtered view, create a saved search, or says "save this as [name]".
**Parameters:**
- ` + "`name`" + `: Human-readable name for the base.
## Workflow Example
1. User: "Show me all people who are customers"
2. First, check what properties are available:
` + "`app-navigation({ action: \"get-base-state\" })`" + `
3. Apply filters based on the available properties:
` + "`app-navigation({ action: \"update-base-view\", filters: { set: [{ category: \"relationship\", value: \"customer\" }] } })`" + `
4. If the user wants to save it:
` + "`app-navigation({ action: \"create-base\", name: \"Customers\" })`" + `
## Important Notes
- The ` + "`update-base-view`" + ` action will automatically navigate to the bases view if the user isn't already there.
- ` + "`open-note`" + ` validates that the file exists before navigating.
- Filter categories and values come from frontmatter in knowledge files.
- **Never send ` + "`columns`" + ` or ` + "`sort`" + ` with ` + "`update-base-view`" + ` unless the user specifically asks to change them.** Only pass the parameters you intend to change omitted parameters are left untouched.
## Notes
- read-view/open-view/open-item change what the user is looking at that is
the point, but don't bounce their screen around needlessly; navigate when
it serves the question.
- open-note and open-item validate the target exists before navigating.
- update-base-view auto-navigates to the bases view.
`;
export default skill;

View file

@ -24,6 +24,9 @@ import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
import * as gitService from "../../code-mode/git/service.js";
import { listImportantThreads, searchThreads } from "../../knowledge/sync_gmail.js";
import { listTasks as listBackgroundTasks } from "../../background-tasks/fileops.js";
import type { ISessions } from "../../sessions/api.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the
@ -1068,15 +1071,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// ============================================================================
'app-navigation': {
description: 'Control the app UI - navigate to notes, switch views, filter/search the knowledge base, and manage saved views.',
description: 'Drive the Rowboat app UI: navigate to any view, read what a view contains (emails, background agents, chat history), open specific items (an email thread, a note, an agent, a past chat), filter/search the knowledge base, and manage saved views. Use it to SHOW the user things while telling them — navigation happens on their screen.',
inputSchema: z.object({
action: z.enum(["open-note", "open-view", "open-app", "update-base-view", "get-base-state", "create-base"]).describe("The navigation action to perform"),
action: z.enum(["open-note", "open-view", "open-app", "read-view", "open-item", "update-base-view", "get-base-state", "create-base"]).describe("The navigation action to perform"),
// open-note
path: z.string().optional().describe("Knowledge file path for open-note, e.g. knowledge/People/John.md"),
// open-app
appId: z.string().optional().describe("Mini App id (folder name under ~/.rowboat/apps) for open-app — opens it in the middle pane under Mini Apps."),
// open-view
view: z.enum(["bases", "graph"]).optional().describe("Which view to open (for open-view action)"),
appId: z.string().optional().describe("App folder slug under ~/.rowboat/apps (for open-app) — opens the app in the middle pane."),
// open-view / read-view
view: z.enum(["home", "email", "meetings", "live-notes", "bg-tasks", "chat-history", "knowledge", "workspace", "code", "bases", "graph"]).optional().describe("Which view to open (open-view) or read (read-view; supported for read: email, bg-tasks, chat-history)"),
// read-view (email)
query: z.string().optional().describe("For read-view on email: search query (sender name, subject words, etc.). Omit to list the latest important inbox threads."),
limit: z.number().int().min(1).max(50).optional().describe("For read-view: max items to return (default 15)"),
// open-item
kind: z.enum(["email-thread", "note", "bg-task", "session"]).optional().describe("What to open (for open-item)"),
threadId: z.string().optional().describe("Gmail thread id (open-item kind=email-thread; get it from read-view email)"),
taskName: z.string().optional().describe("Background task/agent name (open-item kind=bg-task; get it from read-view bg-tasks)"),
sessionId: z.string().optional().describe("Chat session id (open-item kind=session; get it from read-view chat-history)"),
// update-base-view
filters: z.object({
set: z.array(z.object({ category: z.string(), value: z.string() })).optional().describe("Replace all filters with these"),
@ -1136,6 +1148,110 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
return { success: true, action: 'open-app', appId, appName };
}
case 'read-view': {
// Returns the same data the view renders, so the assistant
// can answer precisely — and the renderer navigates to the
// view at the same time so the user SEES what's being read.
const view = input.view as string;
const limit = (input.limit as number | undefined) ?? 15;
try {
switch (view) {
case 'email': {
const query = (input.query as string | undefined)?.trim();
const result = query
? await searchThreads(query, { limit })
: listImportantThreads({ limit });
const threads = (result.threads ?? []).slice(0, limit).map((t) => ({
threadId: t.threadId,
subject: t.subject ?? '(no subject)',
from: t.from ?? '',
date: t.date ?? '',
unread: t.unread ?? false,
summary: t.summary ? t.summary.slice(0, 200) : undefined,
}));
return { success: true, action: 'read-view', view, query, threads };
}
case 'bg-tasks': {
const { items } = await listBackgroundTasks({ limit });
const agents = items.map((t) => ({
name: t.name,
slug: t.slug,
active: t.active,
triggers: t.triggers,
lastRunAt: t.lastRunAt,
lastRunSummary: t.lastRunSummary ? t.lastRunSummary.slice(0, 200) : undefined,
lastRunError: t.lastRunError ? t.lastRunError.slice(0, 200) : undefined,
}));
return { success: true, action: 'read-view', view, agents };
}
case 'chat-history': {
const sessions = container.resolve<ISessions>('sessions')
.listSessions()
.slice(0, limit)
.map((s) => ({
sessionId: s.sessionId,
title: s.title ?? '(untitled)',
updatedAt: s.updatedAt,
turnCount: s.turnCount,
}));
return { success: true, action: 'read-view', view, sessions };
}
default:
return {
success: false,
error: `read-view supports: email, bg-tasks, chat-history. For notes/meetings/live-notes use the file-* tools (they are files under the workspace); for other views use open-view and describe what you need.`,
};
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : `Failed to read ${view}`,
};
}
}
case 'open-item': {
const kind = input.kind as string;
switch (kind) {
case 'email-thread': {
const threadId = input.threadId as string | undefined;
if (!threadId) return { success: false, error: 'threadId is required for kind=email-thread' };
return { success: true, action: 'open-item', kind, threadId };
}
case 'note': {
const filePath = input.path as string | undefined;
if (!filePath) return { success: false, error: 'path is required for kind=note' };
const result = await files.exists(filePath);
if (!result.exists) return { success: false, error: `File not found: ${filePath}` };
return { success: true, action: 'open-item', kind, path: filePath };
}
case 'bg-task': {
const taskName = input.taskName as string | undefined;
if (!taskName) return { success: false, error: 'taskName is required for kind=bg-task' };
// Validate (and canonicalize) against the real task list.
const { items: tasks } = await listBackgroundTasks({});
const match = tasks.find(
(t) => t.name === taskName || t.slug === taskName
|| t.name.toLowerCase() === taskName.toLowerCase(),
);
if (!match) {
return {
success: false,
error: `No background task named "${taskName}". Known tasks: ${tasks.map((t) => t.name).join(', ') || '(none)'}`,
};
}
return { success: true, action: 'open-item', kind, taskName: match.name };
}
case 'session': {
const sessionId = input.sessionId as string | undefined;
if (!sessionId) return { success: false, error: 'sessionId is required for kind=session' };
return { success: true, action: 'open-item', kind, sessionId };
}
default:
return { success: false, error: `Unknown item kind: ${kind}` };
}
}
case 'update-base-view': {
const updates: Record<string, unknown> = {};
if (input.filters) updates.filters = input.filters;

View file

@ -1,9 +1,8 @@
import type { BackgroundTask, BackgroundTaskTriggerType } from '@x/shared/dist/background-task.js';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import { fetchTask, patchTask, prependRunId } from './fileops.js';
import { createRun, createMessage } from '../runs/runs.js';
import { getBackgroundTaskAgentModel } from '../models/defaults.js';
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
import { startHeadlessAgent } from '../agents/headless-app.js';
import { buildTriggerBlock } from '../agents/build-trigger-block.js';
import { backgroundTaskBus } from './bus.js';
import { withUseCase } from '../analytics/use_case.js';
@ -139,20 +138,25 @@ export async function runBackgroundTask(
}
const model = task.model || await getBackgroundTaskAgentModel();
const agentRun = await createRun({
agentId: 'background-task-agent',
model,
...(task.provider ? { provider: task.provider } : {}),
useCase: 'background_task_agent',
// Granular trigger as analytics sub-use-case — matches live-note's
// pattern at runner.ts:149.
subUseCase: trigger,
});
// Establish the use-case context for the whole turn so every tool the
// agent calls (notably notify-user) reads `background_task_agent` via
// getCurrentUseCase(); the AsyncLocalStorage context set here flows
// through the turn's async execution chain.
const handle = await withUseCase(
{ useCase: 'background_task_agent', subUseCase: trigger },
() => startHeadlessAgent({
agentId: 'background-task-agent',
message: buildMessage(slug, task, trigger, context, codeProject),
model,
...(task.provider ? { provider: task.provider } : {}),
throwOnError: true,
}),
);
const runId = agentRun.id;
// Record this run in the task's runs.log pointer file (newest first).
// The transcript itself lives at the global $WorkDir/runs/<runId>.jsonl
// — runs.log is just an index that ties runIds to this task.
const runId = handle.turnId;
// Record this turn in the task's runs.log pointer file (newest first).
// The transcript itself lives at $WorkDir/storage/turns/YYYY/MM/DD/
// — runs.log is just an index that ties turn ids to this task.
await prependRunId(slug, runId);
const startedAt = new Date().toISOString();
@ -184,20 +188,7 @@ export async function runBackgroundTask(
});
try {
// Establish the use-case context for the whole run so every tool the
// agent calls (notably notify-user) reads `background_task_agent` via
// getCurrentUseCase(). createMessage synchronously fires
// agentRuntime.trigger(), so the detached run loop — and the tool
// calls within it — inherit this AsyncLocalStorage context. (The
// runtime's own enterUseCase runs inside an async generator and
// doesn't reliably propagate to tool execution, so we set it here at
// the trigger point instead.)
await withUseCase(
{ useCase: 'background_task_agent', subUseCase: trigger },
() => createMessage(runId, buildMessage(slug, task, trigger, context, codeProject)),
);
await waitForRunCompletion(runId, { throwOnError: true });
const summary = await extractAgentResponse(runId);
const { summary } = await handle.done;
// Success — bump cycle anchor, refresh summary, clear any prior error.
await patchTask(slug, {

View file

@ -0,0 +1,278 @@
import { describe, expect, it, vi } from "vitest";
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
import type { TurnStreamEvent } from "@x/shared/dist/turns.js";
import { EmitterSessionBus } from "../sessions/bus.js";
import { TurnInputError } from "../turns/api.js";
import type { ISessions } from "../sessions/api.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
const SENDER = "test:1";
function entry(overrides: Partial<SessionIndexEntry>): SessionIndexEntry {
return {
sessionId: "s1",
createdAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-07-01T00:00:00Z",
turnCount: 1,
latestTurnStatus: "completed",
...overrides,
};
}
function completedEvent(turnId: string, text: string): TurnStreamEvent {
return {
type: "turn_completed",
turnId,
ts: "2026-07-01T00:00:00Z",
output: { role: "assistant", content: text },
finishReason: "stop",
usage: {},
} as unknown as TurnStreamEvent;
}
function askEvent(turnId: string, question: string, options?: string[]): TurnStreamEvent {
return {
type: "turn_suspended",
turnId,
ts: "2026-07-01T00:00:00Z",
pendingPermissions: [],
pendingAsyncTools: [
{
toolCallId: "call_1",
toolId: "builtin:ask-human",
toolName: "ask-human",
input: { question, ...(options ? { options } : {}) },
},
],
usage: {},
} as unknown as TurnStreamEvent;
}
interface Harness {
bridge: ChannelBridge;
bus: EmitterSessionBus;
replies: string[];
reply: (text: string) => Promise<void>;
sessions: {
createSession: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
respondToAskHuman: ReturnType<typeof vi.fn>;
stopTurn: ReturnType<typeof vi.fn>;
getTurn: ReturnType<typeof vi.fn>;
listSessions: ReturnType<typeof vi.fn>;
};
listModels: ReturnType<typeof vi.fn>;
publish: (turnId: string, event: TurnStreamEvent) => void;
}
const MODELS: ModelChoice[] = [
{ provider: "anthropic", model: "claude-fable-5", label: "Fable 5 — Anthropic" },
{ provider: "anthropic", model: "claude-sonnet-5", label: "Sonnet 5 — Anthropic" },
{ provider: "openai", model: "gpt-5", label: "GPT-5 — OpenAI" },
];
function harness(entries: SessionIndexEntry[] = []): Harness {
const bus = new EmitterSessionBus();
const publish = (turnId: string, event: TurnStreamEvent) =>
bus.publish({ kind: "turn-event", sessionId: "s1", turnId, event });
const sessions = {
createSession: vi.fn(async () => "s1"),
sendMessage: vi.fn(async () => ({ turnId: "t1" })),
respondToAskHuman: vi.fn(async () => undefined),
stopTurn: vi.fn(async () => undefined),
getTurn: vi.fn(async () => ({ turnId: "t1", events: [] })),
listSessions: vi.fn(() => entries),
};
const listModels = vi.fn(async () => MODELS);
const bridge = new ChannelBridge({
sessions: sessions as unknown as ISessions,
sessionBus: bus,
listModels,
});
const replies: string[] = [];
const reply = async (text: string) => {
replies.push(text);
};
return { bridge, bus, replies, reply, sessions, listModels, publish };
}
// Settle the turn as soon as sendMessage is called: the watcher subscribes
// before sendMessage, so a synchronous publish lands in its buffer — the
// exact race the buffering exists for.
function settleOnSend(h: Harness, event: TurnStreamEvent, turnId = "t1"): void {
h.sessions.sendMessage.mockImplementation(async () => {
h.publish(turnId, event);
return { turnId };
});
}
describe("ChannelBridge commands", () => {
it("replies with help text", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "help", h.reply);
expect(h.replies).toHaveLength(1);
expect(h.replies[0]).toContain("Rowboat commands");
});
it("lists recent sessions newest-first and resumes by index", async () => {
const h = harness([
entry({ sessionId: "old", title: "Old chat", updatedAt: "2026-07-01T00:00:00Z" }),
entry({ sessionId: "new", title: "New chat", updatedAt: "2026-07-02T00:00:00Z" }),
]);
await h.bridge.handleInbound(SENDER, "list", h.reply);
expect(h.replies[0]).toContain("1. New chat");
expect(h.replies[0]).toContain("2. Old chat");
await h.bridge.handleInbound(SENDER, "resume 2", h.reply);
expect(h.replies[1]).toContain('Resumed "Old chat"');
settleOnSend(h, completedEvent("t1", "done"));
await h.bridge.handleInbound(SENDER, "hello again", h.reply);
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
"old",
expect.objectContaining({ content: "hello again" }),
expect.objectContaining({ autoPermission: true }),
);
});
it("rejects resume with an out-of-range index", async () => {
const h = harness([entry({ sessionId: "s1", title: "Only chat" })]);
await h.bridge.handleInbound(SENDER, "resume 5", h.reply);
expect(h.replies[0]).toContain("No chat #5");
});
});
describe("ChannelBridge model selection", () => {
it("lists models and applies a by-index selection to later turns", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model", h.reply);
expect(h.replies[0]).toContain("1. Fable 5 — Anthropic");
expect(h.replies[0]).toContain("app default");
await h.bridge.handleInbound(SENDER, "model 3", h.reply);
expect(h.replies[1]).toContain("GPT-5");
settleOnSend(h, completedEvent("t1", "done"));
await h.bridge.handleInbound(SENDER, "hello", h.reply);
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
"s1",
expect.anything(),
expect.objectContaining({
agent: {
agentId: "copilot",
overrides: { model: { provider: "openai", model: "gpt-5" } },
},
}),
);
});
it("selects by unique name match and resets on 'model default'", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model fable", h.reply);
expect(h.replies[0]).toContain("Fable 5");
settleOnSend(h, completedEvent("t1", "done"));
await h.bridge.handleInbound(SENDER, "hi", h.reply);
expect(h.sessions.sendMessage).toHaveBeenCalledWith(
"s1",
expect.anything(),
expect.objectContaining({
agent: expect.objectContaining({
overrides: { model: { provider: "anthropic", model: "claude-fable-5" } },
}),
}),
);
await h.bridge.handleInbound(SENDER, "model default", h.reply);
settleOnSend(h, completedEvent("t2", "done"), "t2");
await h.bridge.handleInbound(SENDER, "hi again", h.reply);
expect(h.sessions.sendMessage).toHaveBeenLastCalledWith(
"s1",
expect.anything(),
expect.objectContaining({ agent: { agentId: "copilot" } }),
);
});
it("asks for disambiguation on an ambiguous name and rejects unknown ones", async () => {
const h = harness();
await h.bridge.handleInbound(SENDER, "model claude", h.reply);
expect(h.replies[0]).toContain("matches 2 models");
await h.bridge.handleInbound(SENDER, "model llama", h.reply);
expect(h.replies[1]).toContain('No model matching "llama"');
});
});
describe("ChannelBridge message flow", () => {
it("creates a session, acks, and delivers the completed text", async () => {
const h = harness();
settleOnSend(h, completedEvent("t1", "The answer is 42."));
await h.bridge.handleInbound(SENDER, "what is the answer?", h.reply);
expect(h.sessions.createSession).toHaveBeenCalledOnce();
expect(h.replies[0]).toContain("Working on it");
expect(h.replies[1]).toBe("The answer is 42.");
});
it("delivers a failed turn as an error reply", async () => {
const h = harness();
settleOnSend(h, {
type: "turn_failed",
turnId: "t1",
ts: "2026-07-01T00:00:00Z",
error: "model exploded",
usage: {},
} as unknown as TurnStreamEvent);
await h.bridge.handleInbound(SENDER, "do a thing", h.reply);
expect(h.replies[1]).toContain("model exploded");
});
it("relays the ask-human question text and options, then routes the answer", async () => {
const h = harness();
settleOnSend(h, askEvent("t1", "Which lane?", ["fast", "slow"]));
await h.bridge.handleInbound(SENDER, "start task", h.reply);
expect(h.replies[1]).toContain("Which lane?");
expect(h.replies[1]).toContain("1. fast");
// The answer resolves the ask; the turn then completes.
h.sessions.respondToAskHuman.mockImplementation(async () => {
h.publish("t1", completedEvent("t1", "Took the fast lane."));
});
await h.bridge.handleInbound(SENDER, "fast", h.reply);
expect(h.sessions.respondToAskHuman).toHaveBeenCalledWith("t1", "call_1", "fast");
expect(h.replies[3]).toBe("Took the fast lane.");
});
it("falls back to a normal message when the ask was already answered elsewhere", async () => {
const h = harness();
settleOnSend(h, askEvent("t1", "Which lane?"));
await h.bridge.handleInbound(SENDER, "start task", h.reply);
h.sessions.respondToAskHuman.mockRejectedValue(
new TurnInputError("no pending async tool call call_1"),
);
settleOnSend(h, completedEvent("t2", "Handled as a new message."), "t2");
await h.bridge.handleInbound(SENDER, "actually do this instead", h.reply);
expect(h.sessions.sendMessage).toHaveBeenLastCalledWith(
"s1",
expect.objectContaining({ content: "actually do this instead" }),
expect.anything(),
);
expect(h.replies.at(-1)).toBe("Handled as a new message.");
});
it("reports busy while a turn is in flight", async () => {
const h = harness();
let releaseTurn!: () => void;
h.sessions.sendMessage.mockImplementation(async () => {
releaseTurn = () => h.publish("t1", completedEvent("t1", "finally"));
return { turnId: "t1" };
});
const first = h.bridge.handleInbound(SENDER, "slow task", h.reply);
await new Promise((resolve) => setTimeout(resolve, 0));
await h.bridge.handleInbound(SENDER, "impatient follow-up", h.reply);
expect(h.replies.some((r) => r.includes("Still working"))).toBe(true);
releaseTurn();
await first;
expect(h.replies.at(-1)).toBe("finally");
});
});

View file

@ -0,0 +1,540 @@
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
import { reduceTurn, type TurnStreamEvent } from "@x/shared/dist/turns.js";
import { assistantText, lastAssistantText } from "../agents/headless.js";
import { TurnInputError } from "../turns/api.js";
import { ASK_HUMAN_TOOL } from "../turns/bridges/real-agent-resolver.js";
import { TurnNotSettledError, type ISessions } from "../sessions/api.js";
import type { EmitterSessionBus } from "../sessions/bus.js";
// Transport-agnostic command layer: inbound texts from a messaging channel
// are parsed into commands (list / resume / new / stop / status) or forwarded
// into a regular chat session; the turn's final assistant text is sent back
// through the transport's reply callback. Turns run with autoPermission and
// show up live in the desktop UI like any other session.
const AGENT_ID = "copilot";
const TURN_TIMEOUT_MS = 30 * 60 * 1000;
const LIST_LIMIT = 10;
// Telegram caps messages at 4096 chars; WhatsApp is far higher. Long replies
// are chunked, then truncated — the desktop app has the full text.
const REPLY_CHUNK_SIZE = 3500;
const MAX_REPLY_CHUNKS = 3;
const ASK_HUMAN_TOOL_ID = `builtin:${ASK_HUMAN_TOOL}`;
const HELP_TEXT = [
"🤖 Rowboat commands:",
"• list — recent chats",
"• resume N — continue chat N from the list",
"• new [message] — start a fresh chat",
"• model [N or name] — pick the model (\"model default\" resets)",
"• status — current chat and what it's doing",
"• stop — cancel the running task",
"",
"Anything else is sent to the current chat.",
].join("\n");
const MODEL_LIST_LIMIT = 20;
export type ReplyFn = (text: string) => Promise<void>;
export interface ModelChoice {
provider: string;
model: string;
label: string;
}
interface SenderState {
activeSessionId: string | null;
// sessionIds as last shown by `list` (1-based indexing for `resume N`).
lastList: string[];
// Choices as last shown by `model` (1-based indexing for `model N`).
lastModels: ModelChoice[];
// Per-sender override passed on every turn; null = app default model.
model: { provider: string; model: string } | null;
pendingAsk: { turnId: string; toolCallId: string } | null;
busy: boolean;
}
type Settled =
| { kind: "completed"; text: string | null }
| { kind: "failed"; error: string }
| { kind: "cancelled" }
| { kind: "ask_human"; toolCallId: string; query: string; options?: string[] }
| { kind: "suspended" }
| { kind: "timeout" };
function settleOf(event: TurnStreamEvent): Settled | null {
switch (event.type) {
case "turn_completed":
return { kind: "completed", text: assistantText(event.output) };
case "turn_failed":
return { kind: "failed", error: event.error };
case "turn_cancelled":
return { kind: "cancelled" };
case "turn_suspended": {
const ask = event.pendingAsyncTools.find(
(t) => t.toolId === ASK_HUMAN_TOOL_ID || t.toolName === ASK_HUMAN_TOOL,
);
if (ask) {
const input = ask.input as { question?: unknown; options?: unknown } | null;
const query =
typeof input?.question === "string" && input.question
? input.question
: "The agent needs your input.";
const options = Array.isArray(input?.options)
? input.options.filter((o): o is string => typeof o === "string")
: undefined;
return { kind: "ask_human", toolCallId: ask.toolCallId, query, options };
}
// Other async tools settle on their own and the turn resumes;
// keep waiting. Pending permissions need the desktop.
if (event.pendingAsyncTools.length === 0 && event.pendingPermissions.length > 0) {
return { kind: "suspended" };
}
return null;
}
default:
return null;
}
}
function relativeTime(iso: string): string {
const then = Date.parse(iso);
if (!Number.isFinite(then)) return "";
const diffSec = Math.round((Date.now() - then) / 1000);
if (diffSec < 60) return "just now";
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.round(diffHr / 24)}d ago`;
}
function chunkReply(text: string): string[] {
if (text.length <= REPLY_CHUNK_SIZE) return [text];
const parts: string[] = [];
let rest = text;
while (rest.length > 0 && parts.length < MAX_REPLY_CHUNKS) {
parts.push(rest.slice(0, REPLY_CHUNK_SIZE));
rest = rest.slice(REPLY_CHUNK_SIZE);
}
if (rest.length > 0) {
parts[parts.length - 1] += "\n… (truncated — open Rowboat for the full reply)";
}
return parts;
}
interface TurnWatcher {
waitFor(turnId: string, timeoutMs: number): Promise<Settled>;
dispose(): void;
}
export class ChannelBridge {
private senders = new Map<string, SenderState>();
constructor(
private readonly deps: {
sessions: ISessions;
sessionBus: EmitterSessionBus;
listModels: () => Promise<ModelChoice[]>;
},
) {}
async handleInbound(senderKey: string, text: string, reply: ReplyFn): Promise<void> {
const trimmed = text.trim();
if (!trimmed) return;
const state = this.senderState(senderKey);
const lower = trimmed.toLowerCase();
try {
if (lower === "help" || lower === "?") {
await reply(HELP_TEXT);
return;
}
if (lower === "list" || lower === "chats") {
await reply(this.renderList(state));
return;
}
const resume = /^(?:resume|open)\s+(\d+)$/.exec(lower);
if (resume) {
await reply(this.resumeSession(state, Number(resume[1])));
return;
}
if (lower === "model" || lower === "models") {
await reply(await this.renderModelList(state));
return;
}
const model = /^model\s+(.+)$/i.exec(trimmed);
if (model) {
await reply(await this.selectModel(state, model[1].trim()));
return;
}
if (lower === "status") {
await reply(this.renderStatus(state));
return;
}
if (lower === "stop") {
await reply(await this.stopActive(state));
return;
}
if (lower === "new") {
state.activeSessionId = null;
state.pendingAsk = null;
await reply("🆕 Fresh chat — send your first message.");
return;
}
const newWithText = /^new\s+([\s\S]+)$/i.exec(trimmed);
if (newWithText) {
state.activeSessionId = null;
state.pendingAsk = null;
await this.runMessage(state, newWithText[1].trim(), reply);
return;
}
await this.runMessage(state, trimmed, reply);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await reply(`${message}`).catch(() => undefined);
}
}
private senderState(senderKey: string): SenderState {
let state = this.senders.get(senderKey);
if (!state) {
state = {
activeSessionId: null,
lastList: [],
lastModels: [],
model: null,
pendingAsk: null,
busy: false,
};
this.senders.set(senderKey, state);
}
return state;
}
private isCurrentModel(state: SenderState, choice: ModelChoice): boolean {
return (
state.model?.provider === choice.provider && state.model?.model === choice.model
);
}
private async renderModelList(state: SenderState): Promise<string> {
const choices = await this.deps.listModels();
if (choices.length === 0) {
return "No models available — configure one in Rowboat → Settings → Models.";
}
state.lastModels = choices;
const shown = choices.slice(0, MODEL_LIST_LIMIT);
const lines = shown.map((c, i) => {
const current = this.isCurrentModel(state, c) ? " ← current" : "";
return `${i + 1}. ${c.label}${current}`;
});
if (choices.length > shown.length) {
lines.push(`… and ${choices.length - shown.length} more — pick by name.`);
}
return [
`Models${state.model ? "" : " (using app default)"}:`,
...lines,
"",
`Reply "model N" or "model <name>" to switch, "model default" to reset.`,
].join("\n");
}
private async selectModel(state: SenderState, arg: string): Promise<string> {
const lower = arg.toLowerCase();
if (lower === "default" || lower === "reset") {
state.model = null;
return "✅ Using the app default model.";
}
if (state.lastModels.length === 0) {
state.lastModels = await this.deps.listModels();
}
let choice: ModelChoice | undefined;
if (/^\d+$/.test(lower)) {
choice = state.lastModels[Number(lower) - 1];
if (!choice) {
return `No model #${lower} — send "model" to see the list.`;
}
} else {
const matches = state.lastModels.filter(
(c) =>
c.label.toLowerCase().includes(lower) ||
c.model.toLowerCase().includes(lower),
);
if (matches.length === 0) {
return `No model matching "${arg}" — send "model" to see the list.`;
}
if (matches.length > 1) {
const preview = matches.slice(0, 5).map((c) => `${c.label}`);
return [`"${arg}" matches ${matches.length} models:`, ...preview, "", "Be more specific."].join("\n");
}
choice = matches[0];
}
state.model = { provider: choice.provider, model: choice.model };
return `✅ Model set to ${choice.label} for your chats from here.`;
}
private sessionEntry(sessionId: string): SessionIndexEntry | undefined {
return this.deps.sessions.listSessions().find((e) => e.sessionId === sessionId);
}
private recentSessions(): SessionIndexEntry[] {
return this.deps.sessions
.listSessions()
.filter((e) => !e.error)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, LIST_LIMIT);
}
private renderList(state: SenderState): string {
const entries = this.recentSessions();
if (entries.length === 0) {
return "No chats yet — just send a message to start one.";
}
state.lastList = entries.map((e) => e.sessionId);
const lines = entries.map((e, i) => {
const marker =
e.latestTurnStatus === "suspended" ? " ⚠️" :
e.latestTurnStatus === "idle" ? " ⏳" : "";
const active = e.sessionId === state.activeSessionId ? " ← current" : "";
return `${i + 1}. ${e.title ?? "Untitled"}${marker} (${relativeTime(e.updatedAt)})${active}`;
});
return [
"Recent chats:",
...lines,
"",
`Reply "resume N" to continue one.`,
].join("\n");
}
private resumeSession(state: SenderState, index: number): string {
if (state.lastList.length === 0) {
state.lastList = this.recentSessions().map((e) => e.sessionId);
}
const sessionId = state.lastList[index - 1];
if (!sessionId) {
return `No chat #${index} — send "list" to see recent chats.`;
}
state.activeSessionId = sessionId;
state.pendingAsk = null;
const entry = this.sessionEntry(sessionId);
return `▶️ Resumed "${entry?.title ?? "Untitled"}" — send a message to continue.`;
}
private renderStatus(state: SenderState): string {
if (!state.activeSessionId) {
return "No current chat — your next message starts a new one.";
}
const entry = this.sessionEntry(state.activeSessionId);
if (!entry) return "Current chat no longer exists — send a message to start fresh.";
const status = state.busy
? "working"
: entry.latestTurnStatus === "suspended"
? "waiting on input"
: entry.latestTurnStatus;
return `Current chat: "${entry.title ?? "Untitled"}" — ${status}.`;
}
private async stopActive(state: SenderState): Promise<string> {
state.pendingAsk = null;
if (!state.activeSessionId) return "Nothing to stop.";
const entry = this.sessionEntry(state.activeSessionId);
if (!entry?.latestTurnId) return "Nothing to stop.";
if (
entry.latestTurnStatus === "completed" ||
entry.latestTurnStatus === "failed" ||
entry.latestTurnStatus === "cancelled"
) {
return "Nothing running in the current chat.";
}
await this.deps.sessions.stopTurn(entry.latestTurnId, "stopped from mobile channel");
return "🛑 Stop requested.";
}
private async runMessage(state: SenderState, text: string, reply: ReplyFn): Promise<void> {
if (state.busy) {
await reply('⏳ Still working on the previous message — send "stop" to cancel it.');
return;
}
state.busy = true;
try {
await reply("⏳ Working on it…");
if (state.pendingAsk) {
const ask = state.pendingAsk;
state.pendingAsk = null;
const answered = await this.answerAsk(state, ask, text, reply);
if (answered) return;
// The ask was already resolved elsewhere (e.g. answered in the
// desktop UI) or the turn is terminal — treat the text as a
// normal message instead of discarding it.
}
await this.sendToSession(state, text, reply);
} catch (error) {
if (error instanceof TurnNotSettledError) {
await reply(
'⏳ That chat is still working on something — send "stop" to cancel it, or "new" to start a fresh chat.',
);
return;
}
throw error;
} finally {
state.busy = false;
}
}
private async sendToSession(state: SenderState, text: string, reply: ReplyFn): Promise<void> {
// Subscribe before advancing so no settle event can slip past.
const watcher = this.watchBus();
try {
if (!state.activeSessionId) {
state.activeSessionId = await this.deps.sessions.createSession();
}
const sent = await this.deps.sessions.sendMessage(
state.activeSessionId,
{ role: "user", content: text },
{
agent: {
agentId: AGENT_ID,
...(state.model ? { overrides: { model: state.model } } : {}),
},
autoPermission: true,
},
);
const settled = await watcher.waitFor(sent.turnId, TURN_TIMEOUT_MS);
await this.deliverSettled(state, sent.turnId, settled, reply);
} finally {
watcher.dispose();
}
}
// Returns false when the ask was stale (already answered on the desktop /
// turn terminal) — the caller then routes the text as a normal message.
private async answerAsk(
state: SenderState,
ask: { turnId: string; toolCallId: string },
text: string,
reply: ReplyFn,
): Promise<boolean> {
const watcher = this.watchBus();
try {
const settledPromise = watcher.waitFor(ask.turnId, TURN_TIMEOUT_MS);
// respondToAskHuman resolves only when the whole advance settles,
// so it must not be awaited ahead of the watcher (that would
// bypass TURN_TIMEOUT_MS). Race instead: its rejection (stale
// ask) must beat the 30-minute timeout; its success defers to the
// settle event.
const settled = await Promise.race([
settledPromise,
this.deps.sessions
.respondToAskHuman(ask.turnId, ask.toolCallId, text)
.then(() => settledPromise),
]);
await this.deliverSettled(state, ask.turnId, settled, reply);
return true;
} catch (error) {
if (error instanceof TurnInputError) return false;
throw error;
} finally {
watcher.dispose();
}
}
private async deliverSettled(
state: SenderState,
turnId: string,
settled: Settled,
reply: ReplyFn,
): Promise<void> {
switch (settled.kind) {
case "completed": {
let text = settled.text;
if (!text) {
// Rare: final message had no text parts; recover the last
// assistant text from the persisted turn.
try {
const turn = await this.deps.sessions.getTurn(turnId);
text = lastAssistantText(reduceTurn(turn.events));
} catch {
text = null;
}
}
for (const chunk of chunkReply(text ?? "✅ Done (no text reply).")) {
await reply(chunk);
}
return;
}
case "failed":
await reply(`❌ Task failed: ${settled.error}`);
return;
case "cancelled":
await reply("🛑 Stopped.");
return;
case "ask_human": {
state.pendingAsk = { turnId, toolCallId: settled.toolCallId };
const lines = [`${settled.query}`];
if (settled.options?.length) {
lines.push(...settled.options.map((o, i) => `${i + 1}. ${o}`));
}
lines.push("", "Reply with your answer.");
await reply(lines.join("\n"));
return;
}
case "suspended":
await reply(
"⚠️ The agent is waiting for a permission approval — open Rowboat on your desktop to continue.",
);
return;
case "timeout":
await reply(
"⏱️ Still running after 30 minutes — check the desktop app for progress.",
);
return;
}
}
// Buffers settle-relevant events (≈1 per turn) from the moment of
// subscription so a settle firing between advance-start and waitFor() is
// never lost — without retaining the per-token delta stream of every
// concurrent session. One watcher per in-flight message.
private watchBus(): TurnWatcher {
const buffered: Array<{ turnId: string; settled: Settled }> = [];
let waiter: { turnId: string; resolve: (settled: Settled) => void } | null = null;
let cancelTimer: (() => void) | null = null;
const unsubscribe = this.deps.sessionBus.subscribe((event) => {
if (event.kind !== "turn-event") return;
const settled = settleOf(event.event);
if (!settled) return;
if (waiter) {
if (event.turnId === waiter.turnId) waiter.resolve(settled);
return;
}
buffered.push({ turnId: event.turnId, settled });
});
return {
waitFor: (turnId: string, timeoutMs: number): Promise<Settled> =>
new Promise<Settled>((resolve) => {
const hit = buffered.find((b) => b.turnId === turnId);
if (hit) {
resolve(hit.settled);
return;
}
buffered.length = 0;
const timer = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
cancelTimer = () => clearTimeout(timer);
waiter = {
turnId,
resolve: (settled) => {
clearTimeout(timer);
resolve(settled);
},
};
}),
dispose: () => {
unsubscribe();
cancelTimer?.();
},
};
}
}

View file

@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import type { z } from 'zod';
import { WorkDir } from '../config/config.js';
import { ChannelsConfig, DEFAULT_CHANNELS_CONFIG } from '@x/shared/dist/channels.js';
export interface IChannelsConfigRepo {
getConfig(): Promise<z.infer<typeof ChannelsConfig>>;
setConfig(config: z.infer<typeof ChannelsConfig>): Promise<void>;
}
export class FSChannelsConfigRepo implements IChannelsConfigRepo {
private readonly configPath = path.join(WorkDir, 'config', 'channels.json');
constructor() {
this.ensureConfigFile();
}
private async ensureConfigFile(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
await fs.writeFile(this.configPath, JSON.stringify(DEFAULT_CHANNELS_CONFIG, null, 2));
}
}
async getConfig(): Promise<z.infer<typeof ChannelsConfig>> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return ChannelsConfig.parse(JSON.parse(content));
} catch {
return DEFAULT_CHANNELS_CONFIG;
}
}
async setConfig(config: z.infer<typeof ChannelsConfig>): Promise<void> {
const validated = ChannelsConfig.parse(config);
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
}
}

View file

@ -0,0 +1,251 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { z } from "zod";
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/dist/channels.js";
import container from "../di/container.js";
import { WorkDir } from "../config/config.js";
import type { ISessions } from "../sessions/api.js";
import type { EmitterSessionBus } from "../sessions/bus.js";
import { isSignedIn } from "../account/account.js";
import { listGatewayModels } from "../models/gateway.js";
import { listOnboardingModels } from "../models/models-dev.js";
import { ChannelBridge, type ModelChoice } from "./bridge.js";
import type { IChannelsConfigRepo } from "./repo.js";
import { TelegramTransport } from "./transports/telegram.js";
// Type-only: the real module (which pulls the ~9MB baileys dependency) is
// loaded dynamically in startWhatsApp, so boot pays nothing while disabled.
import type { WhatsAppTransport } from "./transports/whatsapp.js";
// Lifecycle owner for the mobile channels: reads config, runs the enabled
// transports against one shared ChannelBridge, and fans status out to the
// renderer (QR pairing, connection state). init() from main after
// sessions.initialize(); applyChannelsConfig() on every settings save.
type Config = z.infer<typeof ChannelsConfig>;
type Status = z.infer<typeof ChannelsStatus>;
const WHATSAPP_AUTH_DIR = path.join(WorkDir, "channels", "whatsapp-auth");
const TELEGRAM_STATE_FILE = path.join(WorkDir, "channels", "telegram-state.json");
let bridge: ChannelBridge | null = null;
let whatsapp: WhatsAppTransport | null = null;
let telegram: TelegramTransport | null = null;
const status: Status = {
whatsapp: { state: "disabled" },
telegram: { state: "disabled" },
};
const statusListeners = new Set<(status: Status) => void>();
// Serializes apply/logout so a fast settings double-save can't interleave
// transport teardown and startup. enqueue() recovers the chain before adding
// a step — a rejected step must fail only its own caller, never poison every
// later settings save.
let lifecycle: Promise<void> = Promise.resolve();
function enqueue(step: () => Promise<void>): Promise<void> {
const run = lifecycle.catch(() => undefined).then(step);
lifecycle = run.catch(() => undefined);
return run;
}
function notifyStatus(): void {
const snapshot = structuredClone(status);
for (const listener of statusListeners) {
try {
listener(snapshot);
} catch {
// observers must never affect the channels
}
}
}
function setWhatsAppStatus(next: Status["whatsapp"]): void {
status.whatsapp = next;
notifyStatus();
}
function setTelegramStatus(next: Status["telegram"]): void {
status.telegram = next;
notifyStatus();
}
export function getChannelsStatus(): Status {
return structuredClone(status);
}
export function subscribeChannelsStatus(listener: (status: Status) => void): () => void {
statusListeners.add(listener);
return () => statusListeners.delete(listener);
}
// Same catalog the desktop model picker uses (models:list IPC).
async function listBridgeModels(): Promise<ModelChoice[]> {
const catalog = (await isSignedIn())
? await listGatewayModels()
: await listOnboardingModels();
return catalog.providers.flatMap((provider) =>
provider.models.map((m) => ({
provider: provider.id,
model: m.id,
label: `${m.name ?? m.id}${provider.name}`,
})),
);
}
function ensureBridge(): ChannelBridge {
if (!bridge) {
bridge = new ChannelBridge({
sessions: container.resolve<ISessions>("sessions"),
sessionBus: container.resolve<EmitterSessionBus>("sessionBus"),
listModels: listBridgeModels,
});
}
return bridge;
}
async function stopWhatsApp(): Promise<void> {
if (!whatsapp) return;
const stopping = whatsapp;
whatsapp = null;
await stopping.stop().catch(() => undefined);
setWhatsAppStatus({ state: "disabled" });
}
function stopTelegram(): void {
if (!telegram) return;
const stopping = telegram;
telegram = null;
stopping.stop();
setTelegramStatus({ state: "disabled" });
}
// Invalidates pending async QR renders whenever a newer status lands.
let qrSeq = 0;
async function startWhatsApp(config: Config["whatsapp"]): Promise<void> {
if (!config.enabled) {
setWhatsAppStatus({ state: "disabled" });
return;
}
const channelBridge = ensureBridge();
const [{ WhatsAppTransport: Transport }, QRCode] = await Promise.all([
import("./transports/whatsapp.js"),
import("qrcode").then((m) => m.default),
]);
const transport = new Transport({
authDir: WHATSAPP_AUTH_DIR,
allowFrom: config.allowFrom,
onInbound: (senderKey, chatJid, text) => {
// Route replies through whichever transport is current at send
// time — the originating instance may have been replaced by a
// settings save while the turn was running.
const reply = async (replyText: string) => {
const current = whatsapp;
if (!current) throw new Error("WhatsApp channel is disabled");
await current.send(chatJid, replyText);
};
void channelBridge.handleInbound(senderKey, text, reply);
},
onStatus: (update) => {
if (whatsapp !== transport) return; // superseded instance
if (update.state === "qr" && update.qr) {
const seq = ++qrSeq;
// Render the pairing QR main-side so the renderer just shows
// an <img>; the raw pairing string never leaves core.
QRCode.toDataURL(update.qr, { margin: 1, width: 256 })
.then((qrDataUrl) => {
if (whatsapp === transport && seq === qrSeq) {
setWhatsAppStatus({ state: "qr", qrDataUrl });
}
})
.catch(() => {
if (whatsapp === transport && seq === qrSeq) {
setWhatsAppStatus({ state: "error", error: "Failed to render pairing QR" });
}
});
return;
}
qrSeq++;
setWhatsAppStatus({
state: update.state,
...(update.self ? { self: update.self } : {}),
...(update.error ? { error: update.error } : {}),
});
},
});
whatsapp = transport;
transport.start().catch((error) => {
if (whatsapp !== transport) return;
setWhatsAppStatus({
state: "error",
error: error instanceof Error ? error.message : String(error),
});
});
}
function startTelegram(config: Config["telegram"]): void {
if (!config.enabled) {
setTelegramStatus({ state: "disabled" });
return;
}
if (!config.botToken) {
setTelegramStatus({ state: "error", error: "Bot token missing — create one with @BotFather" });
return;
}
const channelBridge = ensureBridge();
const transport = new TelegramTransport({
botToken: config.botToken,
allowFrom: config.allowFrom,
stateFile: TELEGRAM_STATE_FILE,
onInbound: (senderKey, chatId, text) => {
const reply = async (replyText: string) => {
const current = telegram;
if (!current) throw new Error("Telegram channel is disabled");
await current.send(chatId, replyText);
};
void channelBridge.handleInbound(senderKey, text, reply);
},
onStatus: (update) => {
if (telegram !== transport) return; // superseded instance
setTelegramStatus(update);
},
});
telegram = transport;
void transport.start();
}
export function applyChannelsConfig(config: Config): Promise<void> {
return enqueue(async () => {
await stopWhatsApp();
stopTelegram();
await startWhatsApp(config.whatsapp);
startTelegram(config.telegram);
});
}
// Unlink the WhatsApp device and, if the channel is still enabled, restart it
// so a fresh pairing QR appears. Telegram is left untouched.
export function logoutWhatsApp(): Promise<void> {
return enqueue(async () => {
if (whatsapp) {
const out = whatsapp;
whatsapp = null;
await out.logout().catch(() => undefined);
} else {
await fs.rm(WHATSAPP_AUTH_DIR, { recursive: true, force: true }).catch(() => undefined);
}
const config = await container
.resolve<IChannelsConfigRepo>("channelsConfigRepo")
.getConfig();
await startWhatsApp(config.whatsapp);
});
}
export async function init(): Promise<void> {
const config = await container
.resolve<IChannelsConfigRepo>("channelsConfigRepo")
.getConfig();
await applyChannelsConfig(config);
}

View file

@ -0,0 +1,218 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { z } from "zod";
import type { TelegramChannelStatus } from "@x/shared/dist/channels.js";
// Telegram Bot API transport. Deliberately dependency-free: the Bot API is
// plain HTTPS — getUpdates long polling (outbound connection, works behind
// NAT) plus sendMessage. The user supplies their own bot token (@BotFather).
//
// The getUpdates offset is persisted to disk after each processed batch:
// Telegram only marks updates confirmed when a LATER getUpdates call passes a
// higher offset, so without persistence every transport restart (app relaunch
// or settings save) would redeliver — and re-execute — the last batch.
const POLL_TIMEOUT_S = 50;
const RETRY_DELAY_MS = 5000;
const MAX_RETRY_DELAY_MS = 60_000;
type Status = z.infer<typeof TelegramChannelStatus>;
class TelegramApiError extends Error {
constructor(
message: string,
readonly code?: number,
) {
super(message);
this.name = "TelegramApiError";
}
}
// 401 = token revoked/invalid, 404 = bot deleted / malformed token. Retrying
// these forever would hammer the API and show a misleading "polling" status.
function isTerminal(error: unknown): boolean {
return error instanceof TelegramApiError && (error.code === 401 || error.code === 404);
}
interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
text?: string;
chat: { id: number; type: string };
from?: { id: number; is_bot?: boolean };
};
}
export interface TelegramTransportOptions {
botToken: string;
allowFrom: string[];
// JSON file holding { offset } across restarts.
stateFile: string;
// chatId is the address to reply to; the caller owns reply routing.
onInbound: (senderKey: string, chatId: string, text: string) => void;
onStatus: (status: Status) => void;
}
export class TelegramTransport {
private abort: AbortController | null = null;
private stopped = false;
private offset = 0;
private botUsername: string | undefined;
constructor(private readonly opts: TelegramTransportOptions) {}
async start(): Promise<void> {
this.stopped = false;
this.opts.onStatus({ state: "starting" });
void this.run();
}
stop(): void {
this.stopped = true;
this.abort?.abort();
this.opts.onStatus({ state: "disabled" });
}
private async call(method: string, body?: unknown, signal?: AbortSignal): Promise<unknown> {
const res = await fetch(`https://api.telegram.org/bot${this.opts.botToken}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
...(signal ? { signal } : {}),
});
const payload = (await res.json()) as {
ok: boolean;
result?: unknown;
description?: string;
error_code?: number;
};
if (!payload.ok) {
throw new TelegramApiError(
payload.description ?? `Telegram API error (${method})`,
payload.error_code,
);
}
return payload.result;
}
private async loadOffset(): Promise<void> {
try {
const raw = await fs.readFile(this.opts.stateFile, "utf8");
const parsed = JSON.parse(raw) as { offset?: unknown };
if (typeof parsed.offset === "number" && Number.isFinite(parsed.offset)) {
this.offset = parsed.offset;
}
} catch {
// first run or unreadable state — start from 0
}
}
private async saveOffset(): Promise<void> {
try {
await fs.mkdir(path.dirname(this.opts.stateFile), { recursive: true });
await fs.writeFile(this.opts.stateFile, JSON.stringify({ offset: this.offset }));
} catch {
// best effort — worst case is one redelivered batch after restart
}
}
private async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
private async run(): Promise<void> {
await this.loadOffset();
// Validate the token, retrying transient failures (the app often
// starts at login before the network is up). Only a definitive
// API rejection is terminal.
let delay = RETRY_DELAY_MS;
while (!this.stopped) {
try {
const me = (await this.call("getMe")) as { username?: string };
if (this.stopped) return;
this.botUsername = me.username;
this.opts.onStatus({ state: "polling", botUsername: me.username });
break;
} catch (error) {
if (this.stopped) return;
const message = error instanceof Error ? error.message : String(error);
if (isTerminal(error)) {
this.opts.onStatus({
state: "error",
error: `Bot token rejected (${message}) — create a new token with @BotFather.`,
});
return;
}
this.opts.onStatus({ state: "error", error: message });
await this.sleep(delay);
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS);
}
}
delay = RETRY_DELAY_MS;
let healthy = true;
while (!this.stopped) {
this.abort = new AbortController();
try {
const updates = (await this.call(
"getUpdates",
{
timeout: POLL_TIMEOUT_S,
offset: this.offset,
allowed_updates: ["message"],
},
this.abort.signal,
)) as TelegramUpdate[];
for (const update of updates) {
this.offset = update.update_id + 1;
this.handleUpdate(update);
}
if (updates.length > 0) {
await this.saveOffset();
}
if (!healthy) {
// Restore the healthy status only after a successful poll.
healthy = true;
this.opts.onStatus({ state: "polling", botUsername: this.botUsername });
}
delay = RETRY_DELAY_MS;
} catch (error) {
if (this.stopped) return;
const message = error instanceof Error ? error.message : String(error);
if (isTerminal(error)) {
this.opts.onStatus({
state: "error",
error: `Bot token rejected (${message}) — create a new token with @BotFather.`,
});
return;
}
healthy = false;
this.opts.onStatus({ state: "error", error: message });
await this.sleep(delay);
delay = Math.min(delay * 2, MAX_RETRY_DELAY_MS);
}
}
}
private handleUpdate(update: TelegramUpdate): void {
const message = update.message;
if (!message?.text || message.from?.is_bot) return;
// DMs only: group chats would let any member drive the bridge.
if (message.chat.type !== "private") return;
const chatId = String(message.chat.id);
if (!this.opts.allowFrom.includes(chatId)) {
void this.send(
chatId,
`⛔ Not authorized. Your chat ID is ${chatId} — add it under Rowboat → Settings → Mobile to pair this chat.`,
).catch(() => undefined);
return;
}
this.opts.onInbound(`telegram:${chatId}`, chatId, message.text);
}
async send(chatId: string, text: string): Promise<void> {
await this.call("sendMessage", { chat_id: chatId, text });
}
}

View file

@ -0,0 +1,219 @@
import fs from "node:fs/promises";
import makeWASocket, {
DisconnectReason,
areJidsSameUser,
isJidGroup,
jidDecode,
useMultiFileAuthState,
} from "baileys";
// WhatsApp transport via Baileys: the app links to the user's own WhatsApp
// account as a linked device (QR pairing, same as WhatsApp Web) over an
// outbound WebSocket — no server, no port forwarding.
//
// Access model: the linked account's own self-chat ("message yourself") is
// always allowed; other senders must be explicitly allowlisted by phone
// number. Group chats are ignored entirely.
type WASocket = ReturnType<typeof makeWASocket>;
const RECONNECT_DELAY_MS = 3000;
// Marks bridge-sent messages. In the self-chat our own replies come back on
// messages.upsert like any other message; the marker (plus sent-id tracking)
// keeps the bridge from answering itself in a loop.
const REPLY_MARKER = "🤖 ";
export interface WhatsAppTransportStatus {
state: "starting" | "qr" | "connected" | "error" | "disabled";
qr?: string;
self?: string;
error?: string;
}
export interface WhatsAppTransportOptions {
authDir: string;
allowFrom: string[];
// chatJid is the address to reply to; the caller owns reply routing so a
// reply can go through whichever transport instance is current by then.
onInbound: (senderKey: string, chatJid: string, text: string) => void;
onStatus: (status: WhatsAppTransportStatus) => void;
}
interface TextishMessage {
conversation?: unknown;
extendedTextMessage?: { text?: unknown };
ephemeralMessage?: { message?: TextishMessage };
}
interface InboundWAMessage {
key?: {
remoteJid?: string | null;
// Phone-number JID when remoteJid is a LID (anonymized) JID.
remoteJidAlt?: string | null;
fromMe?: boolean | null;
id?: string | null;
};
message?: unknown;
}
function messageText(message: unknown): string | null {
if (!message || typeof message !== "object") return null;
const m = message as TextishMessage;
const unwrapped = m.ephemeralMessage?.message ?? m;
const text: unknown = unwrapped.conversation ?? unwrapped.extendedTextMessage?.text;
return typeof text === "string" && text ? text : null;
}
export class WhatsAppTransport {
private sock: WASocket | null = null;
private stopped = false;
// Bumped on every connect/stop/logout; handlers close over their own
// generation and go inert the moment they are superseded, so a stop()
// racing an await inside connect() cannot leave a zombie socket
// processing messages alongside its replacement.
private generation = 0;
private sentIds = new Set<string>();
constructor(private readonly opts: WhatsAppTransportOptions) {}
async start(): Promise<void> {
this.stopped = false;
this.opts.onStatus({ state: "starting" });
await this.connect();
}
async stop(): Promise<void> {
this.stopped = true;
this.generation++;
try {
this.sock?.end(undefined);
} catch {
// already closed
}
this.sock = null;
this.opts.onStatus({ state: "disabled" });
}
// Unlink this device: invalidates the pairing on the phone and clears
// local credentials so the next start shows a fresh QR.
async logout(): Promise<void> {
this.stopped = true;
this.generation++;
try {
await this.sock?.logout();
} catch {
// best effort — clearing creds below is what actually unpairs us
}
this.sock = null;
await fs.rm(this.opts.authDir, { recursive: true, force: true });
this.opts.onStatus({ state: "disabled" });
}
private async connect(): Promise<void> {
if (this.stopped) return;
const generation = ++this.generation;
const { state, saveCreds } = await useMultiFileAuthState(this.opts.authDir);
if (this.stopped || generation !== this.generation) return;
const sock = makeWASocket({
auth: state,
syncFullHistory: false,
markOnlineOnConnect: false,
});
this.sock = sock;
const isCurrent = () =>
!this.stopped && generation === this.generation && this.sock === sock;
sock.ev.on("creds.update", saveCreds);
sock.ev.on("connection.update", (update) => {
if (!isCurrent()) return;
if (update.qr) {
this.opts.onStatus({ state: "qr", qr: update.qr });
}
if (update.connection === "open") {
const self = jidDecode(sock.user?.id ?? "")?.user;
this.opts.onStatus({ state: "connected", ...(self ? { self } : {}) });
}
if (update.connection === "close") {
const statusCode = (update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)
?.output?.statusCode;
if (statusCode === DisconnectReason.loggedOut) {
// Unlinked from the phone; stale creds would loop forever.
void fs.rm(this.opts.authDir, { recursive: true, force: true });
this.opts.onStatus({
state: "error",
error: "Logged out from the phone — toggle WhatsApp off and on to pair again.",
});
return;
}
setTimeout(() => {
if (!isCurrent()) return;
this.connect().catch((error) => {
this.opts.onStatus({
state: "error",
error: error instanceof Error ? error.message : String(error),
});
});
}, RECONNECT_DELAY_MS);
}
});
sock.ev.on("messages.upsert", ({ messages, type }) => {
if (!isCurrent() || type !== "notify") return;
for (const msg of messages) {
this.handleMessage(sock, msg);
}
});
}
private handleMessage(sock: WASocket, msg: InboundWAMessage): void {
const jid: string | undefined = msg.key?.remoteJid ?? undefined;
if (!jid || isJidGroup(jid) || jid === "status@broadcast") return;
const messageId: string | undefined = msg.key?.id ?? undefined;
if (messageId && this.sentIds.has(messageId)) return;
const text = messageText(msg.message);
if (!text || text.startsWith(REPLY_MARKER)) return;
// LID-addressed chats put the anonymized id in remoteJid and (when
// the server supplies it) the real phone-number JID in remoteJidAlt.
// Identity checks must consider both.
const altJid: string | undefined = msg.key?.remoteJidAlt ?? undefined;
const chatJids = altJid ? [jid, altJid] : [jid];
const user = sock.user as { id?: string; lid?: string } | undefined;
const selfIds = [user?.id, user?.lid].filter((v): v is string => Boolean(v));
const isSelfChat = chatJids.some((j) =>
selfIds.some((selfId) => areJidsSameUser(j, selfId)),
);
const senderNumbers = chatJids.flatMap((j) => {
const decoded = jidDecode(j)?.user;
return decoded ? [decoded] : [];
});
// Self-chat is the owner by definition. Anyone else must be
// allowlisted — this bridge is remote control over the desktop agent.
if (!isSelfChat) {
if (msg.key?.fromMe) return;
if (!senderNumbers.some((n) => this.opts.allowFrom.includes(n))) return;
}
// Prefer the phone number (altJid decodes to it when present) as the
// stable sender identity.
const senderId = altJid
? (jidDecode(altJid)?.user ?? senderNumbers[0] ?? jid)
: (senderNumbers[0] ?? jid);
this.opts.onInbound(`whatsapp:${senderId}`, jid, text);
}
async send(jid: string, text: string): Promise<void> {
const sock = this.sock;
if (!sock) throw new Error("WhatsApp is not connected");
const sent = await sock.sendMessage(jid, { text: `${REPLY_MARKER}${text}` });
const id = sent?.key?.id;
if (id) {
this.sentIds.add(id);
if (this.sentIds.size > 500) {
this.sentIds = new Set(Array.from(this.sentIds).slice(-250));
}
}
}
}

View file

@ -139,6 +139,8 @@ function toEvent(update: SessionUpdate): CodeRunEvent {
priority: e.priority ?? undefined,
})),
};
case 'usage_update':
return { type: 'usage', used: update.used, size: update.size };
default:
return { type: 'other', sessionUpdate: update.sessionUpdate };
}

View file

@ -44,6 +44,17 @@ function ensureDefaultConfigs() {
configured: false
}, null, 2));
}
// Create gmail_sync.json with the default onboarding email count if it
// doesn't exist, so the "how many emails to backfill" setting is
// discoverable and editable. Keep the default in sync with
// DEFAULT_MAX_EMAILS in gmail_sync_config.ts.
const gmailSyncConfig = path.join(WorkDir, "config", "gmail_sync.json");
if (!fs.existsSync(gmailSyncConfig)) {
fs.writeFileSync(gmailSyncConfig, JSON.stringify({
maxEmails: 500
}, null, 2));
}
}
ensureDirs();

View file

@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
const CONFIG_FILE = path.join(WorkDir, 'config', 'gmail_sync.json');
/**
* How many of the newest email threads the initial (onboarding) / recovery
* Gmail sync pulls down. This bounds the sync by a COUNT of recent threads
* rather than a fixed date window, so a fresh account backfills its most recent
* `maxEmails` emails even when they span more than a week.
*/
export const DEFAULT_MAX_EMAILS = 500;
// Guard rails: at least one email, and a hard ceiling so a misconfigured value
// can't trigger a runaway onboarding sync (each thread costs a threads.get plus
// an LLM classification).
const MIN_MAX_EMAILS = 1;
const MAX_MAX_EMAILS = 5000;
interface GmailSyncConfig {
maxEmails: number;
}
function clampMaxEmails(value: number): number {
return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value)));
}
function readConfig(): Partial<GmailSyncConfig> {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw) as Partial<GmailSyncConfig>;
}
} catch (err) {
console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err);
}
return {};
}
function writeConfig(config: Partial<GmailSyncConfig>): void {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Read the configured max email count for the onboarding/full sync.
* Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed,
* or holds an out-of-range value.
*/
export function getMaxEmails(): number {
const value = Number(readConfig()?.maxEmails);
if (Number.isFinite(value) && value > 0) {
return clampMaxEmails(value);
}
return DEFAULT_MAX_EMAILS;
}
/**
* Persist the max email count used by the onboarding/full sync. The value is
* clamped into the supported range before writing.
*/
export function setMaxEmails(maxEmails: number): void {
writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) });
}

View file

@ -1,4 +1,6 @@
import { asClass, asValue, createContainer, InjectionMode } from "awilix";
import path from "node:path";
import { asClass, asFunction, asValue, createContainer, InjectionMode } from "awilix";
import { WorkDir } from "../config/config.js";
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
@ -16,6 +18,7 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { FSChannelsConfigRepo, IChannelsConfigRepo } from "../channels/repo.js";
import { CodeModeManager } from "../code-mode/acp/manager.js";
import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js";
import { FSCodeProjectsRepo, ICodeProjectsRepo } from "../code-mode/projects/repo.js";
@ -24,6 +27,37 @@ import { CodeSessionService } from "../code-mode/sessions/service.js";
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
import { SystemClock, type IClock } from "../turns/clock.js";
import { FSTurnRepo } from "../turns/fs-repo.js";
import type { ITurnRepo } from "../turns/repo.js";
import { TurnRepoContextResolver, type IContextResolver } from "../turns/context-resolver.js";
import { EmitterTurnLifecycleBus, type ITurnLifecycleBus } from "../turns/bus.js";
import { RealUsageReporter } from "../turns/bridges/real-usage-reporter.js";
import type { IUsageReporter } from "../turns/usage-reporter.js";
import { TurnRuntime } from "../turns/runtime.js";
import type { ITurnRuntime } from "../turns/api.js";
import type { IAgentResolver } from "../turns/agent-resolver.js";
import type { IModelRegistry } from "../turns/model-registry.js";
import type { IToolRegistry } from "../turns/tool-registry.js";
import type { IPermissionChecker, IPermissionClassifier } from "../turns/permission.js";
import { RealAgentResolver } from "../turns/bridges/real-agent-resolver.js";
import { RealModelRegistry } from "../turns/bridges/real-model-registry.js";
import { RealToolRegistry } from "../turns/bridges/real-tool-registry.js";
import { RealPermissionChecker } from "../turns/bridges/real-permission-checker.js";
import { RealPermissionClassifier } from "../turns/bridges/real-permission-classifier.js";
import { FSSessionRepo } from "../sessions/fs-repo.js";
import type { ISessionRepo } from "../sessions/repo.js";
import { EmitterSessionBus, type ISessionBus } from "../sessions/bus.js";
import { SessionsImpl } from "../sessions/sessions.js";
import type { ISessions } from "../sessions/api.js";
import {
DefaultModelResolver,
type IDefaultModelResolver,
} from "../models/default-model-resolver.js";
import {
HeadlessAgentRunner,
type IHeadlessAgentRunner,
} from "../agents/headless.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -36,7 +70,15 @@ container.register({
bus: asClass<IBus>(InMemoryBus).singleton(),
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
abortRegistry: asClass<IAbortRegistry>(InMemoryAbortRegistry).singleton(),
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
// Lazy: agents/runtime.js participates in an import cycle with this
// module (and is now also reachable via the turn-runtime bridges), so the
// class binding may not be initialized yet when this body runs.
agentRuntime: asFunction<IAgentRuntime>(
(cradle) =>
new AgentRuntime(
cradle as unknown as ConstructorParameters<typeof AgentRuntime>[0],
),
).singleton(),
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
@ -49,6 +91,7 @@ container.register({
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
channelsConfigRepo: asClass<IChannelsConfigRepo>(FSChannelsConfigRepo).singleton(),
// ACP code-mode engine: the manager holds a live agent connection per chat only
// around an active turn (torn down after a short idle grace; resumed via
@ -62,6 +105,30 @@ container.register({
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
codeSessionService: asClass(CodeSessionService).singleton(),
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
// New turn/session runtime (turn-runtime-design.md / session-design.md).
// Bridges are constructed via asFunction so their optional test seams
// don't collide with strict PROXY cradle resolution.
clock: asClass<IClock>(SystemClock).singleton(),
turnsRootDir: asValue(path.join(WorkDir, "storage", "turns")),
sessionsRootDir: asValue(path.join(WorkDir, "storage", "sessions")),
turnRepo: asClass<ITurnRepo>(FSTurnRepo).singleton(),
contextResolver: asClass<IContextResolver>(TurnRepoContextResolver).singleton(),
lifecycleBus: asClass<ITurnLifecycleBus>(EmitterTurnLifecycleBus).singleton(),
usageReporter: asClass<IUsageReporter>(RealUsageReporter).singleton(),
agentResolver: asFunction<IAgentResolver>(() => new RealAgentResolver()).singleton(),
modelRegistry: asFunction<IModelRegistry>(() => new RealModelRegistry()).singleton(),
toolRegistry: asFunction<IToolRegistry>(() => new RealToolRegistry()).singleton(),
permissionChecker: asFunction<IPermissionChecker>(() => new RealPermissionChecker()).singleton(),
permissionClassifier: asFunction<IPermissionClassifier>(() => new RealPermissionClassifier()).singleton(),
turnRuntime: asClass<ITurnRuntime>(TurnRuntime).singleton(),
sessionRepo: asClass<ISessionRepo>(FSSessionRepo).singleton(),
sessionBus: asClass<ISessionBus>(EmitterSessionBus).singleton(),
sessions: asClass<ISessions>(SessionsImpl).singleton(),
defaultModelResolver:
asClass<IDefaultModelResolver>(DefaultModelResolver).singleton(),
headlessAgentRunner:
asClass<IHeadlessAgentRunner>(HeadlessAgentRunner).singleton(),
});
export default container;

View file

@ -2,9 +2,9 @@ import fs from 'fs';
import path from 'path';
import { google } from 'googleapis';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { runHeadlessAgent } from '../agents/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { loadUserConfig, updateUserEmail } from '../config/user_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
@ -281,14 +281,12 @@ async function processAgentNotes(): Promise<void> {
const timestamp = new Date().toISOString();
const message = `Current timestamp: ${timestamp}\n\nProcess the following source material and update the Agent Notes folder accordingly.\n\n${messageParts.join('\n\n')}`;
const agentRun = await createRun({
await runHeadlessAgent({
agentId: AGENT_ID,
message,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'agent_notes',
throwOnError: true,
});
await createMessage(agentRun.id, message);
await waitForRunCompletion(agentRun.id, { throwOnError: true });
// Mark everything as processed
for (const p of emailPaths) {

View file

@ -2,9 +2,8 @@ import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { getKgModel } from '../models/defaults.js';
import { createRun, createMessage } from '../runs/runs.js';
import { bus } from '../runs/bus.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import {
loadState,
@ -89,14 +88,6 @@ function hasNoiseLabels(content: string): boolean {
return false;
}
function extractPathFromToolInput(input: string): string | null {
try {
const parsed = JSON.parse(input) as { path?: string };
return typeof parsed.path === 'string' ? parsed.path : null;
} catch {
return null;
}
}
function ensureSuggestedTopicsFileLocation(): string {
if (fs.existsSync(SUGGESTED_TOPICS_PATH)) {
@ -252,13 +243,6 @@ async function createNotesFromBatch(
fs.mkdirSync(NOTES_OUTPUT_DIR, { recursive: true });
}
// Create a run for the note creation agent
const run = await createRun({
agentId: NOTE_CREATION_AGENT,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'build_graph',
});
const suggestedTopicsContent = readSuggestedTopicsFile();
// Build message with index and all files in the batch
@ -293,37 +277,19 @@ async function createNotesFromBatch(
message += `\n\n---\n\n`;
});
const notesCreated = new Set<string>();
const notesModified = new Set<string>();
const unsubscribe = await bus.subscribe(run.id, async (event) => {
if (event.type !== "tool-invocation") {
return;
}
if (event.toolName !== "file-writeText" && event.toolName !== "file-editText") {
return;
}
const toolPath = extractPathFromToolInput(event.input);
if (!toolPath) {
return;
}
if (event.toolName === "file-writeText") {
notesCreated.add(toolPath);
} else if (event.toolName === "file-editText") {
notesModified.add(toolPath);
}
const { turnId, state } = await runHeadlessAgent({
agentId: NOTE_CREATION_AGENT,
message,
model: await getKgModel(),
throwOnError: true,
});
await createMessage(run.id, message);
// Created/modified paths come from the durable turn state instead of
// streaming bus subscriptions.
const notesCreated = toolInputPaths(state, ["file-writeText"]);
const notesModified = toolInputPaths(state, ["file-editText"]);
// Wait for the run to complete
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, notesCreated, notesModified };
return { runId: turnId, notesCreated, notesModified };
}
/**

View file

@ -3,13 +3,12 @@ import path from 'path';
import { CronExpressionParser } from 'cron-parser';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage, fetchRun } from '../runs/runs.js';
import { runHeadlessAgent } from '../agents/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import container from '../di/container.js';
import type { IModelConfigRepo } from '../models/repo.js';
import { createProvider } from '../models/models.js';
import { inlineTask } from '@x/shared';
import { extractAgentResponse, waitForRunCompletion } from '../agents/utils.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
@ -470,13 +469,6 @@ async function processInlineTasks(): Promise<void> {
console.log(`[InlineTasks] Running task: "${task.instruction.slice(0, 80)}..."`);
try {
const run = await createRun({
agentId: INLINE_TASK_AGENT,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'inline_task_run',
});
const message = [
`Execute the following instruction from the note "${relativePath}":`,
'',
@ -488,10 +480,11 @@ async function processInlineTasks(): Promise<void> {
'```',
].join('\n');
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
const result = await extractAgentResponse(run.id);
const { summary: result } = await runHeadlessAgent({
agentId: INLINE_TASK_AGENT,
message,
model: await getKgModel(),
});
if (result) {
if (task.targetId) {
// Recurring task with target region — replace content inside the region
@ -555,13 +548,6 @@ export async function processRowboatInstruction(
scheduleLabel: string | null;
response: string | null;
}> {
const run = await createRun({
agentId: INLINE_TASK_AGENT,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'inline_task_run',
});
const message = [
`Process the following @rowboat instruction from the note "${notePath}":`,
'',
@ -573,10 +559,11 @@ export async function processRowboatInstruction(
'```',
].join('\n');
await createMessage(run.id, message);
await waitForRunCompletion(run.id);
const rawResponse = await extractAgentResponse(run.id);
const { summary: rawResponse } = await runHeadlessAgent({
agentId: INLINE_TASK_AGENT,
message,
model: await getKgModel(),
});
if (!rawResponse) {
return { instruction, schedule: null, scheduleLabel: null, response: null };
}

View file

@ -73,8 +73,10 @@ export interface KnowledgeIndex {
* Looks for patterns like **Field:** value or **Field:** [[Link]]
*/
function extractField(content: string, fieldName: string): string | undefined {
// Match **Field:** value (handles [[links]] and plain text)
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+?)(?:\\n|$)`, 'i');
// Match **Field:** value (handles [[links]] and plain text). Only consume
// spaces/tabs after the label — NOT newlines — so an empty field returns
// undefined instead of bleeding the next line's value into it.
const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*[ \\t]*(.+?)(?:\\r?\\n|$)`, 'i');
const match = content.match(pattern);
if (match) {
let value = match[1].trim();
@ -275,6 +277,34 @@ export function buildKnowledgeIndex(): KnowledgeIndex {
return index;
}
// ---------------------------------------------------------------------------
// Cached access
// ---------------------------------------------------------------------------
// buildKnowledgeIndex() does a synchronous recursive scan + read of the whole
// knowledge dir, so callers that run often (e.g. meeting prep) should use the
// cached accessor and rely on invalidateKnowledgeIndex() being called whenever
// a knowledge file changes (wired to the workspace watcher in the main process).
let cachedIndex: KnowledgeIndex | null = null;
/**
* Return the knowledge index, building it once and reusing it until invalidated.
*/
export function getKnowledgeIndex(): KnowledgeIndex {
if (!cachedIndex) {
cachedIndex = buildKnowledgeIndex();
}
return cachedIndex;
}
/**
* Drop the cached index so the next getKnowledgeIndex() rebuilds it. Call this
* whenever a file under knowledge/ is created, changed, moved, or deleted.
*/
export function invalidateKnowledgeIndex(): void {
cachedIndex = null;
}
/**
* Format the index as a string for inclusion in agent prompts
*/

View file

@ -1,10 +1,9 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import { bus } from '../runs/bus.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -70,13 +69,6 @@ function getUnlabeledEmails(state: LabelingState): string[] {
async function labelEmailBatch(
files: { path: string; content: string }[]
): Promise<{ runId: string; filesEdited: Set<string> }> {
const run = await createRun({
agentId: LABELING_AGENT,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'label_emails',
});
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
message += `**Important:** Use workspace-relative paths with file-editText (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
@ -92,33 +84,16 @@ async function labelEmailBatch(
message += `\n\n---\n\n`;
}
const filesEdited = new Set<string>();
const unsubscribe = await bus.subscribe(run.id, async (event) => {
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'file-editText') {
return;
}
try {
const parsed = JSON.parse(event.input) as { path?: string };
if (typeof parsed.path === 'string') {
filesEdited.add(parsed.path);
}
} catch {
// ignore parse errors
}
const { turnId, state } = await runHeadlessAgent({
agentId: LABELING_AGENT,
message,
model: await getKgModel(),
throwOnError: true,
});
await createMessage(run.id, message);
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, filesEdited };
// Edited paths come from the durable turn state instead of streaming
// bus subscriptions.
return { runId: turnId, filesEdited: toolInputPaths(state, ['file-editText']) };
}
/**

View file

@ -1,8 +1,8 @@
import type { LiveNote, LiveNoteTriggerType } from '@x/shared/dist/live-note.js';
import { fetchLiveNote, patchLiveNote, readNoteBody } from './fileops.js';
import { createRun, createMessage } from '../../runs/runs.js';
import { getLiveNoteAgentModel } from '../../models/defaults.js';
import { extractAgentResponse, waitForRunCompletion } from '../../agents/utils.js';
import { startHeadlessAgent } from '../../agents/headless-app.js';
import { withUseCase } from '../../analytics/use_case.js';
import { buildTriggerBlock } from '../../agents/build-trigger-block.js';
import { liveNoteBus } from './bus.js';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
@ -109,17 +109,20 @@ export async function runLiveNoteAgent(
const bodyBefore = await readNoteBody(filePath);
const model = live.model ?? await getLiveNoteAgentModel();
const agentRun = await createRun({
agentId: 'live-note-agent',
model,
...(live.provider ? { provider: live.provider } : {}),
useCase: 'live_note_agent',
// Use the granular trigger as the analytics sub-use-case so
// dashboards can break down agent runs by what woke them up
// (manual / cron / window / event). Pass 1 routing emits the
// separate `routing` sub-use-case from routing.ts.
subUseCase: trigger,
});
// The use-case context propagates to every tool the agent calls; the
// granular trigger doubles as the sub-use-case (manual / cron /
// window / event) so dashboards can break down what woke the agent.
const handle = await withUseCase(
{ useCase: 'live_note_agent', subUseCase: trigger },
() => startHeadlessAgent({
agentId: 'live-note-agent',
message: buildMessage(filePath, live, trigger, context),
model,
...(live.provider ? { provider: live.provider } : {}),
throwOnError: true,
}),
);
const agentRun = { id: handle.turnId };
log.log(`${filePath} — start trigger=${trigger} runId=${agentRun.id}`);
@ -141,14 +144,11 @@ export async function runLiveNoteAgent(
});
try {
await createMessage(agentRun.id, buildMessage(filePath, live, trigger, context));
// throwOnError: surface any error event in the run's log (LLM API
// failures, tool errors, billing/credit issues) as a rejection so
// the failure branch records lastRunError. Without this the run
// can "complete" with errors silently and we'd hit the success
// branch with an empty summary, clobbering any prior lastRunError.
await waitForRunCompletion(agentRun.id, { throwOnError: true });
const summary = await extractAgentResponse(agentRun.id);
// throwOnError: a failed/cancelled turn rejects here so the
// failure branch records lastRunError. Without this the turn
// could settle silently and we'd hit the success branch with an
// empty summary, clobbering any prior lastRunError.
const { summary } = await handle.done;
const bodyAfter = await readNoteBody(filePath);
const didUpdate = bodyAfter !== bodyBefore;

View file

@ -0,0 +1,230 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { WorkDir } from '../config/config.js';
import { getKnowledgeIndex } from './knowledge_index.js';
const KNOWLEDGE_DIR = path.join(WorkDir, 'knowledge');
/**
* A calendar attendee as it arrives from the renderer (sourced from the
* Google Calendar event's `attendees[]`).
*/
export interface MeetingPrepAttendee {
email?: string;
displayName?: string;
self?: boolean;
}
/**
* The note we resolved for a matched attendee. `markdown` is the full note
* body so the renderer can render it as-is no LLM, no summarisation.
*/
export interface MeetingPrepNote {
/** Workspace-relative path, e.g. "knowledge/People/Sarah Chen.md". */
path: string;
name: string;
role?: string;
organization?: string;
markdown: string;
}
/**
* One attendee after resolution. `note` is set when we found a person note,
* `null` otherwise (the "no note yet" case the UI offers to create).
*/
export interface MeetingPrepResolved {
/** Best display label — the note name, else displayName, else email. */
label: string;
email?: string;
displayName?: string;
note: MeetingPrepNote | null;
}
/** An organization note relevant to the meeting (resolved from attendee domains). */
export interface MeetingPrepOrg {
path: string;
name: string;
markdown: string;
}
export interface MeetingPrepResult {
/** Resolved attendees, matched ones first (notes-first ordering). */
attendees: MeetingPrepResolved[];
/** Distinct external organizations in the meeting that have a note. */
organizations: MeetingPrepOrg[];
/** How many have a note vs. not — convenience for the UI header. */
matchedCount: number;
unmatchedCount: number;
}
function norm(value: string | undefined): string {
return (value ?? '').trim().toLowerCase();
}
/** Normalize a display name for matching: drop parenthetical suffixes like
* "(via Google Calendar)" / "(Guest)" that calendars sometimes append. */
function normName(value: string | undefined): string {
return norm((value ?? '').replace(/\s*\([^)]*\)\s*$/g, ''));
}
/** Lowercased domain part of an email, or '' if there isn't one. */
function domainOf(email: string | undefined): string {
const e = norm(email);
const at = e.lastIndexOf('@');
return at >= 0 ? e.slice(at + 1) : '';
}
/** Tidy a field value for display: strip a leading knowledge folder segment so
* a link target like "Organizations/Rowboat Labs" reads as "Rowboat Labs". */
function displayRef(value: string | undefined): string | undefined {
if (!value) return undefined;
const m = value.match(/^(?:People|Organizations|Projects|Topics)\/(.+)$/i);
return (m ? m[1] : value).trim() || undefined;
}
/**
* Resolve a meeting's attendees against the knowledge base, returning each
* attendee's existing person note (or null). Deterministic: email-exact match
* first, then an unambiguous name/alias match on the display name.
*/
export async function resolveMeetingPrep(
attendees: MeetingPrepAttendee[],
): Promise<MeetingPrepResult> {
const index = getKnowledgeIndex();
// email -> person (first wins; emails are effectively unique).
const byEmail = new Map<string, (typeof index.people)[number]>();
// normalized name/alias -> people that carry it (for ambiguity checks).
const byName = new Map<string, (typeof index.people)[number][]>();
for (const person of index.people) {
const email = norm(person.email);
if (email && !byEmail.has(email)) byEmail.set(email, person);
for (const key of [person.name, ...person.aliases]) {
const nk = normName(key);
if (!nk) continue;
const list = byName.get(nk) ?? [];
list.push(person);
byName.set(nk, list);
}
}
// domain -> organization, and name/alias -> organization, for resolving the
// companies in the meeting from attendee email domains.
const orgByDomain = new Map<string, (typeof index.organizations)[number]>();
const orgByName = new Map<string, (typeof index.organizations)[number]>();
for (const org of index.organizations) {
const d = norm(org.domain);
if (d && !orgByDomain.has(d)) orgByDomain.set(d, org);
for (const key of [org.name, ...org.aliases]) {
const nk = norm(key);
if (nk && !orgByName.has(nk)) orgByName.set(nk, org);
}
}
// The user's own domain (from the self attendee) is "internal" — we never
// surface the user's own company as meeting context.
const selfDomain = domainOf(attendees.find((a) => a.self)?.email);
// Cache note reads so a person listed under multiple keys is read once.
const noteCache = new Map<string, MeetingPrepNote | null>();
const readNote = async (person: (typeof index.people)[number]): Promise<MeetingPrepNote | null> => {
if (noteCache.has(person.file)) return noteCache.get(person.file)!;
let note: MeetingPrepNote | null = null;
try {
const markdown = await fs.readFile(path.join(KNOWLEDGE_DIR, person.file), 'utf-8');
note = {
path: path.posix.join('knowledge', person.file.split(path.sep).join('/')),
name: person.name,
role: displayRef(person.role),
organization: displayRef(person.organization),
markdown,
};
} catch {
// Indexed file vanished between index build and read — treat as no note.
note = null;
}
noteCache.set(person.file, note);
return note;
};
const resolved: MeetingPrepResolved[] = [];
const seenFiles = new Set<string>();
// Distinct external orgs to surface, keyed by note file.
const orgEntries = new Map<string, (typeof index.organizations)[number]>();
for (const attendee of attendees) {
if (attendee.self) continue;
const email = norm(attendee.email);
const displayName = normName(attendee.displayName);
const domain = domainOf(attendee.email);
let person = email ? byEmail.get(email) : undefined;
if (!person && displayName) {
const candidates = byName.get(displayName);
// Only a single, unambiguous hit counts — never guess between two
// people who happen to share a name.
if (candidates && candidates.length === 1) person = candidates[0];
}
// Resolve the attendee's company — but only for external domains, so an
// internal standup doesn't surface the user's own org note. Prefer a
// domain match; fall back to the matched person's Organization field.
if (domain && domain !== selfDomain) {
const org =
orgByDomain.get(domain) ??
(person?.organization ? orgByName.get(norm(person.organization)) : undefined);
if (org) orgEntries.set(org.file, org);
}
const label =
person?.name ||
attendee.displayName?.trim() ||
attendee.email?.trim() ||
'Unknown';
const note = person ? await readNote(person) : null;
// Dedupe: the same person can appear once even if the calendar lists
// them twice (e.g. organizer + attendee).
if (note && seenFiles.has(note.path)) continue;
if (note) seenFiles.add(note.path);
resolved.push({
label,
email: attendee.email?.trim() || undefined,
displayName: attendee.displayName?.trim() || undefined,
note,
});
}
// Notes-first ordering; stable within each group.
resolved.sort((a, b) => {
if (Boolean(a.note) === Boolean(b.note)) return 0;
return a.note ? -1 : 1;
});
// Read the resolved org notes (skip any whose file vanished).
const organizations: MeetingPrepOrg[] = [];
for (const org of orgEntries.values()) {
try {
const markdown = await fs.readFile(path.join(KNOWLEDGE_DIR, org.file), 'utf-8');
organizations.push({
path: path.posix.join('knowledge', org.file.split(path.sep).join('/')),
name: org.name,
markdown,
});
} catch {
// Indexed file vanished between index build and read — skip it.
}
}
const matchedCount = resolved.filter((a) => a.note).length;
return {
attendees: resolved,
organizations,
matchedCount,
unmatchedCount: resolved.length - matchedCount,
};
}

View file

@ -0,0 +1,307 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { generateText } from 'ai';
import { WorkDir } from '../config/config.js';
import { createProvider } from '../models/models.js';
import { getDefaultModelAndProvider, getMeetingNotesModel, resolveProviderConfig } from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
import { parseFrontmatter } from '../application/lib/parse-frontmatter.js';
import { resolveMeetingPrep, type MeetingPrepResult } from './meeting_prep.js';
const MEETINGS_DIR = path.join(WorkDir, 'knowledge', 'Meetings');
const PREP_DIR = path.join(MEETINGS_DIR, 'prep');
/** The bits of a Google Calendar event we use for prep. */
interface CalendarEvent {
id?: string;
summary?: string;
description?: string;
status?: string;
recurringEventId?: string;
start?: { dateTime?: string; date?: string };
attendees?: Array<{ email?: string; displayName?: string; self?: boolean; responseStatus?: string }>;
}
export interface PrepNoteResult {
/** Workspace-relative path of the written note. */
path: string;
}
function norm(s: string | undefined): string {
return (s ?? '').trim().toLowerCase();
}
function slugify(s: string): string {
return (s || 'meeting')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'meeting';
}
/** Local YYYY-MM-DD for the event's start. */
function eventDateKey(event: CalendarEvent): string {
const iso = event.start?.dateTime ?? event.start?.date ?? '';
const d = iso ? new Date(iso) : null;
if (!d || Number.isNaN(d.getTime())) return 'undated';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Pull a "## Heading" section's body (until the next "## " or end). */
function extractSection(markdown: string, heading: string): string {
const headRe = new RegExp(`^##\\s+${heading}\\s*$`, 'i');
const out: string[] = [];
let capturing = false;
for (const line of markdown.split('\n')) {
if (capturing) {
if (/^##\s/.test(line)) break;
out.push(line);
} else if (headRe.test(line)) {
capturing = true;
}
}
return out.join('\n').trim();
}
interface PriorNote {
file: string; // workspace-relative
title: string;
date: string;
actionItems: string;
body: string;
}
/**
* Find the most recent prior meeting note for this series. We match by title
* resemblance to the event summary (notes don't yet store an event id), and
* only consider notes dated before the meeting.
*/
async function findLastMeetingNote(event: CalendarEvent): Promise<PriorNote | null> {
const summaryNorm = norm(event.summary);
if (!summaryNorm) return null;
const meetingDate = eventDateKey(event);
let entries: string[] = [];
try {
entries = (await fs.readdir(MEETINGS_DIR, { recursive: true }))
.filter((p) => p.endsWith('.md'));
} catch {
return null;
}
const candidates: PriorNote[] = [];
for (const rel of entries) {
// Skip our own generated prep notes.
if (rel.startsWith('prep/') || rel.startsWith(`prep${path.sep}`)) continue;
let raw: string;
try {
raw = await fs.readFile(path.join(MEETINGS_DIR, rel), 'utf-8');
} catch {
continue;
}
const { frontmatter, content } = parseFrontmatter(raw);
const fm = (frontmatter ?? {}) as Record<string, unknown>;
const title = String(fm.title ?? (content.match(/^#\s+(.+)$/m)?.[1] ?? '')).trim();
const date = String(fm.date ?? '').trim();
const titleNorm = norm(title);
// Series match: the event summary appears in the note title (e.g.
// "standup" within "Eng Standup — 2026-06-18").
if (!titleNorm || !titleNorm.includes(summaryNorm)) continue;
// Only prior instances.
if (date && meetingDate !== 'undated' && date >= meetingDate) continue;
candidates.push({
file: path.posix.join('knowledge', 'Meetings', rel.split(path.sep).join('/')),
title,
date,
actionItems: extractSection(content, 'Action items'),
body: content,
});
}
if (candidates.length === 0) return null;
// Most recent by date (notes without a date sort last).
candidates.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
return candidates[0];
}
/** True when this looks like a recurring meeting we have history for. */
function isRecurring(event: CalendarEvent, prior: PriorNote | null): boolean {
return Boolean(event.recurringEventId) && prior !== null;
}
/** Assemble the deterministic prep context for an event. */
async function assembleContext(event: CalendarEvent): Promise<{
roster: MeetingPrepResult;
prior: PriorNote | null;
recurring: boolean;
agenda: string;
}> {
const attendees = (event.attendees ?? []).map((a) => ({
email: a.email,
displayName: a.displayName,
self: a.self,
}));
const roster = await resolveMeetingPrep(attendees);
const prior = await findLastMeetingNote(event);
return {
roster,
prior,
recurring: isRecurring(event, prior),
agenda: (event.description ?? '').trim(),
};
}
const BRIEF_SYSTEM = `You write a short, concrete "what matters for this meeting" brief.
Rules:
- Use ONLY the context provided. Never invent facts, names, or commitments.
- 3-5 bullet points, one line each. No preamble, no headings, no sign-off.
- Lead with what the user should focus on or decide. Reference open items and
prior commitments by name where the context supplies them.
- If the context is thin, say so in one line rather than padding.`;
/** Generate the "what matters" brief via the user's configured model. */
async function generateBrief(event: CalendarEvent, ctx: Awaited<ReturnType<typeof assembleContext>>): Promise<string> {
const parts: string[] = [`Meeting: ${event.summary || '(untitled)'}`];
if (ctx.agenda) parts.push(`Agenda:\n${ctx.agenda}`);
if (ctx.prior?.actionItems) parts.push(`Action items from last time (${ctx.prior.date || 'prior'}):\n${ctx.prior.actionItems}`);
const attendeeLines = ctx.roster.attendees.map((a) => {
if (!a.note) return `- ${a.label} (no note)`;
const sub = [a.note.role, a.note.organization].filter(Boolean).join(', ');
return `- ${a.note.name}${sub ? `${sub}` : ''}`;
});
if (attendeeLines.length) parts.push(`Attendees:\n${attendeeLines.join('\n')}`);
const modelId = await getMeetingNotesModel();
const { provider: providerName } = await getDefaultModelAndProvider();
const providerConfig = await resolveProviderConfig(providerName);
const model = createProvider(providerConfig).languageModel(modelId);
const result = await withUseCase({ useCase: 'meeting_prep' }, () => generateText({
model,
system: BRIEF_SYSTEM,
prompt: parts.join('\n\n'),
}));
captureLlmUsage({ useCase: 'meeting_prep', model: modelId, provider: providerName, usage: result.usage });
return result.text.trim();
}
/** Render the prep note's markdown body (brief is optional). */
function renderPrepNote(event: CalendarEvent, ctx: Awaited<ReturnType<typeof assembleContext>>, brief: string, generatedAt: string): string {
const fm = [
'---',
'source: meeting-prep',
`title: "Prep: ${(event.summary || 'Meeting').replace(/"/g, "'")}"`,
`meetingDate: "${eventDateKey(event)}"`,
event.id ? `eventId: "${event.id}"` : null,
event.recurringEventId ? `recurringEventId: "${event.recurringEventId}"` : null,
`generatedAt: "${generatedAt}"`,
'---',
'',
].filter((l) => l !== null).join('\n');
const lines: string[] = [`# Prep: ${event.summary || 'Meeting'}`, ''];
if (brief) {
lines.push('## What matters', '', brief, '');
}
// Adaptive ordering: recurring → recap first; new → agenda first.
const recapBlock = ctx.prior
? ['## Last time', '', ctx.prior.actionItems
? ctx.prior.actionItems
: `See [[${ctx.prior.title}]].`, '']
: [];
const agendaBlock = ctx.agenda ? ['## Agenda', '', ctx.agenda, ''] : [];
if (ctx.recurring) {
lines.push(...recapBlock, ...agendaBlock);
} else {
lines.push(...agendaBlock, ...recapBlock);
}
// Roster — every attendee, linking to their note when we have one.
lines.push('## Whos coming', '');
for (const a of ctx.roster.attendees) {
if (a.note) {
const sub = [a.note.role, a.note.organization].filter(Boolean).join(', ');
lines.push(`- [[${a.note.name}]]${sub ? `${sub}` : ''}`);
} else {
lines.push(`- ${a.label} _(no note yet)_`);
}
}
lines.push('');
if (ctx.roster.organizations.length > 0) {
lines.push('## Companies', '');
for (const org of ctx.roster.organizations) lines.push(`- [[${org.name}]]`);
lines.push('');
}
return fm + lines.join('\n').trimEnd() + '\n';
}
/**
* Generate and write the prep note for a calendar event. Returns the note path,
* or null when there's nothing to prep (no other attendees). The AI brief is
* best-effort if no model is configured the note is still written with the
* deterministic sections.
*/
export async function generateAndWritePrep(eventJson: string): Promise<PrepNoteResult | null> {
const event = JSON.parse(eventJson) as CalendarEvent;
if (event.status === 'cancelled') return null;
if (!(event.attendees ?? []).some((a) => !a.self)) return null; // nobody else
const ctx = await assembleContext(event);
if (ctx.roster.attendees.length === 0) return null;
let brief = '';
try {
brief = await generateBrief(event, ctx);
} catch (err) {
console.error('[MeetingPrep] brief generation failed:', err);
}
const generatedAt = new Date().toISOString();
const body = renderPrepNote(event, ctx, brief, generatedAt);
await fs.mkdir(PREP_DIR, { recursive: true });
const fileName = `${slugify(event.summary || 'meeting')}-${eventDateKey(event)}.md`;
const absPath = path.join(PREP_DIR, fileName);
await fs.writeFile(absPath, body, 'utf-8');
return { path: path.posix.join('knowledge', 'Meetings', 'prep', fileName) };
}
/**
* Find the pre-generated prep note for a calendar event (matched by the
* `eventId` stamped in frontmatter) and return its path + "What matters" brief.
* Returns null when no prep has been generated yet.
*/
export async function readPrepNoteForEvent(eventId: string): Promise<{ path: string; brief: string } | null> {
if (!eventId) return null;
let files: string[];
try {
files = (await fs.readdir(PREP_DIR)).filter((f) => f.endsWith('.md'));
} catch {
return null;
}
for (const f of files) {
let raw: string;
try {
raw = await fs.readFile(path.join(PREP_DIR, f), 'utf-8');
} catch {
continue;
}
const { frontmatter, content } = parseFrontmatter(raw);
const fm = (frontmatter ?? {}) as Record<string, unknown>;
if (String(fm.eventId ?? '') !== eventId) continue;
return {
path: path.posix.join('knowledge', 'Meetings', 'prep', f),
brief: extractSection(content, 'What matters'),
};
}
return null;
}

View file

@ -0,0 +1,158 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import { WorkDir } from "../config/config.js";
import { generateAndWritePrep } from "./meeting_prep_brief.js";
// Generate prep up to 6h before a meeting. We tick every 5 minutes and scan the
// synced calendar — a calendar-aware loop fits "N hours before each meeting"
// better than a fixed cron, and re-reading the calendar each tick means moves
// and cancellations are picked up automatically.
const TICK_INTERVAL_MS = 15 * 60_000;
const PREP_LEAD_MS = 6 * 60 * 60_000;
// Drop state entries older than 24h so the file doesn't grow forever.
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
const STATE_FILE = path.join(WorkDir, "meeting_prep_state.json");
interface PrepState {
preppedEventIds: Record<string, { preppedAt: string; startTime: string }>;
}
interface CalendarEvent {
id?: string;
summary?: string;
status?: string;
start?: { dateTime?: string; date?: string };
attendees?: Array<{ self?: boolean; responseStatus?: string }>;
}
async function loadState(): Promise<PrepState> {
try {
const raw = await fs.readFile(STATE_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.preppedEventIds) {
return parsed as PrepState;
}
} catch {
// No state file yet, or corrupt — start fresh.
}
return { preppedEventIds: {} };
}
async function saveState(state: PrepState): Promise<void> {
// Write to a sibling tmp file then rename so a mid-write crash can't leave
// the JSON corrupt.
const tmp = `${STATE_FILE}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, STATE_FILE);
}
function gcState(state: PrepState): PrepState {
const cutoff = Date.now() - STATE_TTL_MS;
const fresh: PrepState["preppedEventIds"] = {};
for (const [id, entry] of Object.entries(state.preppedEventIds)) {
const ts = Date.parse(entry.preppedAt);
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
}
return { preppedEventIds: fresh };
}
function isAllDay(event: CalendarEvent): boolean {
return !event.start?.dateTime;
}
function isDeclinedBySelf(event: CalendarEvent): boolean {
return event.attendees?.find((a) => a.self)?.responseStatus === "declined";
}
async function tick(state: PrepState): Promise<{ state: PrepState; dirty: boolean }> {
let entries: Dirent[];
try {
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
} catch {
return { state, dirty: false };
}
const now = Date.now();
let dirty = false;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
if (entry.name.startsWith("sync_state")) continue;
const eventId = entry.name.replace(/\.json$/, "");
if (state.preppedEventIds[eventId]) continue;
let raw: string;
try {
raw = await fs.readFile(path.join(CALENDAR_SYNC_DIR, entry.name), "utf-8");
} catch {
continue;
}
let event: CalendarEvent;
try {
event = JSON.parse(raw);
} catch {
continue;
}
if (event.status === "cancelled") continue;
if (isAllDay(event)) continue;
if (isDeclinedBySelf(event)) continue;
if (!(event.attendees ?? []).some((a) => !a.self)) continue; // nobody else
const startStr = event.start?.dateTime;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (!Number.isFinite(startMs)) continue;
const msUntilStart = startMs - now;
if (msUntilStart > PREP_LEAD_MS) continue; // too far out
if (msUntilStart <= 0) continue; // already started — too late to pre-generate
try {
const result = await generateAndWritePrep(raw);
if (result) {
console.log(`[MeetingPrep] generated prep for "${event.summary ?? eventId}" → ${result.path}`);
}
} catch (err) {
console.error(`[MeetingPrep] prep generation failed for ${eventId}:`, err);
continue; // leave unmarked so we retry next tick
}
state.preppedEventIds[eventId] = {
preppedAt: new Date().toISOString(),
startTime: startStr,
};
dirty = true;
}
return { state, dirty };
}
export async function init(): Promise<void> {
console.log("[MeetingPrep] starting meeting prep scheduler");
console.log(`[MeetingPrep] tick every ${TICK_INTERVAL_MS / 60_000}m, lead ${PREP_LEAD_MS / 3_600_000}h`);
let state = gcState(await loadState());
while (true) {
try {
const result = await tick(state);
state = result.state;
if (result.dirty) {
state = gcState(state);
try {
await saveState(state);
} catch (err) {
console.error("[MeetingPrep] failed to save state:", err);
}
}
} catch (err) {
console.error("[MeetingPrep] tick failed:", err);
}
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
}
}

View file

@ -4,6 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis';
import { NodeHtmlMarkdown } from 'node-html-markdown'
import { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { getMaxEmails } from '../config/gmail_sync_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
@ -15,6 +16,10 @@ import { notifyIfEnabled } from '../application/notification/notifier.js';
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const LEGACY_CACHE_DIR = path.join(SYNC_DIR, 'cache');
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
// Local index of full-text search results. Kept separate from inbox_lists/ so it
// never leaks non-inbox threads into the inbox view. Grows as you search; we
// don't prune it (the user wants a durable local index).
const SEARCH_CACHE_DIR = path.join(WorkDir, 'search_index');
(function migrateLegacyCacheDir() {
try {
@ -95,6 +100,35 @@ function deleteCachedSnapshot(threadId: string): void {
}
}
// Local search index — same on-disk shape as the inbox cache, separate dir.
function searchCachePath(threadId: string): string {
return path.join(SEARCH_CACHE_DIR, `${encodeURIComponent(threadId)}.json`);
}
function readSearchSnapshot(threadId: string): SnapshotCacheEntry | null {
try {
const raw = fs.readFileSync(searchCachePath(threadId), 'utf-8');
return JSON.parse(raw) as SnapshotCacheEntry;
} catch {
return null;
}
}
function writeSearchSnapshot(threadId: string, historyId: string, snapshot: GmailThreadSnapshot): void {
try {
if (!fs.existsSync(SEARCH_CACHE_DIR)) fs.mkdirSync(SEARCH_CACHE_DIR, { recursive: true });
const entry: SnapshotCacheEntry = {
historyId,
fetchedAt: new Date().toISOString(),
parserVersion: SNAPSHOT_PARSER_VERSION,
snapshot,
};
fs.writeFileSync(searchCachePath(threadId), JSON.stringify(entry), 'utf-8');
} catch (err) {
console.warn(`[Gmail search index] write failed for ${threadId}:`, err);
}
}
async function getGmailClientOrThrow() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Gmail is not connected.');
@ -132,19 +166,19 @@ export async function trashThread(threadId: string): Promise<ThreadActionResult>
}
}
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
export async function markThreadRead(threadId: string, read: boolean = true): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['UNREAD'] },
requestBody: read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] },
});
// Update local cache: clear unread on all messages in the thread.
// Mirror the new read state onto every message in the cached thread.
const cached = readCachedSnapshot(threadId);
if (cached) {
for (const m of cached.snapshot.messages) m.unread = false;
cached.snapshot.unread = false;
for (const m of cached.snapshot.messages) m.unread = !read;
cached.snapshot.unread = !read;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
@ -176,6 +210,8 @@ export interface GmailThreadSnapshot {
importance?: 'important' | 'other';
draft_response?: string;
gmail_draft?: string;
/** Gmail-side draft id, present on entries from listDraftThreads. */
draftId?: string;
messages: Array<{
id?: string;
from?: string;
@ -192,8 +228,19 @@ export interface GmailThreadSnapshot {
mimeType?: string;
sizeBytes?: number;
savedPath: string;
messageId?: string;
attachmentId?: string;
}>;
messageIdHeader?: string;
isDraft?: boolean;
/**
* The draft's own stored In-Reply-To / References headers. Only set
* on draft messages (see buildDraftSnapshot) the composer reuses
* them on send since the Drafts pseudo-thread has no other messages
* to rebuild the reply chain from.
*/
inReplyToHeader?: string;
referencesHeader?: string;
}>;
}
@ -248,6 +295,7 @@ function notifyNewEmails(threads: SyncedThread[]): void {
const now = Date.now();
for (const { threadId } of threads) {
const snapshot = readCachedSnapshot(threadId)?.snapshot;
if (snapshot?.importance !== 'important') continue;
if (snapshot && isEmailTooOldToNotify(snapshotDateMs(snapshot), now)) continue;
const subject = snapshot?.subject?.trim() || '(no subject)';
const from = snapshot?.from?.trim();
@ -346,6 +394,10 @@ interface ExtractedAttachment {
mimeType?: string;
sizeBytes?: number;
savedPath: string;
// Gmail identifiers needed to fetch the attachment on demand (e.g. when a
// search result's attachment hasn't been downloaded to disk yet).
messageId?: string;
attachmentId?: string;
}
/**
@ -382,6 +434,8 @@ function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, ht
mimeType: part.mimeType ?? undefined,
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
savedPath: `gmail_sync/attachments/${safeName}`,
messageId: msgId,
attachmentId: attId,
});
}
}
@ -792,6 +846,45 @@ async function buildAndCacheSnapshot(
) {
return cached.snapshot;
}
const snapshot = await parseThreadSnapshot(threadId, threadData, gmailClient);
if (!snapshot) return null;
try {
const userEmail = await getUserEmail(auth);
const skipDraft = (snapshot.gmail_draft?.length ?? 0) > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) {
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
if (draftResponse) snapshot.draft_response = draftResponse;
}
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
}
/**
* Parse a threads.get response into a snapshot WITHOUT AI classification or
* caching the shared core of buildAndCacheSnapshot, also used by search (which
* doesn't need importance/summary). Returns null when there are no visible
* (non-draft) messages.
*/
async function parseThreadSnapshot(
threadId: string,
threadData: gmail.Schema$Thread,
gmailClient: gmail.Gmail,
): Promise<GmailThreadSnapshot | null> {
const messages = threadData.messages;
if (!messages || messages.length === 0) return null;
const cached = readCachedSnapshot(threadId);
const heightCarryover = new Map<string, number>();
if (cached) {
for (const m of cached.snapshot.messages) {
@ -855,7 +948,7 @@ async function buildAndCacheSnapshot(
.filter(Boolean)
.join('\n\n');
const snapshot: GmailThreadSnapshot = {
return {
threadId,
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
subject: latest.subject || visibleMessages[0]?.subject,
@ -868,26 +961,6 @@ async function buildAndCacheSnapshot(
messages: visibleMessages,
gmail_draft: latestDraftBody || undefined,
};
try {
const userEmail = await getUserEmail(auth);
const skipDraft = latestDraftBody.length > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) {
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
if (draftResponse) snapshot.draft_response = draftResponse;
}
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
}
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
@ -919,6 +992,83 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
return null;
}
export interface DownloadAttachmentResult {
ok: boolean;
error?: string;
}
/**
* Ensure an attachment referenced by a snapshot exists on disk, downloading it
* on demand when it doesn't. Inbox attachments are saved during sync, but
* search results build snapshots without downloading, so opening one of their
* attachments needs this. `savedPath` is the workspace-relative path stored on
* the attachment; `attachmentId` (when supplied) is tried first, falling back
* to re-fetching the message and locating the part by filename attachment ids
* can go stale on a cached snapshot, whereas the file name is stable.
*/
export async function downloadAttachment(args: {
messageId: string;
savedPath: string;
attachmentId?: string;
}): Promise<DownloadAttachmentResult> {
try {
const { messageId, savedPath, attachmentId } = args;
if (!messageId || !savedPath) return { ok: false, error: 'Missing attachment reference.' };
const absPath = path.join(WorkDir, savedPath);
if (fs.existsSync(absPath)) return { ok: true };
const gmailClient = await getGmailClientOrThrow();
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fetchData = async (attId: string): Promise<string | null> => {
const res = await gmailClient.users.messages.attachments.get({
userId: 'me',
messageId,
id: attId,
});
return res.data.data ?? null;
};
let data: string | null = null;
if (attachmentId) {
try {
data = await fetchData(attachmentId);
} catch (err) {
console.warn(`[Gmail] attachment fetch by id failed for ${messageId}, retrying by filename:`, err);
}
}
if (!data) {
// Re-fetch the message and locate the attachment part whose derived
// saved name matches the requested savedPath.
const wanted = path.basename(savedPath);
const msg = await gmailClient.users.messages.get({ userId: 'me', id: messageId, format: 'full' });
let foundAttId: string | undefined;
const walk = (part: gmail.Schema$MessagePart): void => {
if (foundAttId) return;
const fn = part.filename;
const attId = part.body?.attachmentId;
if (fn && attId && `${messageId}_${cleanFilename(fn)}` === wanted) {
foundAttId = attId;
return;
}
if (part.parts) for (const sub of part.parts) walk(sub);
};
if (msg.data.payload) walk(msg.data.payload);
if (!foundAttId) return { ok: false, error: 'Attachment not found in message.' };
data = await fetchData(foundAttId);
}
if (!data) return { ok: false, error: 'Attachment had no data.' };
fs.writeFileSync(absPath, Buffer.from(data, 'base64'));
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// --- Sync Logic ---
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
@ -1148,24 +1298,29 @@ async function backfillMissingRecentThreads(
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
const gmail = google.gmail({ version: 'v1', auth });
// If the state file holds a last_sync timestamp (e.g. left over from a
// prior Composio sync, or from a previous successful native sync that
// we're falling back to after a history.list 404), use that as the
// floor — but never reach back further than lookbackDays. This caps the
// window at "1 week at most": if last_sync is within the lookback window
// we resume from it (a smaller window), otherwise we clamp to lookbackDays
// ago. Mail older than the cap that arrived during a long offline gap is
// intentionally skipped rather than backfilled.
// The onboarding / recovery fetch is bounded by a COUNT of the most recent
// threads (maxEmails, configurable — default 500), not by a fixed date
// window. So a fresh account pulls its newest `maxEmails` emails even when
// they span more than a week.
//
// When we can resume after a previous successful sync (a last_sync within
// the lookback window — e.g. the history.list 404 fallback, or a prior
// Composio sync), we still floor the query at last_sync so only genuinely
// new mail is re-walked, and the count cap acts purely as a safety bound.
// With no resumable last_sync (first connect, or a gap longer than the
// lookback window) we drop the date floor entirely and just take the newest
// `maxEmails` threads.
const maxEmails = getMaxEmails();
const state = loadState(stateFile);
const lookbackFloor = new Date();
lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays);
let pastDate: Date;
if (state.last_sync && new Date(state.last_sync) > lookbackFloor) {
pastDate = new Date(state.last_sync);
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
const resumeFrom = state.last_sync && new Date(state.last_sync) > lookbackFloor
? new Date(state.last_sync)
: null;
if (resumeFrom) {
console.log(`Performing full sync from last_sync=${state.last_sync} (max ${maxEmails} threads)...`);
} else {
pastDate = lookbackFloor;
console.log(`Performing full sync of last ${lookbackDays} days...`);
console.log(`Performing full sync of the newest ${maxEmails} threads...`);
}
let run: ServiceRunContext | null = null;
@ -1180,19 +1335,24 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
};
try {
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
const baseQuery = '-in:spam -in:trash';
const q = resumeFrom
? `after:${resumeFrom.toISOString().split('T')[0].replace(/-/g, '/')} ${baseQuery}`
: baseQuery;
// Get History ID
const profile = await gmail.users.getProfile({ userId: 'me' });
const currentHistoryId = profile.data.historyId!;
// Gmail returns threads newest-first, so paginating until we've collected
// maxEmails ids yields the most recent maxEmails threads.
const threadIds: string[] = [];
let pageToken: string | undefined;
do {
const res = await gmail.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
q,
maxResults: Math.min(500, maxEmails),
pageToken
});
@ -1205,7 +1365,9 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
}
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
} while (pageToken && threadIds.length < maxEmails);
if (threadIds.length > maxEmails) threadIds.length = maxEmails;
if (threadIds.length === 0) {
saveState(currentHistoryId, stateFile);
@ -1317,6 +1479,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
for (const item of record.messagesAdded) {
const labels = item.message?.labelIds ?? [];
if (labels.includes('SPAM') || labels.includes('TRASH')) continue;
// Drafts are not incoming mail: every composer autosave
// (ours or another Gmail client's) adds a DRAFT message.
// Processing it would leak unsent draft bodies into
// gmail_sync/ markdown + knowledge events, fire "New
// email" notifications, and re-run the LLM classifier per
// autosave. The Drafts view reads live via gmail:getDrafts
// instead.
if (labels.includes('DRAFT')) continue;
if (item.message?.threadId) {
threadIds.add(item.message.threadId);
}
@ -1434,10 +1604,10 @@ async function performSync() {
// partial-sync on subsequent calls.
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
// partialSync replays *every* messageAdded since the stored historyId,
// regardless of date — so after a long offline gap a still-valid
// historyId would pull the entire gap (e.g. 3 weeks). To honor the
// "1 week at most" cap, bypass it when last_sync is older than the
// lookback window and run a (date-clamped) fullSync instead.
// regardless of date/count — so after a long offline gap a still-valid
// historyId would pull the entire gap (e.g. 3 weeks). When last_sync is
// older than the lookback window, bypass it and run fullSync instead,
// which is count-bounded (the newest maxEmails threads).
const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0;
const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
if (!state.historyId) {
@ -1447,7 +1617,7 @@ async function performSync() {
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else if (gapTooLarge) {
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`);
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running count-bounded full sync instead of partial sync...`);
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else {
console.log("History ID found, starting partial sync...");
@ -1485,6 +1655,19 @@ export interface SendReplyResult {
error?: string;
}
export interface SaveDraftOptions extends Omit<SendReplyOptions, 'to'> {
/** Recipient may be blank while a draft is still being written. */
to?: string;
/** Existing Gmail draft to update; omitted on first save (creates a new one). */
draftId?: string;
}
export interface SaveDraftResult {
/** The Gmail-side draft id, to be passed back on subsequent saves. */
draftId?: string;
error?: string;
}
export interface GmailConnectionStatus {
connected: boolean;
hasRequiredScope: boolean;
@ -1588,6 +1771,88 @@ function sanitizeAttachmentName(name: string): string {
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
}
// Build the raw (base64url) RFC 2822 message shared by both send and draft-save.
// Recipient headers are omitted when blank, so an in-progress draft with no
// `To` yet still produces a valid message. `isEmpty` lets callers reject a
// whitespace-only body without re-parsing the result.
function buildRawMimeMessage(opts: SaveDraftOptions, userEmail: string): { raw: string; isEmpty: boolean } {
const safeTo = opts.to?.trim() ? requireSafeHeaderValue('To', opts.to) : undefined;
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
const replyBody = opts.threadId
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
const isEmpty = !replyBody.bodyText.trim();
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
if (safeTo) headers.push(`To: ${safeTo}`);
if (safeCc) headers.push(`Cc: ${safeCc}`);
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { raw, isEmpty };
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
@ -1597,82 +1862,11 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
const userEmail = await getUserEmail(auth);
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
const safeTo = requireSafeHeaderValue('To', opts.to);
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
const replyBody = opts.threadId
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
if (!opts.to?.trim()) return { error: 'Add at least one recipient.' };
const built = buildRawMimeMessage(opts, userEmail);
if (built.isEmpty) return { error: 'Draft is empty.' };
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
if (safeCc) headers.push(`Cc: ${safeCc}`);
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const requestBody: gmail.Schema$Message = { raw };
const requestBody: gmail.Schema$Message = { raw: built.raw };
if (opts.threadId) requestBody.threadId = opts.threadId;
const res = await gmailClient.users.messages.send({
@ -1706,6 +1900,294 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
}
}
/**
* Save the composer's contents as a Gmail draft. Drafts created here live in the
* user's real Gmail account, so they show up in the Drafts folder of every Gmail
* client and sync back down via the normal history sync (the `gmail_draft` field).
*
* Passing `draftId` updates that existing draft in place. If it's omitted but a
* draft already exists for `threadId` (e.g. a reply opened in a new session),
* that draft is reused instead of creating a duplicate. A stale `draftId`
* (deleted/sent elsewhere) falls back to creating a fresh draft.
*/
export async function saveThreadDraft(opts: SaveDraftOptions): Promise<SaveDraftResult> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
const userEmail = await getUserEmail(auth);
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
const built = buildRawMimeMessage(opts, userEmail);
if (built.isEmpty) return { error: 'Draft is empty.' };
const message: gmail.Schema$Message = { raw: built.raw };
if (opts.threadId) message.threadId = opts.threadId;
// Resolve which draft to update: explicit id wins; otherwise reuse an
// existing draft on the same thread so replies don't pile up duplicates.
let draftId = opts.draftId;
if (!draftId && opts.threadId) {
try {
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
const existing = (drafts.data.drafts || []).find(
(d) => d.message?.threadId === opts.threadId && d.id
);
if (existing?.id) draftId = existing.id;
} catch {
// Listing failed — fall through and create a new draft.
}
}
let res;
if (draftId) {
try {
res = await gmailClient.users.drafts.update({
userId: 'me',
id: draftId,
requestBody: { message },
});
} catch (err) {
const code = (err as { code?: number })?.code
?? (err as { response?: { status?: number } })?.response?.status;
// Recreate only when the draft is actually gone (deleted or
// already sent). A transient failure (timeout, 5xx) must NOT
// fall back to create — the original draft still exists, so
// that would silently pile up duplicates in Gmail.
if (code !== 404 && code !== 410) throw err;
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
}
} else {
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
}
// Mirror the draft body onto the thread's cached snapshot so reopening
// the reply composer shows the autosaved text. Surgical, like
// markThreadRead — draft messages are filtered out of the history sync
// (see partialSync), so no sync pass will refresh this, and waking the
// whole sync loop per autosave (md/event writes + LLM reclassification)
// is exactly what we're avoiding.
if (opts.threadId) {
const cached = readCachedSnapshot(opts.threadId);
if (cached) {
cached.snapshot.gmail_draft = opts.bodyText?.trim() || undefined;
try {
fs.writeFileSync(cachePath(opts.threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
console.warn(`[Gmail cache] draft write failed for ${opts.threadId}:`, err);
}
}
}
return { draftId: res.data.id || undefined };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/** Delete a Gmail draft by id. A missing draft is treated as success. */
export async function deleteThreadDraft(draftId: string): Promise<{ ok: boolean; error?: string }> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { ok: false, error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
await gmailClient.users.drafts.delete({ userId: 'me', id: draftId });
triggerSync();
return { ok: true };
} catch (err) {
const code = (err as { code?: number; response?: { status?: number } })?.code
?? (err as { response?: { status?: number } })?.response?.status;
// Already gone (sent/deleted) — nothing to do.
if (code === 404 || code === 410) return { ok: true };
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// In-memory cache of built draft snapshots, keyed by draftId and validated by
// the draft's underlying message id. Gmail assigns a fresh message id whenever a
// draft is updated (locally via saveThreadDraft or in another client), so an
// unchanged message id means the parsed snapshot can be reused — we skip the
// per-draft drafts.get + body parse, mirroring listInboxPage's mtime cache.
interface DraftCacheEntry {
messageId: string;
snapshot: GmailThreadSnapshot;
}
const draftListCache = new Map<string, DraftCacheEntry>();
// Fetch one draft and parse it into a lightweight snapshot for the Drafts view.
async function buildDraftSnapshot(
gmailClient: gmail.Gmail,
draftId: string,
): Promise<GmailThreadSnapshot | null> {
const full = await gmailClient.users.drafts.get({ userId: 'me', id: draftId, format: 'full' });
const msg = full.data.message;
if (!msg) return null;
const headers = msg.payload?.headers || [];
const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' };
const rawBody = msg.payload ? normalizeBody(getBody(msg.payload)) : '';
const body = stripGmailQuotedReplyText(rawBody);
const subject = headerValue(headers, 'Subject') || '';
const from = headerValue(headers, 'From') || '';
const to = headerValue(headers, 'To') || '';
const cc = headerValue(headers, 'Cc') || '';
const date = headerValue(headers, 'Date') || '';
const threadId = msg.threadId || draftId;
const messageIdHeader =
headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined;
// The reply chain the draft already carries. The composer must reuse these
// on send — this pseudo-thread has no other messages to rebuild them from,
// and deriving them from the draft itself would self-reference a
// Message-ID that never gets delivered (breaking recipients' threading).
const inReplyToHeader = headerValue(headers, 'In-Reply-To') || undefined;
const referencesHeader = headerValue(headers, 'References') || undefined;
return {
threadId,
threadUrl: `https://mail.google.com/mail/u/0/#drafts?compose=${draftId}`,
subject,
from,
to,
date,
latest_email: body,
gmail_draft: body || undefined,
draftId,
unread: false,
messages: [{
id: msg.id || undefined,
from,
to,
cc: cc || undefined,
date,
subject,
body,
bodyHtml: parts.html || undefined,
messageIdHeader,
isDraft: true,
inReplyToHeader,
referencesHeader,
}],
};
}
/**
* List the account's Gmail drafts (reply drafts and standalone new-message
* drafts) as lightweight thread snapshots for the Drafts view. Drafts aren't
* part of the INBOX snapshot cache, so we read them from the Gmail API but a
* cheap drafts.list (ids only) lets us reuse already-parsed snapshots for
* unchanged drafts and only drafts.get the new/edited ones. No AI
* classification; recipients/subject/body come straight off the draft message.
*/
export async function listDraftThreads(): Promise<{ threads: GmailThreadSnapshot[]; error?: string }> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) {
draftListCache.clear();
return { threads: [], error: 'Gmail is not connected.' };
}
const gmailClient = google.gmail({ version: 'v1', auth });
const list = await gmailClient.users.drafts.list({ userId: 'me', maxResults: 50 });
const drafts = list.data.drafts || [];
const seen = new Set<string>();
const built = await Promise.all(drafts.map(async (d) => {
if (!d.id) return null;
seen.add(d.id);
const messageId = d.message?.id || '';
const cached = draftListCache.get(d.id);
// Reuse the cached snapshot when the draft's message id is unchanged.
if (cached && messageId && cached.messageId === messageId) {
return cached.snapshot;
}
try {
const snapshot = await buildDraftSnapshot(gmailClient, d.id);
if (snapshot) draftListCache.set(d.id, { messageId, snapshot });
return snapshot;
} catch (err) {
console.warn('[Gmail] draft fetch failed:', err);
// Fall back to a stale cached copy if we have one.
return cached?.snapshot ?? null;
}
}));
// Evict cache entries for drafts that no longer exist (sent/deleted).
for (const key of draftListCache.keys()) {
if (!seen.has(key)) draftListCache.delete(key);
}
const threads = built.filter((s): s is GmailThreadSnapshot => s !== null);
// Newest first.
threads.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0);
});
return { threads };
} catch (err) {
return { threads: [], error: err instanceof Error ? err.message : String(err) };
}
}
export interface SearchResult {
threads: GmailThreadSnapshot[];
error?: string;
}
/**
* Full-text search across the ENTIRE Gmail mailbox (not just locally-synced
* mail) using Gmail's `q` query. Each matching thread is parsed into a snapshot
* and written to the local search index so repeat searches and opening a
* result are instant. Reuses the inbox cache when a thread is already synced
* there. No AI classification.
*/
export async function searchThreads(query: string, opts: { limit?: number } = {}): Promise<SearchResult> {
const q = query.trim();
if (!q) return { threads: [] };
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { threads: [], error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
// Generous cap so the index isn't artificially small (Gmail allows 500).
const limit = Math.max(1, Math.min(200, opts.limit ?? 100));
const list = await gmailClient.users.threads.list({ userId: 'me', q, maxResults: limit });
const ids = (list.data.threads || [])
.map((t) => t.id)
.filter((id): id is string => Boolean(id));
const built = await Promise.all(ids.map(async (threadId) => {
// Prefer the inbox snapshot (kept fresh by sync), then the search index.
const inboxCached = readCachedSnapshot(threadId);
if (inboxCached?.snapshot) return inboxCached.snapshot;
const indexed = readSearchSnapshot(threadId);
if (indexed?.snapshot) return indexed.snapshot;
try {
const threadData = await gmailClient.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
const snapshot = await parseThreadSnapshot(threadId, threadData.data, gmailClient);
if (snapshot) writeSearchSnapshot(threadId, threadData.data.historyId || '', snapshot);
return snapshot;
} catch (err) {
console.warn(`[Gmail search] fetch failed for ${threadId}:`, err);
return null;
}
}));
const threads = built.filter((s): s is GmailThreadSnapshot => s !== null);
// Newest first.
threads.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0);
});
return { threads };
} catch (err) {
return { threads: [], error: err instanceof Error ? err.message : String(err) };
}
}
export async function init() {
console.log("Starting Gmail Sync (TS)...");
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);

View file

@ -1,10 +1,9 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { runHeadlessAgent, toolInputPaths } from '../agents/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import { bus } from '../runs/bus.js';
import { getErrorDetails, waitForRunCompletion } from '../agents/utils.js';
import { getErrorDetails } from '../agents/utils.js';
import { serviceLogger } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import {
@ -83,13 +82,6 @@ function getUntaggedNotes(state: NoteTaggingState): string[] {
async function tagNoteBatch(
files: { path: string; content: string }[]
): Promise<{ runId: string; filesEdited: Set<string> }> {
const run = await createRun({
agentId: NOTE_TAGGING_AGENT,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'tag_notes',
});
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
message += `**Important:** Use workspace-relative paths with file-editText (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
@ -105,33 +97,16 @@ async function tagNoteBatch(
message += `\n\n---\n\n`;
}
const filesEdited = new Set<string>();
const unsubscribe = await bus.subscribe(run.id, async (event) => {
if (event.type !== 'tool-invocation') {
return;
}
if (event.toolName !== 'file-editText') {
return;
}
try {
const parsed = JSON.parse(event.input) as { path?: string };
if (typeof parsed.path === 'string') {
filesEdited.add(parsed.path);
}
} catch {
// ignore parse errors
}
const { turnId, state } = await runHeadlessAgent({
agentId: NOTE_TAGGING_AGENT,
message,
model: await getKgModel(),
throwOnError: true,
});
await createMessage(run.id, message);
try {
await waitForRunCompletion(run.id, { throwOnError: true });
} finally {
unsubscribe();
}
return { runId: run.id, filesEdited };
// Edited paths come from the durable turn state instead of streaming
// bus subscriptions.
return { runId: turnId, filesEdited: toolInputPaths(state, ['file-editText']) };
}
/**

View file

@ -0,0 +1,15 @@
{"type":"start","runId":"2026-07-02T15-55-29Z-0017565-000","agentName":"background-task-agent","model":"google/gemini-3.1-flash-lite","provider":"rowboat","permissionMode":"manual","useCase":"background_task_agent","subUseCase":"manual","subflow":[],"ts":"2026-07-02T15:55:29.568Z"}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"message","messageId":"2026-07-02T15-55-29Z-0017565-001","message":{"role":"user","content":"Run the background task at `bg-tasks/mohali-weather-brief/`.\n\n**Time:** Thursday, July 2, 2026 at 9:25:29 PM GMT+5:30 (Asia/Calcutta)\n\n**Instructions:**\nGet and show the latest weather in Mohali in degree celsius\n\nYour task folder is `bg-tasks/mohali-weather-brief/`. The user-visible artifact is `bg-tasks/mohali-weather-brief/index.md` — read it with `file-readText` and update it with `file-editText` per the OUTPUT / ACTION mode rule. Do not touch `bg-tasks/mohali-weather-brief/task.yaml` (the runtime owns it).\n\n**Trigger:** Manual run (user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool).","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 9:25 PM GMT+5:30"}},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-31Z-0017565-000","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"oFDM4XYN","toolName":"file-readText","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbHK4P4fV56Kb79TR4GLeN0KXeYhT+YPCqiTE4fmExcz7koIh6pfWrr8i4wlW2X","id":"oFDM4XYN","format":"google-gemini-v1","index":0}]}}},{"type":"reasoning","text":"[REDACTED]"}],"providerOptions":{"openrouter":{"usage":{"promptTokens":12311,"promptTokensDetails":{"cachedTokens":0},"completionTokens":30,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":12341,"costDetails":{"upstreamInferenceCost":0.00312275}},"provider":"Google AI Studio","reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbHK4P4fV56Kb79TR4GLeN0KXeYhT+YPCqiTE4fmExcz7koIh6pfWrr8i4wlW2X","id":"oFDM4XYN","format":"google-gemini-v1","index":0}]}}},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-invocation","toolCallId":"oFDM4XYN","toolName":"file-readText","input":"{\"query\": \"redacted\"}","subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-result","toolCallId":"oFDM4XYN","toolName":"file-readText","result":{"ok":true,"note":"redacted for fixture"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-31Z-0017565-001","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"oFDM4XYN","toolName":"file-readText"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-33Z-0017565-000","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"OAs24vk5","toolName":"web-search","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbHs6o9nrkXG1745HqUQkXR2/g4bvozLYGNP63eCEFEIDPzSKvW+CBAyOuSl46C","id":"OAs24vk5","format":"google-gemini-v1","index":0}]}}},{"type":"reasoning","text":"[REDACTED]"}],"providerOptions":{"openrouter":{"usage":{"promptTokens":12609,"promptTokensDetails":{"cachedTokens":8079},"completionTokens":21,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":12630,"costDetails":{"upstreamInferenceCost":0.001365975}},"provider":"Google AI Studio","reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbHs6o9nrkXG1745HqUQkXR2/g4bvozLYGNP63eCEFEIDPzSKvW+CBAyOuSl46C","id":"OAs24vk5","format":"google-gemini-v1","index":0}]}}},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-invocation","toolCallId":"OAs24vk5","toolName":"web-search","input":"{\"query\": \"redacted\"}","subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-result","toolCallId":"OAs24vk5","toolName":"web-search","result":{"ok":true,"note":"redacted for fixture"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-34Z-0017565-000","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"OAs24vk5","toolName":"web-search"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-36Z-0017565-000","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"xcoaRes7","toolName":"file-editText","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbH+d29EP8/ZWH5EL3vSL+Nb9k5tsCVzMyINtH7vfhAvqeB7hBGY1L60w2BRWn4","id":"xcoaRes7","format":"google-gemini-v1","index":0}]}}},{"type":"reasoning","text":"[REDACTED]"}],"providerOptions":{"openrouter":{"usage":{"promptTokens":17340,"promptTokensDetails":{"cachedTokens":12157},"completionTokens":75,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":17415,"costDetails":{"upstreamInferenceCost":0.001712175}},"provider":"Google AI Studio","reasoning_details":[{"type":"reasoning.encrypted","data":"EjQKMgEMOdbH+d29EP8/ZWH5EL3vSL+Nb9k5tsCVzMyINtH7vfhAvqeB7hBGY1L60w2BRWn4","id":"xcoaRes7","format":"google-gemini-v1","index":0}]}}},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-invocation","toolCallId":"xcoaRes7","toolName":"file-editText","input":"{\"query\": \"redacted\"}","subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","type":"tool-result","toolCallId":"xcoaRes7","toolName":"file-editText","result":{"ok":true,"note":"redacted for fixture"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-36Z-0017565-001","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"xcoaRes7","toolName":"file-editText"},"subflow":[]}
{"runId":"2026-07-02T15-55-29Z-0017565-000","messageId":"2026-07-02T15-55-37Z-0017565-000","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Updated — Mohali: 30.5°C · Clear Sky (as of 9:00 PM IST)."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":17487,"promptTokensDetails":{"cachedTokens":12151},"completionTokens":26,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":17513,"costDetails":{"upstreamInferenceCost":0.001676775}},"provider":"Google AI Studio","reasoning_details":[{"type":"reasoning.text","signature":"EjQKMgEMOdbHosBKKoAUZMNdqBPqUrsYCf2gYO8SL1TPjujCq2Zfl+Iia3fy5vIqcd8yY7TI","format":"google-gemini-v1","index":0}]}}},"subflow":[]}

View file

@ -0,0 +1,7 @@
{"type":"start","runId":"2026-07-02T15-29-15Z-0011690-000","agentName":"copilot","model":"anthropic/claude-opus-4.7","provider":"rowboat","permissionMode":"manual","useCase":"copilot_chat","subflow":[],"ts":"2026-07-02T15:29:15.130Z"}
{"runId":"2026-07-02T15-29-15Z-0011690-000","type":"message","messageId":"2026-07-02T15-29-15Z-0011690-001","message":{"role":"user","content":"run the following command:\nbrew install foobar","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 8:59 PM GMT+5:30","middlePane":{"kind":"empty"}}},"subflow":[]}
{"runId":"2026-07-02T15-29-15Z-0011690-000","messageId":"2026-07-02T15-29-17Z-0011690-002","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"toolu_bdrk_01Vq7Vjphw18SzBskW1F38QP","toolName":"executeCommand","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[]}}},{"type":"text","text":"I'll run that for you."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":24249,"promptTokensDetails":{"cachedTokens":0},"completionTokens":79,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":24328,"costDetails":{"upstreamInferenceCost":0.12322}},"provider":"Amazon Bedrock"}}},"subflow":[]}
{"runId":"2026-07-02T15-29-15Z-0011690-000","type":"tool-permission-request","toolCall":{"type":"tool-call","toolCallId":"toolu_bdrk_01Vq7Vjphw18SzBskW1F38QP","toolName":"executeCommand","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[]}}},"permission":{"kind":"command","commandNames":["brew"]},"subflow":[]}
{"subflow":[],"toolCallId":"toolu_bdrk_01Vq7Vjphw18SzBskW1F38QP","response":"deny","runId":"2026-07-02T15-29-15Z-0011690-000","type":"tool-permission-response"}
{"runId":"2026-07-02T15-29-15Z-0011690-000","messageId":"2026-07-02T15-29-19Z-0011690-002","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"toolu_bdrk_01Vq7Vjphw18SzBskW1F38QP","toolName":"executeCommand"},"subflow":[]}
{"runId":"2026-07-02T15-29-15Z-0011690-000","messageId":"2026-07-02T15-29-24Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"The command was denied by the approval prompt, so `brew install foobar` didn't run. If you'd like to try again, approve the prompt when it appears — or let me know if you want me to try a different package name (Homebrew doesn't have an official `foobar` formula, so it would likely fail with \"No available formula\" anyway)."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":24357,"promptTokensDetails":{"cachedTokens":0},"completionTokens":104,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":24461,"costDetails":{"upstreamInferenceCost":0.124385}},"provider":"Amazon Bedrock"}}},"subflow":[]}

View file

@ -0,0 +1,16 @@
{"type":"start","runId":"2026-07-02T15-27-08Z-0011690-000","agentName":"copilot","model":"anthropic/claude-opus-4.7","provider":"rowboat","permissionMode":"auto","useCase":"copilot_chat","subflow":[],"ts":"2026-07-02T15:27:08.939Z"}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"message","messageId":"2026-07-02T15-27-09Z-0011690-000","message":{"role":"user","content":"yo","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 8:57 PM GMT+5:30","middlePane":{"kind":"empty"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-27-11Z-0011690-002","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Yo! What's up?"}],"providerOptions":{"openrouter":{"usage":{"promptTokens":24239,"promptTokensDetails":{"cachedTokens":0},"completionTokens":12,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":24251,"costDetails":{"upstreamInferenceCost":0.121495}},"provider":"Amazon Bedrock"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"message","messageId":"2026-07-02T15-27-25Z-0011690-000","message":{"role":"user","content":"whats the weather in Mohali?","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 8:57 PM GMT+5:30","middlePane":{"kind":"empty"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-27-30Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"toolu_bdrk_013dC9KmbiBbMH7L2a8wJ3HG","toolName":"web-search","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[]}}},{"type":"text","text":"Quick answer coming up — but \"what's the weather\" is exactly the kind of thing that decays fast. Want me to set up a background task that keeps a fresh Mohali forecast in a note (say, refreshed every morning)? Say the word.\n\nFor right now:"}],"providerOptions":{"openrouter":{"usage":{"promptTokens":24325,"promptTokensDetails":{"cachedTokens":0},"completionTokens":176,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":24501,"costDetails":{"upstreamInferenceCost":0.126025}},"provider":"Amazon Bedrock"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"tool-invocation","toolCallId":"toolu_bdrk_013dC9KmbiBbMH7L2a8wJ3HG","toolName":"web-search","input":"{\"query\": \"redacted\"}","subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"tool-result","toolCallId":"toolu_bdrk_013dC9KmbiBbMH7L2a8wJ3HG","toolName":"web-search","result":{"ok":true,"note":"redacted for fixture"},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-27-31Z-0011690-000","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"toolu_bdrk_013dC9KmbiBbMH7L2a8wJ3HG","toolName":"web-search"},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-27-40Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"### Mohali right now (Thu, Jul 2, ~9 PM IST)\n- **~30°C (87°F)**, feels like **~37°C (99°F)** — humid\n- Clear sky, humidity **76%**, light ESE wind ~3 km/h\n- **Today's range:** 27°C → 33°C, 0% rain\n- **Sunset:** 7:29 PM (already down)\n\n### Next few days\n- **Fri Jul 3:** hot & overcast, up to ~34°C\n- **SatWed:** monsoon kicks in — thunderstorms most days, highs ~3234°C\n\nBasically: sticky tonight, storms rolling in over the weekend."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":31150,"promptTokensDetails":{"cachedTokens":0},"completionTokens":231,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":31381,"costDetails":{"upstreamInferenceCost":0.161525}},"provider":"Amazon Bedrock"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"message","messageId":"2026-07-02T15-27-45Z-0011690-000","message":{"role":"user","content":"list my recent downloads","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 8:57 PM GMT+5:30","middlePane":{"kind":"empty"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-28-31Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"tool-call","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolName":"file-list","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[]}}}],"providerOptions":{"openrouter":{"usage":{"promptTokens":31451,"promptTokensDetails":{"cachedTokens":0},"completionTokens":92,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":31543,"costDetails":{"upstreamInferenceCost":0.159555}},"provider":"Amazon Bedrock"}}},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"tool-permission-auto-decision","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolCall":{"type":"tool-call","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolName":"file-list","arguments":{"query":"redacted"},"providerOptions":{"openrouter":{"reasoning_details":[]}}},"permission":{"kind":"file","operation":"list","paths":["/Users/testuser/Downloads"],"pathPrefix":"/Users/testuser/Downloads"},"decision":"allow","reason":"The user explicitly requested to list files in their Downloads directory, and the tool call is scoped to that directory.","subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"tool-invocation","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolName":"file-list","input":"{\"query\": \"redacted\"}","subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","type":"tool-result","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolName":"file-list","result":{"ok":true,"note":"redacted for fixture"},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-28-33Z-0011690-000","type":"message","message":{"role":"tool","content":"redacted for fixture","toolCallId":"toolu_bdrk_01HJvA2Yi1eMWmtr5v5KaNe4","toolName":"file-list"},"subflow":[]}
{"runId":"2026-07-02T15-27-08Z-0011690-000","messageId":"2026-07-02T15-28-57Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Your Downloads folder is *massive* — hundreds of files going back to 2024. Here are the **10 most recent** (all from the past few days, June 2026):\n\n### Most recent downloads\n1. `Punjab-main---Punjab-main.pdf` — 36 MB, Jul 1 (~10 PM)\n2. `Punjab-main-Punjab-main-page-9.pdf` — 339 KB, Jul 1\n3. `89227HC00FLG.pdf` — 46 KB, Jun 29\n4. `26070100012297ICIC_ChallanReceipt.pdf` — 422 KB, Jun 27\n5. `AnnexureD.pdf` — 81 KB, Jun 26\n6. `18997H8016V0.pdf` — 46 KB, Jun 24\n7. `53377H801DNU.pdf` — 46 KB, Jun 24\n8. `76687H8019D2.pdf` — 46 KB, Jun 24\n9. `25327H800NXB.pdf` — 46 KB, Jun 24\n10. `49137H8010X2.pdf` — 46 KB, Jun 24\n\n```filepath\n~/Downloads\n```\n\nHeads up — your Downloads folder is a graveyard: multiple `(1)`, `(2)`, `(3)` duplicates, ~30 old Rowboat .dmg builds, ISOs, obsolete installers. Rough estimate: several GB of easy wins. Say the word and I'll draft a cleanup plan (dedupe, archive old builds, sweep installers)."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":146280,"promptTokensDetails":{"cachedTokens":0},"completionTokens":518,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":146798,"costDetails":{"upstreamInferenceCost":0.74435}},"provider":"Amazon Bedrock"}}},"subflow":[]}

View file

@ -0,0 +1,3 @@
{"type":"start","runId":"2026-07-02T15-26-22Z-0011690-000","agentName":"note_creation","model":"google/gemini-3.1-flash-lite","provider":"rowboat","permissionMode":"manual","useCase":"knowledge_sync","subUseCase":"build_graph","subflow":[],"ts":"2026-07-02T15:26:22.103Z"}
{"runId":"2026-07-02T15-26-22Z-0011690-000","type":"message","messageId":"2026-07-02T15-26-22Z-0011690-001","message":{"role":"user","content":"Process the following 1 source files and create/update obsidian notes.\n\n**Instructions:**\n- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n- Extract entities and relationships\n\n**KNOWLEDGE BASE INDEX:**\n- [[Acme Corp]]\n- [[Sample Project]]\n\n**Source file: sample-note.md**\nMeeting with Acme Corp about the Sample Project. Follow up next week.\n","userMessageContext":{"currentDateTime":"Thursday, July 2, 2026 at 8:56 PM GMT+5:30"}},"subflow":[]}
{"runId":"2026-07-02T15-26-22Z-0011690-000","messageId":"2026-07-02T15-26-24Z-0011690-000","type":"message","message":{"role":"assistant","content":[{"type":"text","text":"Created/updated 1 note: [[Acme Corp]] with a reference to [[Sample Project]]."}],"providerOptions":{"openrouter":{"usage":{"promptTokens":19366,"promptTokensDetails":{"cachedTokens":0},"completionTokens":84,"completionTokensDetails":{"reasoningTokens":0},"cost":0,"totalTokens":19450,"costDetails":{"upstreamInferenceCost":0.0049675}},"provider":"Google AI Studio","reasoning_details":[{"type":"reasoning.text","signature":"EjQKMgEMOdbHC3J8//5gZWhEIrTstK75K+2cLu//G8KXeokib7MUOAXX6oLYt9RyB+jnT6JE","format":"google-gemini-v1","index":0}]}}},"subflow":[]}

View file

@ -0,0 +1,140 @@
import { describe, it, expect } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { RunEvent } from "@x/shared/dist/runs.js";
import { reduceTurn, turnTranscript } from "@x/shared/dist/turns.js";
import { reduceSession } from "@x/shared/dist/sessions.js";
import { convertRun } from "./convert.js";
const fixturesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
);
type RunEventT = z.infer<typeof RunEvent>;
function loadFixture(name: string): { log: RunEventT[]; runId: string } {
const raw = fs.readFileSync(path.join(fixturesDir, name), "utf-8");
const log = raw
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => RunEvent.parse(JSON.parse(line)));
const start = log[0];
if (start.type !== "start") throw new Error("fixture missing start event");
return { log, runId: start.runId };
}
describe("convertRun", () => {
it("non-copilot run -> a single standalone turn whose id is the runId", () => {
const { log, runId } = loadFixture("knowledge-single-turn.jsonl");
const result = convertRun(log, runId);
expect(result.isSession).toBe(false);
expect(result.session).toBeUndefined();
expect(result.turns).toHaveLength(1);
expect(result.turns[0].turnId).toBe(runId);
const state = reduceTurn(result.turns[0].events);
expect(state.terminal?.type).toBe("turn_completed");
expect(state.definition.sessionId).toBeNull();
// Standalone turn has inline (empty) context.
expect(Array.isArray(state.definition.context)).toBe(true);
});
it("background-task run -> standalone turn, reasoning parts preserved", () => {
const { log, runId } = loadFixture("background-task.jsonl");
const result = convertRun(log, runId);
expect(result.isSession).toBe(false);
expect(result.turns[0].turnId).toBe(runId);
const state = reduceTurn(result.turns[0].events);
// The bg-task run interleaves tool-call + reasoning parts; they survive
// verbatim on the completed model messages.
const hasReasoning = state.modelCalls.some(
(c) =>
Array.isArray(c.response?.content) &&
c.response.content.some((p) => p.type === "reasoning"),
);
expect(hasReasoning).toBe(true);
// Every tool call resolved with a terminal result.
expect(state.toolCalls.every((tc) => tc.result)).toBe(true);
});
it("copilot run -> session + one turn per user message, chained by previousTurnId", () => {
const { log, runId } = loadFixture("copilot-multi-turn.jsonl");
const result = convertRun(log, runId);
expect(result.isSession).toBe(true);
expect(result.session?.sessionId).toBe(runId);
// Fixture has 3 user messages.
expect(result.turns).toHaveLength(3);
// Session reduces and lists the turns in order.
const sessionState = reduceSession(result.session!.events);
expect(sessionState.turns.map((t) => t.turnId)).toEqual(
result.turns.map((t) => t.turnId),
);
expect(sessionState.turns.map((t) => t.sessionSeq)).toEqual([1, 2, 3]);
// First turn has inline context; later turns reference their predecessor.
const states = result.turns.map((t) => reduceTurn(t.events));
expect(Array.isArray(states[0].definition.context)).toBe(true);
expect(states[1].definition.context).toEqual({
previousTurnId: result.turns[0].turnId,
});
expect(states[2].definition.context).toEqual({
previousTurnId: result.turns[1].turnId,
});
// Every turn is completed and its session id points back to the session.
for (const s of states) {
expect(s.terminal?.type).toBe("turn_completed");
expect(s.definition.sessionId).toBe(runId);
}
});
it("copilot deny run -> runtime tool result flagged as error", () => {
const { log, runId } = loadFixture("copilot-brew-deny.jsonl");
const result = convertRun(log, runId);
expect(result.isSession).toBe(true);
const state = reduceTurn(result.turns[0].events);
const denied = state.toolCalls.find(
(tc) => tc.result?.source === "runtime",
);
expect(denied).toBeDefined();
expect(denied?.result?.result.isError).toBe(true);
// A denied call is resolved but never invoked.
expect(denied?.permission?.resolved?.decision).toBe("deny");
expect(denied?.invocation).toBeUndefined();
});
it("turnTranscript reproduces the original conversation slice", () => {
const { log, runId } = loadFixture("copilot-multi-turn.jsonl");
const result = convertRun(log, runId);
// Reconstruct expected per-turn user/assistant text from the raw log.
const userTexts: string[] = [];
for (const e of log) {
if (e.type === "message" && e.message.role === "user") {
const c = e.message.content;
userTexts.push(typeof c === "string" ? c : JSON.stringify(c));
}
}
result.turns.forEach((turn, i) => {
const transcript = turnTranscript(reduceTurn(turn.events));
// The first message of each turn's transcript is its user input.
expect(transcript[0].role).toBe("user");
const c = transcript[0].content;
expect(typeof c === "string" ? c : JSON.stringify(c)).toBe(
userTexts[i],
);
// The last contributed message is an assistant response.
expect(transcript[transcript.length - 1].role).toBe("assistant");
});
});
});

View file

@ -0,0 +1,539 @@
import { z } from "zod";
import {
RunEvent,
StartEvent,
type UseCase,
} from "@x/shared/dist/runs.js";
import type {
AssistantMessage,
UserMessage,
} from "@x/shared/dist/message.js";
import {
assistantRef,
reduceTurn,
toolResultRef,
TurnEvent,
type ModelDescriptor,
} from "@x/shared/dist/turns.js";
import { reduceSession, SessionEvent } from "@x/shared/dist/sessions.js";
// One-time conversion of a legacy `run.jsonl` log into the new event-sourced
// turn/session runtime. Pure and I/O-free so it is exhaustively testable
// against real fixtures; the runner (migrate.ts) owns the filesystem side.
//
// Mapping (decided in planning):
// - A run is one whole conversation. Turn boundaries are user messages.
// - copilot_chat runs -> one session + one turn per user message.
// - every other run -> a single standalone turn (sessionId: null) whose
// turnId is the ORIGINAL runId, so the live-note /
// background-task history views (which store that id
// and already fetch turn-first via sessions:getTurn)
// resolve it with no code change.
// - code_session runs are skipped by the runner; nothing to migrate there.
//
// The synthesized turn log is validated against reduceTurn before it is
// returned; anything the reducer would reject throws and the runner quarantines
// the run instead of writing a corrupt turn.
type RunEventT = z.infer<typeof RunEvent>;
type StartT = z.infer<typeof StartEvent>;
type TurnEventT = z.infer<typeof TurnEvent>;
type SessionEventT = z.infer<typeof SessionEvent>;
type UserMessageT = z.infer<typeof UserMessage>;
type AssistantMessageT = z.infer<typeof AssistantMessage>;
type ModelDescriptorT = z.infer<typeof ModelDescriptor>;
export interface ConvertedTurn {
turnId: string;
events: TurnEventT[];
}
export interface ConvertedSession {
sessionId: string;
events: SessionEventT[];
}
export interface ConvertResult {
session?: ConvertedSession;
turns: ConvertedTurn[];
/** True for copilot_chat runs (session + turns); false for standalone. */
isSession: boolean;
}
export class RunConversionError extends Error {
constructor(message: string) {
super(message);
this.name = "RunConversionError";
}
}
// A tool-call part lifted off an assistant message, with everything the run
// recorded about how it resolved.
interface ToolCallPlan {
toolCallId: string;
toolName: string;
input: unknown;
// Permission provenance from the legacy run, if any.
autoDecision?: { decision: "allow" | "deny"; reason: string; request: unknown };
manualRequest?: { request: unknown };
manualResponse?: { decision: "allow" | "deny" };
// Execution outcome from the legacy run, if any.
result?: { output: unknown; isError: boolean };
// Tool-role message content for this call (denial text fallback).
toolMessage?: string;
}
interface ModelCallPlan {
message: AssistantMessageT;
toolCalls: ToolCallPlan[];
}
interface TurnPlan {
input: UserMessageT;
ts: string;
modelCalls: ModelCallPlan[];
}
function isToolCallResultObject(value: unknown): value is { success?: boolean } {
return typeof value === "object" && value !== null;
}
function inferIsError(result: unknown): boolean {
if (isToolCallResultObject(result)) {
if (result.success === false) return true;
if ("error" in result && (result as { error?: unknown }).error) return true;
}
return false;
}
function modelDescriptor(start: StartT): ModelDescriptorT {
// The new ModelDescriptor keeps provider as the name and model as the full
// id (no "/" split) — same convention the live agent resolver writes.
return { provider: start.provider, model: start.model };
}
function titleFrom(input: UserMessageT): string | undefined {
const content = input.content;
let text: string | undefined;
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
const firstText = content.find(
(p): p is { type: "text"; text: string } => p.type === "text",
);
text = firstText?.text;
}
if (!text) return undefined;
const trimmed = text.trim().replace(/\s+/g, " ");
if (!trimmed) return undefined;
return trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
}
// Group the run log into turns keyed off user-message boundaries, folding the
// interleaved tool / permission events onto the tool-call parts they belong to.
function planTurns(log: RunEventT[], start: StartT): TurnPlan[] {
const turns: TurnPlan[] = [];
let current: TurnPlan | undefined;
// Per-turn lookups keyed by toolCallId, resolved when we emit each call.
let byId: Map<string, ToolCallPlan> = new Map();
const ensureToolPlan = (toolCallId: string): ToolCallPlan | undefined =>
byId.get(toolCallId);
for (const event of log) {
switch (event.type) {
case "message": {
const msg = event.message;
if (msg.role === "user") {
current = {
input: msg,
ts: event.ts ?? start.ts ?? new Date(0).toISOString(),
modelCalls: [],
};
byId = new Map();
turns.push(current);
} else if (msg.role === "assistant") {
if (!current) break;
const toolCalls: ToolCallPlan[] = [];
const parts = Array.isArray(msg.content) ? msg.content : [];
for (const part of parts) {
if (part.type !== "tool-call") continue;
const plan: ToolCallPlan = {
toolCallId: part.toolCallId,
toolName: part.toolName,
input: part.arguments,
};
toolCalls.push(plan);
byId.set(part.toolCallId, plan);
}
current.modelCalls.push({ message: msg, toolCalls });
} else if (msg.role === "tool") {
const plan = current && ensureToolPlan(msg.toolCallId);
if (plan) plan.toolMessage = msg.content;
}
break;
}
case "tool-result": {
if (!current || !event.toolCallId) break;
const plan = ensureToolPlan(event.toolCallId);
if (plan) {
plan.result = {
output: event.result ?? null,
isError: inferIsError(event.result),
};
}
break;
}
case "tool-permission-auto-decision": {
if (!current) break;
const plan = ensureToolPlan(event.toolCallId);
if (plan) {
plan.autoDecision = {
decision: event.decision,
reason: event.reason,
request: event.permission ?? event.toolCall,
};
}
break;
}
case "tool-permission-request": {
if (!current) break;
const plan = ensureToolPlan(event.toolCall.toolCallId);
if (plan) {
plan.manualRequest = {
request: event.permission ?? event.toolCall,
};
}
break;
}
case "tool-permission-response": {
if (!current) break;
const plan = ensureToolPlan(event.toolCallId);
if (plan) {
plan.manualResponse = {
decision: event.response === "approve" ? "allow" : "deny",
};
}
break;
}
default:
// start, tool-invocation (input taken from the assistant part),
// llm-stream-event, run-processing-*, spawn-subflow, ask-human-*,
// code-* and error events do not contribute to the transcript.
break;
}
}
return turns;
}
// Resolve a tool call's permission story + terminal result into the ordered
// list of new turn events. Throws (-> quarantine) if the run left the call
// dangling (no result and no denial), which the reducer cannot represent.
function toolEvents(
turnId: string,
ts: string,
plan: ToolCallPlan,
): TurnEventT[] {
const events: TurnEventT[] = [];
const hasPermission =
plan.autoDecision || plan.manualRequest || plan.manualResponse;
let decision: "allow" | "deny" | undefined;
if (hasPermission) {
events.push({
type: "tool_permission_required",
turnId,
ts,
toolCallId: plan.toolCallId,
toolName: plan.toolName,
request: (plan.autoDecision?.request ??
plan.manualRequest?.request ??
{}) as unknown,
} as TurnEventT);
if (plan.autoDecision) {
events.push({
type: "tool_permission_classified",
turnId,
ts,
toolCallId: plan.toolCallId,
decision: plan.autoDecision.decision,
reason: plan.autoDecision.reason,
});
}
// A human response is the effective decision; otherwise the classifier's.
if (plan.manualResponse) {
decision = plan.manualResponse.decision;
events.push({
type: "tool_permission_resolved",
turnId,
ts,
toolCallId: plan.toolCallId,
decision,
source: "human",
});
} else if (plan.autoDecision) {
decision = plan.autoDecision.decision;
events.push({
type: "tool_permission_resolved",
turnId,
ts,
toolCallId: plan.toolCallId,
decision,
source: "classifier",
reason: plan.autoDecision.reason,
});
} else {
// Only a manual request, never answered — the run ended waiting.
throw new RunConversionError(
`tool call ${plan.toolCallId} has an unresolved permission request`,
);
}
}
if (decision === "deny") {
const denial =
plan.toolMessage ?? "Unable to execute this tool: Permission was denied";
events.push({
type: "tool_result",
turnId,
ts,
toolCallId: plan.toolCallId,
toolName: plan.toolName,
source: "runtime",
result: { output: denial, isError: true },
});
return events;
}
// Allowed (explicitly or by absence of a permission gate): the run must have
// recorded a terminal result, else the call is dangling and unrepresentable.
if (!plan.result) {
if (plan.toolMessage !== undefined) {
// Fall back to the tool-role message when the raw result event is
// missing (older logs).
events.push(
invocationEvent(turnId, ts, plan),
{
type: "tool_result",
turnId,
ts,
toolCallId: plan.toolCallId,
toolName: plan.toolName,
source: "sync",
result: { output: plan.toolMessage, isError: false },
},
);
return events;
}
throw new RunConversionError(
`tool call ${plan.toolCallId} has no terminal result`,
);
}
events.push(invocationEvent(turnId, ts, plan), {
type: "tool_result",
turnId,
ts,
toolCallId: plan.toolCallId,
toolName: plan.toolName,
source: "sync",
result: {
output: (plan.result.output ?? null) as unknown,
isError: plan.result.isError,
},
} as TurnEventT);
return events;
}
function invocationEvent(
turnId: string,
ts: string,
plan: ToolCallPlan,
): TurnEventT {
return {
type: "tool_invocation_requested",
turnId,
ts,
toolCallId: plan.toolCallId,
toolId: plan.toolName,
toolName: plan.toolName,
execution: "sync",
input: (plan.input ?? null) as unknown,
} as TurnEventT;
}
// Build one reduceTurn-legal turn log from a planned turn.
function buildTurn(
turnId: string,
sessionId: string | null,
previousTurnId: string | null,
start: StartT,
plan: TurnPlan,
): ConvertedTurn {
const ts = plan.ts;
const model = modelDescriptor(start);
const events: TurnEventT[] = [];
events.push({
type: "turn_created",
schemaVersion: 1,
turnId,
ts,
sessionId,
agent: {
requested: { agentId: start.agentName },
resolved: {
agentId: start.agentName,
systemPrompt: "",
model,
tools: [],
},
},
context: previousTurnId ? { previousTurnId } : [],
input: plan.input,
config: {
autoPermission: start.permissionMode === "auto",
humanAvailable: true,
maxModelCalls: Math.max(20, plan.modelCalls.length + 5),
},
});
if (plan.modelCalls.length === 0) {
throw new RunConversionError("turn has no assistant response");
}
const last = plan.modelCalls[plan.modelCalls.length - 1];
if (last.toolCalls.length > 0) {
// The final response still has open tool calls (interrupted run); the
// turn cannot be completed cleanly.
throw new RunConversionError("final model call has unresolved tool calls");
}
plan.modelCalls.forEach((call, index) => {
// Request references: only what is NEW since the previous model call.
let contextRef: { previousTurnId: string } | undefined;
let messages: string[];
if (index === 0) {
if (previousTurnId) {
contextRef = { previousTurnId };
messages = ["input"];
} else {
messages = ["input"];
}
} else {
const prev = plan.modelCalls[index - 1];
messages = [
assistantRef(index - 1),
...prev.toolCalls.map((tc) => toolResultRef(tc.toolCallId)),
];
}
events.push({
type: "model_call_requested",
turnId,
ts,
modelCallIndex: index,
request: {
...(contextRef ? { contextRef } : {}),
messages,
parameters: {},
},
});
events.push({
type: "model_call_completed",
turnId,
ts,
modelCallIndex: index,
message: call.message,
finishReason: call.toolCalls.length > 0 ? "tool-calls" : "stop",
usage: {},
});
for (const tc of call.toolCalls) {
events.push(...toolEvents(turnId, ts, tc));
}
});
events.push({
type: "turn_completed",
turnId,
ts,
output: last.message,
finishReason: "stop",
usage: {},
});
// The definition of a well-formed migration: the reducer accepts it.
reduceTurn(events);
return { turnId, events };
}
// A copilot run's session turns get fresh ids derived from each initiating user
// message id (unique, sortable, date-prefixed — satisfies the repo id regex).
function turnIdFor(runId: string, index: number): string {
return `${runId}-t${String(index).padStart(3, "0")}`;
}
/**
* Convert one legacy run log into the new runtime shape. `runId` is the run's
* id (== the log's start.runId); pass it explicitly so the caller controls id
* preservation. Throws RunConversionError / TurnCorruptionError on anything the
* new reducers would reject.
*/
export function convertRun(log: RunEventT[], runId: string): ConvertResult {
const first = log[0];
if (!first || first.type !== "start") {
throw new RunConversionError("run log does not begin with a start event");
}
const start = first as StartT;
const useCase: z.infer<typeof UseCase> | undefined = start.useCase;
const plans = planTurns(log, start);
if (plans.length === 0) {
throw new RunConversionError("run has no user messages");
}
if (useCase === "copilot_chat") {
// Session id reuses the original runId; turns get derived ids.
const sessionId = runId;
const turns: ConvertedTurn[] = [];
const sessionEvents: SessionEventT[] = [];
const title = titleFrom(plans[0].input);
sessionEvents.push({
type: "session_created",
schemaVersion: 1,
sessionId,
ts: plans[0].ts,
...(title ? { title } : {}),
});
let previousTurnId: string | null = null;
plans.forEach((plan, i) => {
const turnId = turnIdFor(runId, i);
const turn = buildTurn(turnId, sessionId, previousTurnId, start, plan);
turns.push(turn);
sessionEvents.push({
type: "turn_appended",
sessionId,
ts: plan.ts,
turnId,
sessionSeq: i + 1,
agentId: start.agentName,
model: modelDescriptor(start),
});
previousTurnId = turnId;
});
reduceSession(sessionEvents);
return {
session: { sessionId, events: sessionEvents },
turns,
isSession: true,
};
}
// Non-copilot: a single standalone turn whose id IS the runId.
if (plans.length > 1) {
throw new RunConversionError(
`non-copilot run has ${plans.length} user messages; expected a single turn`,
);
}
const turn = buildTurn(runId, null, null, start, plans[0]);
return { turns: [turn], isSession: false };
}

View file

@ -0,0 +1,191 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { reduceTurn } from "@x/shared/dist/turns.js";
import { reduceSession } from "@x/shared/dist/sessions.js";
import { FSTurnRepo } from "../../turns/fs-repo.js";
import { FSSessionRepo } from "../../sessions/fs-repo.js";
import { migrateRuns } from "./migrate.js";
const fixturesDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
);
let tmp: string;
let runsDir: string;
let turnsRootDir: string;
let sessionsRootDir: string;
let archiveDir: string;
let logFile: string;
function deps() {
return {
runsDir,
turnsRootDir,
sessionsRootDir,
archiveDir,
logFile,
now: () => "2026-07-02T00:00:00.000Z",
logger: () => undefined,
};
}
function copyFixture(name: string, targetName?: string): string {
const dest = path.join(runsDir, targetName ?? name);
fs.copyFileSync(path.join(fixturesDir, name), dest);
return dest;
}
// Turn/session files nest under YYYY/MM/DD; find one by id anywhere in a tree.
function findFile(root: string, id: string): string | undefined {
const want = `${id}.jsonl`;
const walk = (dir: string): string | undefined => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return undefined;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
const hit = walk(full);
if (hit) return hit;
} else if (entry.name === want) {
return full;
}
}
return undefined;
};
return walk(root);
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "runs-migration-"));
runsDir = path.join(tmp, "runs");
turnsRootDir = path.join(tmp, "storage", "turns");
sessionsRootDir = path.join(tmp, "storage", "sessions");
archiveDir = path.join(tmp, "runs-archive");
logFile = path.join(tmp, "config", "runs-migration.json");
fs.mkdirSync(runsDir, { recursive: true });
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("migrateRuns", () => {
it("migrates a non-copilot run to a standalone turn keyed by the runId, and archives the source", () => {
copyFixture("knowledge-single-turn.jsonl");
const runId = "2026-07-02T15-26-22Z-0011690-000";
const summary = migrateRuns(deps());
expect(summary.scanned).toBe(1);
expect(summary.migratedTurns).toBe(1);
expect(summary.migratedSessions).toBe(0);
expect(summary.failed).toHaveLength(0);
// Turn file exists at the runId; no session written.
expect(findFile(turnsRootDir, runId)).toBeDefined();
expect(fs.existsSync(sessionsRootDir)).toBe(false);
// Source archived out of the scan dir.
expect(fs.existsSync(path.join(runsDir, "knowledge-single-turn.jsonl"))).toBe(
false,
);
expect(
fs.existsSync(path.join(archiveDir, "knowledge-single-turn.jsonl")),
).toBe(true);
});
it("migrates a copilot run to a session + turns", () => {
copyFixture("copilot-multi-turn.jsonl");
const runId = "2026-07-02T15-27-08Z-0011690-000";
const summary = migrateRuns(deps());
expect(summary.migratedSessions).toBe(1);
expect(summary.migratedTurns).toBe(3);
expect(findFile(sessionsRootDir, runId)).toBeDefined();
expect(findFile(turnsRootDir, `${runId}-t000`)).toBeDefined();
expect(findFile(turnsRootDir, `${runId}-t002`)).toBeDefined();
});
it("writes files the real FS repos can read back and reduce", async () => {
copyFixture("copilot-multi-turn.jsonl");
const runId = "2026-07-02T15-27-08Z-0011690-000";
migrateRuns(deps());
const turnRepo = new FSTurnRepo({ turnsRootDir });
const sessionRepo = new FSSessionRepo({ sessionsRootDir });
// The app's actual read path: repo.read (strict trailing-newline + id
// checks) then reduce.
const sessionEvents = await sessionRepo.read(runId);
const sessionState = reduceSession(sessionEvents);
expect(sessionState.turns).toHaveLength(3);
const turnEvents = await turnRepo.read(`${runId}-t000`);
const turnState = reduceTurn(turnEvents);
expect(turnState.terminal?.type).toBe("turn_completed");
});
it("skips code_session runs and leaves them in place", () => {
// Rewrite a fixture's useCase to code_session.
const raw = fs
.readFileSync(path.join(fixturesDir, "knowledge-single-turn.jsonl"), "utf-8")
.split("\n")
.filter((l) => l.trim());
const start = JSON.parse(raw[0]);
start.useCase = "code_session";
raw[0] = JSON.stringify(start);
fs.writeFileSync(path.join(runsDir, "code.jsonl"), raw.join("\n") + "\n");
const summary = migrateRuns(deps());
expect(summary.skipped).toBe(1);
expect(summary.migratedTurns).toBe(0);
// Left in place, not archived.
expect(fs.existsSync(path.join(runsDir, "code.jsonl"))).toBe(true);
expect(fs.existsSync(path.join(archiveDir, "code.jsonl"))).toBe(false);
});
it("leaves a malformed run in place and records the failure", () => {
fs.writeFileSync(path.join(runsDir, "broken.jsonl"), "not json\n");
const summary = migrateRuns(deps());
expect(summary.failed).toHaveLength(1);
expect(summary.failed[0].file).toBe("broken.jsonl");
expect(fs.existsSync(path.join(runsDir, "broken.jsonl"))).toBe(true);
});
it("is idempotent — a second pass finds nothing left to migrate", () => {
copyFixture("background-task.jsonl");
const first = migrateRuns(deps());
expect(first.migratedTurns).toBe(1);
const second = migrateRuns(deps());
expect(second.scanned).toBe(0);
expect(second.migratedTurns).toBe(0);
});
it("writes a migration log with counts", () => {
copyFixture("copilot-brew-deny.jsonl");
migrateRuns(deps());
expect(fs.existsSync(logFile)).toBe(true);
const parsed = JSON.parse(fs.readFileSync(logFile, "utf-8"));
expect(parsed.ranAt).toBe("2026-07-02T00:00:00.000Z");
expect(parsed.migratedSessions).toBe(1);
});
it("does nothing gracefully when the runs dir is absent", () => {
fs.rmSync(runsDir, { recursive: true, force: true });
const summary = migrateRuns(deps());
expect(summary.scanned).toBe(0);
});
});

View file

@ -0,0 +1,165 @@
import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import { WorkDir } from "../../config/config.js";
import { RunEvent } from "@x/shared/dist/runs.js";
import { TurnEvent } from "@x/shared/dist/turns.js";
import { SessionEvent } from "@x/shared/dist/sessions.js";
import { convertRun } from "./convert.js";
// One-time migration that ports legacy `runs/<runId>.jsonl` logs into the new
// turn/session runtime at startup. Runs before the session index is built.
//
// Design (see convert.ts for the mapping):
// - Successfully migrated runs are MOVED to `runs-archive/`. That move is the
// idempotency guard — migrated files leave the scan directory, so a re-run
// never re-processes them and no separate marker is needed.
// - Any run that fails to convert is LEFT in `runs/` (still served by the
// `runs:fetch` fallback the history views keep) and retried next launch.
// - code_session runs are skipped and left in place — Code mode still uses
// the old runtime, so those files must stay.
// Every step is defensive; a bad run (or the whole pass) never blocks boot.
const ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T[A-Za-z0-9-]+$/;
export interface MigrateRunsDeps {
runsDir?: string;
turnsRootDir?: string;
sessionsRootDir?: string;
archiveDir?: string;
logFile?: string;
now?: () => string;
logger?: (message: string) => void;
}
export interface MigrationSummary {
scanned: number;
migratedSessions: number;
migratedTurns: number;
skipped: number;
failed: Array<{ file: string; error: string }>;
}
function idPath(rootDir: string, id: string): string {
const match = ID_PATTERN.exec(id);
if (!match) throw new Error(`invalid id: ${id}`);
const [, year, month, day] = match;
return path.join(rootDir, year, month, day, `${id}.jsonl`);
}
// Mirrors the FS repos' serialization: revalidate each event, one JSON object
// per line, trailing newline. Overwrites any existing target so a re-run after
// a mid-migration crash converges cleanly (historical ids never collide with
// live turns, whose ids are minted from the current clock).
function writeJsonl<T>(
rootDir: string,
id: string,
events: T[],
schema: z.ZodType<T>,
): void {
const file = idPath(rootDir, id);
fs.mkdirSync(path.dirname(file), { recursive: true });
const payload =
events.map((event) => JSON.stringify(schema.parse(event))).join("\n") +
"\n";
fs.writeFileSync(file, payload);
}
export function migrateRuns(deps: MigrateRunsDeps = {}): MigrationSummary {
const runsDir = deps.runsDir ?? path.join(WorkDir, "runs");
const turnsRootDir =
deps.turnsRootDir ?? path.join(WorkDir, "storage", "turns");
const sessionsRootDir =
deps.sessionsRootDir ?? path.join(WorkDir, "storage", "sessions");
const archiveDir = deps.archiveDir ?? path.join(WorkDir, "runs-archive");
const logFile =
deps.logFile ?? path.join(WorkDir, "config", "runs-migration.json");
const log =
deps.logger ?? ((message: string) => console.log(`[runs-migration] ${message}`));
const summary: MigrationSummary = {
scanned: 0,
migratedSessions: 0,
migratedTurns: 0,
skipped: 0,
failed: [],
};
let files: string[];
try {
files = fs
.readdirSync(runsDir)
.filter((name) => name.endsWith(".jsonl"))
.sort();
} catch {
// No runs directory — nothing to migrate.
return summary;
}
for (const file of files) {
summary.scanned++;
const source = path.join(runsDir, file);
try {
const raw = fs.readFileSync(source, "utf-8");
const logEvents = raw
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => RunEvent.parse(JSON.parse(line)));
const start = logEvents[0];
if (!start || start.type !== "start") {
throw new Error("run log does not begin with a start event");
}
if (start.useCase === "code_session") {
// Code mode still uses the old runtime; leave the file in place.
summary.skipped++;
continue;
}
const result = convertRun(logEvents, start.runId);
for (const turn of result.turns) {
writeJsonl(turnsRootDir, turn.turnId, turn.events, TurnEvent);
}
if (result.session) {
writeJsonl(
sessionsRootDir,
result.session.sessionId,
result.session.events,
SessionEvent,
);
}
// Success — archive the original out of the scan directory.
fs.mkdirSync(archiveDir, { recursive: true });
fs.renameSync(source, path.join(archiveDir, file));
if (result.isSession) summary.migratedSessions++;
summary.migratedTurns += result.turns.length;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
summary.failed.push({ file, error: message });
log(`failed to migrate ${file}: ${message}`);
}
}
try {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
fs.writeFileSync(
logFile,
JSON.stringify(
{ ranAt: deps.now?.() ?? new Date().toISOString(), ...summary },
null,
2,
),
);
} catch {
// Observability only; never fatal.
}
log(
`scanned ${summary.scanned}, sessions ${summary.migratedSessions}, ` +
`turns ${summary.migratedTurns}, skipped ${summary.skipped}, ` +
`failed ${summary.failed.length}`,
);
return summary;
}

View file

@ -0,0 +1,11 @@
import { getDefaultModelAndProvider } from "./defaults.js";
export interface IDefaultModelResolver {
resolve(): Promise<{ model: string; provider: string }>;
}
export class DefaultModelResolver implements IDefaultModelResolver {
resolve(): Promise<{ model: string; provider: string }> {
return getDefaultModelAndProvider();
}
}

View file

@ -1,9 +1,8 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { createRun, createMessage } from '../runs/runs.js';
import { runHeadlessAgent } from '../agents/headless-app.js';
import { getKgModel } from '../models/defaults.js';
import { waitForRunCompletion } from '../agents/utils.js';
import {
loadConfig,
loadState,
@ -38,15 +37,6 @@ async function runAgent(agentName: string): Promise<void> {
}
try {
// Create a run for the agent
// The agent file is expected to be in the agents directory with the same name
const run = await createRun({
agentId: agentName,
model: await getKgModel(),
useCase: 'knowledge_sync',
subUseCase: 'pre_built',
});
// Build trigger message with user context
const message = `Run your scheduled task.
@ -59,10 +49,14 @@ async function runAgent(agentName: string): Promise<void> {
Process new items and use the user context above to identify yourself when drafting responses.`;
await createMessage(run.id, message);
// Wait for completion
await waitForRunCompletion(run.id);
// The agent file is expected to be in the agents directory with
// the same name. Waits for the turn to settle (errors tolerated,
// matching the old no-throwOnError wait).
await runHeadlessAgent({
agentId: agentName,
message,
model: await getKgModel(),
});
// Update last run time
setLastRunTime(agentName, new Date());

View file

@ -0,0 +1,79 @@
import type { z } from "zod";
import type { UserMessage } from "@x/shared/dist/message.js";
import type {
SessionIndexEntry,
SessionState,
} from "@x/shared/dist/sessions.js";
import type {
JsonValue,
RequestedAgent,
ToolResultData,
} from "@x/shared/dist/turns.js";
import type { Turn } from "../turns/api.js";
// Per-message configuration; it lands on the turn (sessions store none).
export interface SendMessageConfig {
agent: z.infer<typeof RequestedAgent>;
autoPermission?: boolean;
maxModelCalls?: number;
}
export interface ISessions {
// Startup scan: builds the in-memory index from session files (reading
// each session's latest turn for status). Must run before listSessions.
initialize(): Promise<void>;
createSession(input?: { title?: string }): Promise<string>;
listSessions(): SessionIndexEntry[];
getSession(sessionId: string): Promise<SessionState>;
getTurn(turnId: string): Promise<Turn>;
// Rejects with TurnNotSettledError while the latest turn is non-terminal.
// Returns as soon as the turn is created, referenced, and advancing;
// progress flows through the bus.
sendMessage(
sessionId: string,
input: z.infer<typeof UserMessage>,
config: SendMessageConfig,
): Promise<{ turnId: string }>;
// External inputs, one advanceTurn each. These settle with that
// invocation's outcome; turn-runtime input rejections pass through.
respondToPermission(
turnId: string,
toolCallId: string,
decision: "allow" | "deny",
metadata?: JsonValue,
): Promise<void>;
// The dedicated ask-human endpoint; sendMessage never routes here.
respondToAskHuman(
turnId: string,
toolCallId: string,
answer: string,
): Promise<void>;
deliverAsyncToolResult(
turnId: string,
toolCallId: string,
result: z.infer<typeof ToolResultData>,
): Promise<void>;
stopTurn(turnId: string, reason?: string): Promise<void>;
// Recovery entry for turns left idle by a crash; runs in the background.
resumeTurn(sessionId: string): Promise<void>;
setTitle(sessionId: string, title: string): Promise<void>;
deleteSession(sessionId: string): Promise<void>;
}
export class TurnNotSettledError extends Error {
constructor(
readonly sessionId: string,
readonly turnId: string,
readonly turnStatus: string,
) {
super(
`session ${sessionId} has a non-terminal turn ${turnId} (${turnStatus})`,
);
this.name = "TurnNotSettledError";
}
}

View file

@ -0,0 +1,28 @@
import type { SessionBusEvent } from "@x/shared/dist/sessions.js";
// Ephemeral fan-out toward the renderer (bridged over IPC in the app layer).
// Publishing is fire-and-forget; nothing durable depends on delivery.
export interface ISessionBus {
publish(event: SessionBusEvent): void;
}
// Default in-process fan-out; the app layer subscribes and forwards over IPC.
// Listener errors are swallowed: observers must never affect sessions.
export class EmitterSessionBus implements ISessionBus {
private listeners = new Set<(event: SessionBusEvent) => void>();
publish(event: SessionBusEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// observational only
}
}
}
subscribe(listener: (event: SessionBusEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View file

@ -0,0 +1,108 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { z } from "zod";
import {
SessionCorruptionError,
type SessionCreated,
type SessionEvent,
} from "@x/shared/dist/sessions.js";
import { FSSessionRepo } from "./fs-repo.js";
const S1 = "2026-07-02T09-00-00Z-0000001-000";
const S2 = "2026-07-03T09-00-00Z-0000002-000";
function created(sessionId = S1): z.infer<typeof SessionCreated> {
return {
type: "session_created",
schemaVersion: 1,
sessionId,
ts: "2026-07-02T09:00:00Z",
title: "T",
};
}
function appended(sessionId = S1): z.infer<typeof SessionEvent> {
return {
type: "turn_appended",
sessionId,
ts: "2026-07-02T09:01:00Z",
turnId: "turn-1",
sessionSeq: 1,
agentId: "copilot",
model: { provider: "fake", model: "m" },
};
}
describe("FSSessionRepo", () => {
let root: string;
let repo: FSSessionRepo;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "session-repo-"));
repo = new FSSessionRepo({ sessionsRootDir: root });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it("creates date-partitioned files and round-trips events", async () => {
await repo.create(created());
await repo.append(S1, [appended()]);
const file = path.join(root, "2026", "07", "02", `${S1}.jsonl`);
await fs.access(file);
const events = await repo.read(S1);
expect(events.map((e) => e.type)).toEqual([
"session_created",
"turn_appended",
]);
});
it("rejects malformed and path-like session ids", async () => {
for (const bad of ["../evil", "a/b", "nope", ""]) {
await expect(repo.read(bad)).rejects.toThrowError(/invalid session id/);
}
});
it("create fails if the session exists; append fails if it doesn't", async () => {
await repo.create(created());
await expect(repo.create(created())).rejects.toThrowError();
await expect(repo.append(S2, [appended(S2)])).rejects.toThrowError(
/session not found/,
);
});
it("read rejects corrupt files whole", async () => {
const file = path.join(root, "2026", "07", "02", `${S1}.jsonl`);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, `${JSON.stringify(created())}\nnot json\n`);
await expect(repo.read(S1)).rejects.toThrowError(SessionCorruptionError);
await fs.writeFile(file, `${JSON.stringify(created())}\n{"torn`);
await expect(repo.read(S1)).rejects.toThrowError(
/does not end with a complete line/,
);
});
it("lists session ids across date partitions", async () => {
await repo.create(created(S1));
await repo.create(created(S2));
expect(await repo.listSessionIds()).toEqual([S1, S2]);
});
it("listing an empty root returns no ids", async () => {
const empty = new FSSessionRepo({
sessionsRootDir: path.join(root, "missing"),
});
expect(await empty.listSessionIds()).toEqual([]);
});
it("delete removes the file only; deleting a missing session throws", async () => {
await repo.create(created(S1));
await repo.create(created(S2));
await repo.delete(S1);
expect(await repo.listSessionIds()).toEqual([S2]);
await expect(repo.delete(S1)).rejects.toThrowError(/session not found/);
});
});

View file

@ -0,0 +1,149 @@
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
SessionCorruptionError,
SessionCreated,
SessionEvent,
} from "@x/shared/dist/sessions.js";
import { KeyedMutex } from "../turns/keyed-mutex.js";
import type { ISessionRepo } from "./repo.js";
// Session IDs come from the same generator as turn IDs.
const SESSION_ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T[A-Za-z0-9-]+$/;
export class FSSessionRepo implements ISessionRepo {
private readonly rootDir: string;
private readonly mutex = new KeyedMutex();
constructor({ sessionsRootDir }: { sessionsRootDir: string }) {
this.rootDir = sessionsRootDir;
}
private filePath(sessionId: string): string {
const match = SESSION_ID_PATTERN.exec(sessionId);
if (!match) {
throw new Error(`invalid session id: ${sessionId}`);
}
const [, year, month, day] = match;
return path.join(this.rootDir, year, month, day, `${sessionId}.jsonl`);
}
private serialize(
sessionId: string,
events: Array<z.infer<typeof SessionEvent>>,
): string {
let payload = "";
for (const event of events) {
const parsed = SessionEvent.parse(event);
if (parsed.sessionId !== sessionId) {
throw new Error(
`event sessionId ${parsed.sessionId} does not match ${sessionId}`,
);
}
payload += `${JSON.stringify(parsed)}\n`;
}
return payload;
}
async create(event: z.infer<typeof SessionCreated>): Promise<void> {
const parsed = SessionCreated.parse(event);
const file = this.filePath(parsed.sessionId);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, this.serialize(parsed.sessionId, [parsed]), {
flag: "wx",
});
}
async read(sessionId: string): Promise<Array<z.infer<typeof SessionEvent>>> {
const file = this.filePath(sessionId);
let raw: string;
try {
raw = await fs.readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`session not found: ${sessionId}`);
}
throw error;
}
if (raw.length === 0) {
throw new SessionCorruptionError(`session file is empty: ${sessionId}`);
}
const lines = raw.split("\n");
const trailing = lines.pop();
if (trailing !== "") {
throw new SessionCorruptionError(
`session file does not end with a complete line: ${sessionId}`,
);
}
const events: Array<z.infer<typeof SessionEvent>> = [];
for (const [index, line] of lines.entries()) {
let parsed: z.infer<typeof SessionEvent>;
try {
parsed = SessionEvent.parse(JSON.parse(line));
} catch (error) {
throw new SessionCorruptionError(
`malformed session event at ${sessionId}:${index + 1}: ${String(
error instanceof Error ? error.message : error,
)}`,
);
}
if (parsed.sessionId !== sessionId) {
throw new SessionCorruptionError(
`event sessionId ${parsed.sessionId} does not match file ${sessionId}`,
);
}
events.push(parsed);
}
return events;
}
async append(
sessionId: string,
events: Array<z.infer<typeof SessionEvent>>,
): Promise<void> {
if (events.length === 0) {
return;
}
const payload = this.serialize(sessionId, events);
const file = this.filePath(sessionId);
try {
await fs.access(file);
} catch {
throw new Error(`session not found: ${sessionId}`);
}
await fs.appendFile(file, payload);
}
async withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
return this.mutex.run(sessionId, fn);
}
async listSessionIds(): Promise<string[]> {
let names: string[];
try {
names = (await fs.readdir(this.rootDir, { recursive: true })) as string[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
return names
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.basename(name, ".jsonl"))
.sort();
}
async delete(sessionId: string): Promise<void> {
const file = this.filePath(sessionId);
try {
await fs.unlink(file);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`session not found: ${sessionId}`);
}
throw error;
}
}
}

View file

@ -0,0 +1,41 @@
import type { z } from "zod";
import type { UserMessage } from "@x/shared/dist/message.js";
import type {
ConversationMessage,
RequestedAgent,
} from "@x/shared/dist/turns.js";
import type { ITurnRuntime, TurnOutcome } from "../turns/api.js";
// Standalone turns for non-session callers (background tasks, live notes,
// knowledge pipelines, scheduled agents): sessionId null, automatic
// permissions, no human. Never appears in the session index; callers keep
// the turnId if they need history.
export async function runHeadlessTurn(
turnRuntime: ITurnRuntime,
input: {
agent: z.infer<typeof RequestedAgent>;
context?: Array<z.infer<typeof ConversationMessage>>;
input: z.infer<typeof UserMessage>;
maxModelCalls?: number;
signal?: AbortSignal;
},
): Promise<{ turnId: string; outcome: TurnOutcome }> {
const turnId = await turnRuntime.createTurn({
agent: input.agent,
sessionId: null,
context: input.context ?? [],
input: input.input,
config: {
autoPermission: true,
humanAvailable: false,
...(input.maxModelCalls === undefined
? {}
: { maxModelCalls: input.maxModelCalls }),
},
});
const execution = turnRuntime.advanceTurn(turnId, undefined, {
signal: input.signal,
});
const outcome = await execution.outcome;
return { turnId, outcome };
}

View file

@ -0,0 +1,83 @@
import { z } from "zod";
import {
SessionCorruptionError,
SessionCreated,
SessionEvent,
} from "@x/shared/dist/sessions.js";
import { KeyedMutex } from "../turns/keyed-mutex.js";
import type { ISessionRepo } from "./repo.js";
// Test fake mirroring FSSessionRepo semantics without touching disk.
export class InMemorySessionRepo implements ISessionRepo {
private files = new Map<string, Array<z.infer<typeof SessionEvent>>>();
private corrupt = new Set<string>();
private mutex = new KeyedMutex();
async create(event: z.infer<typeof SessionCreated>): Promise<void> {
const parsed = SessionCreated.parse(event);
if (this.files.has(parsed.sessionId) || this.corrupt.has(parsed.sessionId)) {
throw new Error(`session already exists: ${parsed.sessionId}`);
}
this.files.set(parsed.sessionId, [structuredClone(parsed)]);
}
async read(sessionId: string): Promise<Array<z.infer<typeof SessionEvent>>> {
if (this.corrupt.has(sessionId)) {
throw new SessionCorruptionError(`session file is corrupt: ${sessionId}`);
}
const events = this.files.get(sessionId);
if (!events) {
throw new Error(`session not found: ${sessionId}`);
}
return structuredClone(events);
}
async append(
sessionId: string,
events: Array<z.infer<typeof SessionEvent>>,
): Promise<void> {
const file = this.files.get(sessionId);
if (!file) {
throw new Error(`session not found: ${sessionId}`);
}
for (const event of events) {
const parsed = SessionEvent.parse(event);
if (parsed.sessionId !== sessionId) {
throw new Error(
`event sessionId ${parsed.sessionId} does not match ${sessionId}`,
);
}
file.push(structuredClone(parsed));
}
}
async withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
return this.mutex.run(sessionId, fn);
}
async listSessionIds(): Promise<string[]> {
return [...this.files.keys(), ...this.corrupt].sort();
}
async delete(sessionId: string): Promise<void> {
const existed = this.files.delete(sessionId) || this.corrupt.delete(sessionId);
if (!existed) {
throw new Error(`session not found: ${sessionId}`);
}
}
// Test helpers
seed(events: Array<z.infer<typeof SessionEvent>>): void {
if (events.length === 0) {
throw new Error("cannot seed an empty log");
}
this.files.set(
events[0].sessionId,
events.map((e) => structuredClone(SessionEvent.parse(e))),
);
}
seedCorrupt(sessionId: string): void {
this.corrupt.add(sessionId);
}
}

View file

@ -0,0 +1,8 @@
export * from "./api.js";
export * from "./bus.js";
export * from "./fs-repo.js";
export * from "./headless.js";
export * from "./in-memory-session-repo.js";
export * from "./repo.js";
export * from "./session-index.js";
export * from "./sessions.js";

View file

@ -0,0 +1,20 @@
import type { z } from "zod";
import type { SessionCreated, SessionEvent } from "@x/shared/dist/sessions.js";
export interface ISessionRepo {
// Fails if the session already exists.
create(event: z.infer<typeof SessionCreated>): Promise<void>;
// Validates every line strictly; corrupt files are rejected whole.
read(sessionId: string): Promise<Array<z.infer<typeof SessionEvent>>>;
// Validates events before writing.
append(
sessionId: string,
events: Array<z.infer<typeof SessionEvent>>,
): Promise<void>;
// In-process per-session exclusion.
withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
// Enumerates all session ids for the startup scan.
listSessionIds(): Promise<string[]>;
// Removes the session file only; referenced turn files stay as orphans.
delete(sessionId: string): Promise<void>;
}

View file

@ -0,0 +1,25 @@
import type { SessionIndexEntry } from "@x/shared/dist/sessions.js";
// In-memory projection over session files. Never a source of truth: rebuilt
// by the startup scan, maintained write-through by the sessions service.
export class SessionIndex {
private entries = new Map<string, SessionIndexEntry>();
list(): SessionIndexEntry[] {
return [...this.entries.values()].sort((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
);
}
get(sessionId: string): SessionIndexEntry | undefined {
return this.entries.get(sessionId);
}
upsert(entry: SessionIndexEntry): void {
this.entries.set(entry.sessionId, entry);
}
remove(sessionId: string): boolean {
return this.entries.delete(sessionId);
}
}

View file

@ -0,0 +1,844 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import type { SessionBusEvent, SessionEvent } from "@x/shared/dist/sessions.js";
import type {
ResolvedAgent,
TurnEvent,
TurnStreamEvent,
} from "@x/shared/dist/turns.js";
import type {
CreateTurnInput,
ITurnRuntime,
Turn,
TurnExecution,
TurnExternalInput,
TurnOutcome,
} from "../turns/api.js";
import { TurnInputError } from "../turns/api.js";
import { HotStream } from "../turns/stream.js";
import { TurnNotSettledError } from "./api.js";
import { runHeadlessTurn } from "./headless.js";
import { InMemorySessionRepo } from "./in-memory-session-repo.js";
import type { ISessionRepo } from "./repo.js";
import { SessionsImpl } from "./sessions.js";
type TEvent = z.infer<typeof TurnEvent>;
const TS = "2026-07-02T10:00:00Z";
const FIXTURE_MODEL = { provider: "openai", model: "gpt-fixture" };
const FIXTURE_AGENT: z.infer<typeof ResolvedAgent> = {
agentId: "copilot",
systemPrompt: "SYS",
model: FIXTURE_MODEL,
tools: [],
};
function user(text: string) {
return { role: "user" as const, content: text };
}
function assistantText(text: string) {
return { role: "assistant" as const, content: text };
}
function completedOutcome(): TurnOutcome {
return {
status: "completed",
output: assistantText("ok"),
finishReason: "stop",
usage: {},
};
}
function createdEvent(turnId: string, input: CreateTurnInput): TEvent {
return {
type: "turn_created",
schemaVersion: 1,
turnId,
ts: TS,
sessionId: input.sessionId ?? null,
agent: { requested: input.agent, resolved: FIXTURE_AGENT },
context: input.context,
input: input.input,
config: {
autoPermission: input.config.autoPermission ?? false,
humanAvailable: input.config.humanAvailable,
maxModelCalls: input.config.maxModelCalls ?? 20,
},
};
}
// Minimal valid event logs reducing to each turn status.
function turnLog(
turnId: string,
sessionId: string,
status: "idle" | "completed" | "failed" | "cancelled" | "suspended",
): TEvent[] {
const created: TEvent = createdEvent(turnId, {
agent: { agentId: "copilot" },
sessionId,
context: [],
input: user("hi"),
config: { humanAvailable: true },
});
const requested: TEvent = {
type: "model_call_requested",
turnId,
ts: TS,
modelCallIndex: 0,
request: { messages: ['input'], parameters: {} },
};
switch (status) {
case "idle":
return [created];
case "completed":
return [
created,
requested,
{
type: "model_call_completed",
turnId,
ts: TS,
modelCallIndex: 0,
message: assistantText("ok"),
finishReason: "stop",
usage: {},
},
{
type: "turn_completed",
turnId,
ts: TS,
output: assistantText("ok"),
finishReason: "stop",
usage: {},
},
];
case "failed":
return [
created,
requested,
{ type: "model_call_failed", turnId, ts: TS, modelCallIndex: 0, error: "boom" },
{ type: "turn_failed", turnId, ts: TS, error: "boom", usage: {} },
];
case "cancelled":
return [
created,
{ type: "turn_cancelled", turnId, ts: TS, usage: {} },
];
case "suspended":
return [
created,
requested,
{
type: "model_call_completed",
turnId,
ts: TS,
modelCallIndex: 0,
message: {
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "B",
toolName: "fetch",
arguments: {},
},
],
},
finishReason: "tool-calls",
usage: {},
},
{
type: "tool_invocation_requested",
turnId,
ts: TS,
toolCallId: "B",
toolId: "tool.fetch",
toolName: "fetch",
execution: "async",
input: {},
},
{
type: "turn_suspended",
turnId,
ts: TS,
pendingPermissions: [],
pendingAsyncTools: [
{ toolCallId: "B", toolId: "tool.fetch", toolName: "fetch", input: {} },
],
usage: {},
},
];
}
}
type AdvanceScript = (call: {
turnId: string;
input?: TurnExternalInput;
signal?: AbortSignal;
}) =>
| { events?: TurnStreamEvent[]; outcome: TurnOutcome }
| { error: unknown }
| { untilAbort: true };
class FakeTurnRuntime implements ITurnRuntime {
createTurnInputs: CreateTurnInput[] = [];
advanceCalls: Array<{ turnId: string; input?: TurnExternalInput }> = [];
logs = new Map<string, TEvent[]>();
createError?: Error;
script?: AdvanceScript;
// When set, session turns get an inherited agent snapshot (like the real
// runtime does for identical predecessors).
inheritSnapshots = false;
private n = 0;
async createTurn(input: CreateTurnInput): Promise<string> {
if (this.createError) {
throw this.createError;
}
this.createTurnInputs.push(input);
this.n += 1;
const turnId = `2026-07-02T10-00-00Z-${String(this.n).padStart(7, "0")}-000`;
const created = createdEvent(turnId, input);
if (
this.inheritSnapshots &&
created.type === "turn_created" &&
!Array.isArray(input.context)
) {
created.agent = {
...created.agent,
resolved: {
agentId: FIXTURE_AGENT.agentId,
model: FIXTURE_MODEL,
inheritedFrom: input.context.previousTurnId,
},
};
}
this.logs.set(turnId, [created]);
return turnId;
}
advanceTurn(
turnId: string,
input?: TurnExternalInput,
options?: { signal?: AbortSignal },
): TurnExecution {
this.advanceCalls.push({ turnId, input });
const stream = new HotStream<TurnStreamEvent, TurnOutcome>();
const result = this.script?.({ turnId, input, signal: options?.signal }) ?? {
outcome: completedOutcome(),
};
if ("error" in result) {
stream.fail(result.error);
} else if ("untilAbort" in result) {
const signal = options?.signal;
if (!signal) {
throw new Error("untilAbort script requires a signal");
}
const finish = () => stream.end({ status: "cancelled", usage: {} });
if (signal.aborted) {
finish();
} else {
signal.addEventListener("abort", finish, { once: true });
}
} else {
for (const event of result.events ?? []) {
stream.push(event);
}
stream.end(result.outcome);
}
return { events: stream.events, outcome: stream.outcome };
}
async getTurn(turnId: string): Promise<Turn> {
const log = this.logs.get(turnId);
if (!log) {
throw new Error(`turn not found: ${turnId}`);
}
return { turnId, events: structuredClone(log) };
}
setLog(turnId: string, events: TEvent[]): void {
this.logs.set(turnId, events);
}
}
class RecordingBus {
events: SessionBusEvent[] = [];
publish(event: SessionBusEvent): void {
this.events.push(event);
}
}
class FakeIdGen {
private n = 0;
async next(): Promise<string> {
this.n += 1;
return `2026-07-02T09-00-00Z-${String(this.n).padStart(7, "0")}-000`;
}
}
class FakeClock {
now(): string {
return TS;
}
}
// Wrapper to simulate a crash between turn-file creation and session append.
class FlakySessionRepo implements ISessionRepo {
failNextAppend = false;
constructor(private readonly inner: InMemorySessionRepo) {}
create(event: Parameters<InMemorySessionRepo["create"]>[0]) {
return this.inner.create(event);
}
read(sessionId: string) {
return this.inner.read(sessionId);
}
async append(
sessionId: string,
events: Array<z.infer<typeof SessionEvent>>,
): Promise<void> {
if (this.failNextAppend) {
this.failNextAppend = false;
throw new Error("disk full");
}
return this.inner.append(sessionId, events);
}
withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
return this.inner.withLock(sessionId, fn);
}
listSessionIds() {
return this.inner.listSessionIds();
}
delete(sessionId: string) {
return this.inner.delete(sessionId);
}
}
function makeSessions(opts: { repo?: ISessionRepo; fake?: FakeTurnRuntime } = {}) {
const repo = opts.repo ?? new InMemorySessionRepo();
const fake = opts.fake ?? new FakeTurnRuntime();
const bus = new RecordingBus();
const sessions = new SessionsImpl({
sessionRepo: repo,
turnRuntime: fake,
idGenerator: new FakeIdGen(),
clock: new FakeClock(),
sessionBus: bus,
});
return { sessions, repo, fake, bus };
}
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
describe("createSession and listing", () => {
it("persists session_created and publishes an index entry with status none", async () => {
const { sessions, repo, bus } = makeSessions();
const sessionId = await sessions.createSession({ title: "My chat" });
const events = await (repo as InMemorySessionRepo).read(sessionId);
expect(events).toEqual([
expect.objectContaining({
type: "session_created",
schemaVersion: 1,
sessionId,
title: "My chat",
}),
]);
expect(sessions.listSessions()).toEqual([
expect.objectContaining({
sessionId,
title: "My chat",
turnCount: 0,
latestTurnStatus: "none",
}),
]);
expect(bus.events).toEqual([
expect.objectContaining({ kind: "index-changed", sessionId }),
]);
});
});
describe("sendMessage (13.3)", () => {
it("first message: inline empty context, config on the turn, denormalized ref, default title", async () => {
const { sessions, repo, fake } = makeSessions();
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("Fix the bug in parser"), {
agent: { agentId: "copilot", overrides: { model: { provider: "x", model: "y" } } },
autoPermission: true,
maxModelCalls: 5,
});
expect(fake.createTurnInputs[0]).toEqual({
agent: { agentId: "copilot", overrides: { model: { provider: "x", model: "y" } } },
sessionId,
context: [],
input: user("Fix the bug in parser"),
config: { humanAvailable: true, autoPermission: true, maxModelCalls: 5 },
});
const events = await (repo as InMemorySessionRepo).read(sessionId);
expect(events[1]).toEqual(
expect.objectContaining({
type: "turn_appended",
turnId,
sessionSeq: 1,
agentId: "copilot",
model: FIXTURE_MODEL, // resolved, not requested override input
}),
);
expect(events[2]).toEqual(
expect.objectContaining({
type: "title_changed",
title: "Fix the bug in parser",
}),
);
expect(fake.advanceCalls).toEqual([{ turnId, input: undefined }]);
});
it("subsequent messages reference the latest turn and skip the title", async () => {
const { sessions, repo, fake } = makeSessions();
const sessionId = await sessions.createSession();
const first = await sessions.sendMessage(sessionId, user("one"), {
agent: { agentId: "copilot" },
});
fake.setLog(first.turnId, turnLog(first.turnId, sessionId, "completed"));
const second = await sessions.sendMessage(sessionId, user("two"), {
agent: { agentId: "copilot" },
});
expect(fake.createTurnInputs[1].context).toEqual({
previousTurnId: first.turnId,
});
const events = await (repo as InMemorySessionRepo).read(sessionId);
const appends = events.filter((e) => e.type === "turn_appended");
expect(appends.map((e) => (e.type === "turn_appended" ? e.sessionSeq : 0))).toEqual([1, 2]);
expect(appends[1]).toEqual(
expect.objectContaining({ turnId: second.turnId }),
);
expect(events.filter((e) => e.type === "title_changed")).toHaveLength(1);
});
it("rejects while the latest turn is idle or suspended; allows all terminal statuses", async () => {
const { sessions, fake } = makeSessions();
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("one"), {
agent: { agentId: "copilot" },
});
for (const status of ["idle", "suspended"] as const) {
fake.setLog(turnId, turnLog(turnId, sessionId, status));
const attempt = sessions.sendMessage(sessionId, user("nope"), {
agent: { agentId: "copilot" },
});
await expect(attempt).rejects.toThrowError(TurnNotSettledError);
await expect(attempt).rejects.toMatchObject({
sessionId,
turnId,
turnStatus: status,
});
}
let latest = turnId;
for (const status of ["completed", "failed", "cancelled"] as const) {
fake.setLog(latest, turnLog(latest, sessionId, status));
const result = await sessions.sendMessage(sessionId, user(`after ${status}`), {
agent: { agentId: "copilot" },
});
latest = result.turnId;
}
});
it("denormalizes the model correctly for inherited agent snapshots", async () => {
const { sessions, repo, fake } = makeSessions();
fake.inheritSnapshots = true;
const sessionId = await sessions.createSession();
const first = await sessions.sendMessage(sessionId, user("one"), {
agent: { agentId: "copilot" },
});
fake.setLog(first.turnId, turnLog(first.turnId, sessionId, "completed"));
const second = await sessions.sendMessage(sessionId, user("two"), {
agent: { agentId: "copilot" },
});
// The second turn's created event carries an inherited snapshot; the
// session still denormalizes the concrete model onto turn_appended,
// and status derivation reduces the inherited log fine.
const created = fake.logs.get(second.turnId)?.[0];
expect(
created?.type === "turn_created" && "inheritedFrom" in created.agent.resolved,
).toBe(true);
const events = await (repo as InMemorySessionRepo).read(sessionId);
const appended = events.filter((e) => e.type === "turn_appended");
expect(appended[1]).toMatchObject({ model: FIXTURE_MODEL });
expect(sessions.listSessions()[0].lastModel).toEqual(FIXTURE_MODEL);
});
it("serializes concurrent sends: exactly one wins", async () => {
const { sessions, repo } = makeSessions();
const sessionId = await sessions.createSession();
const results = await Promise.allSettled([
sessions.sendMessage(sessionId, user("a"), { agent: { agentId: "copilot" } }),
sessions.sendMessage(sessionId, user("b"), { agent: { agentId: "copilot" } }),
]);
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
const rejected = results.find((r) => r.status === "rejected");
expect((rejected as PromiseRejectedResult).reason).toBeInstanceOf(
TurnNotSettledError,
);
const events = await (repo as InMemorySessionRepo).read(sessionId);
expect(events.filter((e) => e.type === "turn_appended")).toHaveLength(1);
});
});
describe("write ordering and crash simulation (13.4)", () => {
it("a failed session append leaves a benign orphan turn and no advance; retry works", async () => {
const inner = new InMemorySessionRepo();
const flaky = new FlakySessionRepo(inner);
const { sessions, fake } = makeSessions({ repo: flaky });
const sessionId = await sessions.createSession();
flaky.failNextAppend = true;
await expect(
sessions.sendMessage(sessionId, user("one"), { agent: { agentId: "copilot" } }),
).rejects.toThrowError("disk full");
// Turn file exists (orphan), session unchanged, nothing advanced.
expect(fake.logs.size).toBe(1);
expect(fake.advanceCalls).toHaveLength(0);
expect(await inner.read(sessionId)).toHaveLength(1);
// Retry produces a fresh turn at sessionSeq 1.
const { turnId } = await sessions.sendMessage(sessionId, user("one again"), {
agent: { agentId: "copilot" },
});
const events = await inner.read(sessionId);
expect(events.filter((e) => e.type === "turn_appended")).toEqual([
expect.objectContaining({ turnId, sessionSeq: 1 }),
]);
});
it("a failed createTurn leaves the session untouched", async () => {
const { sessions, repo, fake } = makeSessions();
const sessionId = await sessions.createSession();
fake.createError = new Error("agent resolution failed");
await expect(
sessions.sendMessage(sessionId, user("one"), { agent: { agentId: "ghost" } }),
).rejects.toThrowError("agent resolution failed");
expect(await (repo as InMemorySessionRepo).read(sessionId)).toHaveLength(1);
expect(fake.advanceCalls).toHaveLength(0);
});
});
describe("external input routing (13.5)", () => {
async function setupSuspended() {
const fixture = makeSessions();
const sessionId = await fixture.sessions.createSession();
const { turnId } = await fixture.sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
fixture.fake.setLog(turnId, turnLog(turnId, sessionId, "suspended"));
fixture.fake.advanceCalls.length = 0;
return { ...fixture, sessionId, turnId };
}
it("respondToPermission advances with a permission_decision input", async () => {
const { sessions, fake, turnId } = await setupSuspended();
await sessions.respondToPermission(turnId, "tc1", "allow", { scope: "once" });
expect(fake.advanceCalls).toEqual([
{
turnId,
input: {
type: "permission_decision",
toolCallId: "tc1",
decision: "allow",
metadata: { scope: "once" },
},
},
]);
});
it("respondToAskHuman is a dedicated async_tool_result wrapper", async () => {
const { sessions, fake, turnId } = await setupSuspended();
await sessions.respondToAskHuman(turnId, "B", "the answer is 42");
expect(fake.advanceCalls).toEqual([
{
turnId,
input: {
type: "async_tool_result",
toolCallId: "B",
result: { output: "the answer is 42", isError: false },
},
},
]);
});
it("deliverAsyncToolResult passes the result through verbatim", async () => {
const { sessions, fake, turnId } = await setupSuspended();
await sessions.deliverAsyncToolResult(turnId, "B", {
output: { rows: 3 },
isError: false,
});
expect(fake.advanceCalls[0].input).toEqual({
type: "async_tool_result",
toolCallId: "B",
result: { output: { rows: 3 }, isError: false },
});
});
it("turn-runtime input rejections pass through", async () => {
const { sessions, fake, turnId } = await setupSuspended();
fake.script = () => ({ error: new TurnInputError("no pending async tool call X") });
await expect(
sessions.deliverAsyncToolResult(turnId, "X", { output: "x", isError: false }),
).rejects.toThrowError(TurnInputError);
});
});
describe("stopTurn and resumeTurn", () => {
it("aborts an actively running advance instead of issuing a cancel input", async () => {
const { sessions, fake } = makeSessions();
fake.script = () => ({ untilAbort: true });
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
expect(fake.advanceCalls).toHaveLength(1);
await sessions.stopTurn(turnId);
// No second advance: the running invocation's signal was aborted.
expect(fake.advanceCalls).toHaveLength(1);
});
it("cancels an at-rest turn through a cancel input", async () => {
const { sessions, fake } = makeSessions();
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await flush();
fake.setLog(turnId, turnLog(turnId, sessionId, "suspended"));
fake.advanceCalls.length = 0;
await sessions.stopTurn(turnId, "user stop");
expect(fake.advanceCalls).toEqual([
{ turnId, input: { type: "cancel", reason: "user stop" } },
]);
});
it("resumeTurn re-enters the latest turn with no input", async () => {
const { sessions, fake } = makeSessions();
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await flush();
fake.advanceCalls.length = 0;
await sessions.resumeTurn(sessionId);
expect(fake.advanceCalls).toEqual([{ turnId, input: undefined }]);
});
it("resumeTurn on a session with no turns throws", async () => {
const { sessions } = makeSessions();
const sessionId = await sessions.createSession();
await expect(sessions.resumeTurn(sessionId)).rejects.toThrowError(
/no turns to resume/,
);
});
});
describe("event forwarding and index maintenance (13.6, 13.7)", () => {
it("forwards stream events to the bus tagged with sessionId in order", async () => {
const { sessions, fake, bus } = makeSessions();
const streamed: TurnStreamEvent[] = [
{ type: "text_delta", turnId: "x", modelCallIndex: 0, delta: "he" },
{ type: "text_delta", turnId: "x", modelCallIndex: 0, delta: "y" },
];
fake.script = () => ({ events: streamed, outcome: completedOutcome() });
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await flush();
const turnEvents = bus.events.filter((e) => e.kind === "turn-event");
expect(turnEvents).toEqual(
streamed.map((event) => ({
kind: "turn-event",
sessionId,
turnId,
event,
})),
);
});
it("outcome settlement updates the index entry's latest turn status", async () => {
const { sessions, fake, bus } = makeSessions();
fake.script = () => ({
outcome: {
status: "suspended",
pendingPermissions: [],
pendingAsyncTools: [
{ toolCallId: "B", toolId: "tool.fetch", toolName: "fetch", input: {} },
],
usage: {},
},
});
const sessionId = await sessions.createSession();
await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await flush();
expect(sessions.listSessions()[0].latestTurnStatus).toBe("suspended");
const last = bus.events[bus.events.length - 1];
expect(last).toMatchObject({
kind: "index-changed",
entry: { latestTurnStatus: "suspended" },
});
});
it("setTitle appends and updates the index preserving status", async () => {
const { sessions, repo } = makeSessions();
const sessionId = await sessions.createSession();
await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await flush();
await sessions.setTitle(sessionId, "Renamed");
const events = await (repo as InMemorySessionRepo).read(sessionId);
expect(events[events.length - 1]).toMatchObject({
type: "title_changed",
title: "Renamed",
});
expect(sessions.listSessions()[0]).toMatchObject({
title: "Renamed",
latestTurnStatus: "completed",
});
});
it("deleteSession removes the file and entry; turn files stay; late settles don't resurrect", async () => {
const { sessions, repo, fake, bus } = makeSessions();
fake.script = () => ({ untilAbort: true });
const sessionId = await sessions.createSession();
const { turnId } = await sessions.sendMessage(sessionId, user("go"), {
agent: { agentId: "copilot" },
});
await sessions.deleteSession(sessionId);
await expect(
(repo as InMemorySessionRepo).read(sessionId),
).rejects.toThrowError(/session not found/);
expect(sessions.listSessions()).toEqual([]);
expect(bus.events[bus.events.length - 1]).toEqual({
kind: "index-changed",
sessionId,
entry: null,
});
// Turn file untouched (orphaned, inert).
expect(fake.logs.has(turnId)).toBe(true);
// The still-running advance settles after deletion: no resurrection.
await sessions.stopTurn(turnId);
await flush();
expect(sessions.listSessions()).toEqual([]);
});
});
describe("startup scan (13.6)", () => {
it("builds the index from session files and each latest turn; corrupt files yield errored entries", async () => {
const repo = new InMemorySessionRepo();
const fake = new FakeTurnRuntime();
const s1 = "2026-07-02T09-00-00Z-0000001-000";
const s2 = "2026-07-02T09-00-00Z-0000002-000";
const s3 = "2026-07-02T09-00-00Z-0000003-000";
repo.seed([
{ type: "session_created", schemaVersion: 1, sessionId: s1, ts: TS, title: "Done one" },
{
type: "turn_appended",
sessionId: s1,
ts: TS,
turnId: "t1",
sessionSeq: 1,
agentId: "copilot",
model: FIXTURE_MODEL,
},
]);
fake.setLog("t1", turnLog("t1", s1, "completed"));
repo.seed([
{ type: "session_created", schemaVersion: 1, sessionId: s2, ts: TS },
{
type: "turn_appended",
sessionId: s2,
ts: TS,
turnId: "t2",
sessionSeq: 1,
agentId: "researcher",
model: { provider: "anthropic", model: "claude-x" },
},
]);
fake.setLog("t2", turnLog("t2", s2, "suspended"));
repo.seedCorrupt(s3);
const { sessions } = makeSessions({ repo, fake });
await sessions.initialize();
const entries = sessions.listSessions();
expect(entries).toHaveLength(3);
const byId = new Map(entries.map((e) => [e.sessionId, e]));
expect(byId.get(s1)).toMatchObject({
title: "Done one",
turnCount: 1,
lastAgentId: "copilot",
lastModel: FIXTURE_MODEL,
latestTurnStatus: "completed",
});
expect(byId.get(s2)).toMatchObject({
lastAgentId: "researcher",
latestTurnStatus: "suspended",
});
expect(byId.get(s3)).toMatchObject({
latestTurnStatus: "none",
error: expect.stringMatching(/corrupt/),
});
});
it("a rebuilt index matches write-through state for the same history", async () => {
const repo = new InMemorySessionRepo();
const fake = new FakeTurnRuntime();
const live = makeSessions({ repo, fake });
const sessionId = await live.sessions.createSession();
const { turnId } = await live.sessions.sendMessage(sessionId, user("hello world"), {
agent: { agentId: "copilot" },
});
await flush();
fake.setLog(turnId, turnLog(turnId, sessionId, "completed"));
const rebuilt = makeSessions({ repo, fake });
await rebuilt.sessions.initialize();
expect(rebuilt.sessions.listSessions()).toEqual([
expect.objectContaining({
sessionId,
title: "hello world",
turnCount: 1,
latestTurnId: turnId,
latestTurnStatus: "completed",
}),
]);
});
});
describe("headless standalone turns (13.8)", () => {
it("runs a standalone turn with sessionId null, auto permission, no human", async () => {
const fake = new FakeTurnRuntime();
const { turnId, outcome } = await runHeadlessTurn(fake, {
agent: { agentId: "background-task-agent" },
input: user("summarize"),
maxModelCalls: 3,
});
expect(fake.createTurnInputs[0]).toEqual({
agent: { agentId: "background-task-agent" },
sessionId: null,
context: [],
input: user("summarize"),
config: { autoPermission: true, humanAvailable: false, maxModelCalls: 3 },
});
expect(outcome.status).toBe("completed");
expect(fake.advanceCalls).toEqual([{ turnId, input: undefined }]);
});
});

View file

@ -0,0 +1,418 @@
import type { z } from "zod";
import type { UserMessage } from "@x/shared/dist/message.js";
import {
SessionCreated,
type SessionEvent,
type SessionIndexEntry,
type SessionLatestTurnStatus,
type SessionState,
reduceSession,
sessionIndexEntry,
} from "@x/shared/dist/sessions.js";
import {
type JsonValue,
type ModelDescriptor,
type ToolResultData,
deriveTurnStatus,
reduceTurn,
} from "@x/shared/dist/turns.js";
import type { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import type {
ITurnRuntime,
Turn,
TurnExecution,
TurnExternalInput,
TurnOutcome,
} from "../turns/api.js";
import type { IClock } from "../turns/clock.js";
import {
type ISessions,
type SendMessageConfig,
TurnNotSettledError,
} from "./api.js";
import type { ISessionBus } from "./bus.js";
import type { ISessionRepo } from "./repo.js";
import { SessionIndex } from "./session-index.js";
export interface SessionsDependencies {
sessionRepo: ISessionRepo;
turnRuntime: ITurnRuntime;
idGenerator: IMonotonicallyIncreasingIdGenerator;
clock: IClock;
sessionBus: ISessionBus;
}
// The session layer per session-design.md: owns conversations as ordered
// chains of turn references, enforces one active turn per session, assembles
// context as a reference to the previous turn, and maintains the in-memory
// index write-through. It never reads or writes turn-file contents beyond
// what ITurnRuntime exposes.
export class SessionsImpl implements ISessions {
private readonly sessionRepo: ISessionRepo;
private readonly turnRuntime: ITurnRuntime;
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
private readonly clock: IClock;
private readonly sessionBus: ISessionBus;
private readonly index = new SessionIndex();
// Ephemeral: executions this process started, for stopTurn's abort path.
private readonly active = new Map<
string,
{ sessionId: string | null; controller: AbortController; execution: TurnExecution }
>();
constructor({
sessionRepo,
turnRuntime,
idGenerator,
clock,
sessionBus,
}: SessionsDependencies) {
this.sessionRepo = sessionRepo;
this.turnRuntime = turnRuntime;
this.idGenerator = idGenerator;
this.clock = clock;
this.sessionBus = sessionBus;
}
// §8.2: scan session files, read each session's latest turn for status.
// Corrupt files yield errored entries; the scan never aborts.
async initialize(): Promise<void> {
for (const sessionId of await this.sessionRepo.listSessionIds()) {
this.index.upsert(await this.scanSession(sessionId));
}
}
private async scanSession(sessionId: string): Promise<SessionIndexEntry> {
try {
const state = reduceSession(await this.sessionRepo.read(sessionId));
const status = await this.latestTurnStatus(state);
return sessionIndexEntry(state, status);
} catch (error) {
return {
sessionId,
createdAt: "",
updatedAt: "",
turnCount: 0,
latestTurnStatus: "none",
error: errorMessage(error),
};
}
}
private async latestTurnStatus(
state: SessionState,
): Promise<SessionLatestTurnStatus> {
if (!state.latestTurnId) {
return "none";
}
const turn = await this.turnRuntime.getTurn(state.latestTurnId);
return deriveTurnStatus(reduceTurn(turn.events));
}
async createSession(input?: { title?: string }): Promise<string> {
const sessionId = await this.idGenerator.next();
const event = SessionCreated.parse({
type: "session_created",
schemaVersion: 1,
sessionId,
ts: this.clock.now(),
...(input?.title === undefined ? {} : { title: input.title }),
});
await this.sessionRepo.create(event);
this.publishEntry(sessionIndexEntry(reduceSession([event]), "none"));
return sessionId;
}
listSessions(): SessionIndexEntry[] {
return this.index.list();
}
async getSession(sessionId: string): Promise<SessionState> {
return reduceSession(await this.sessionRepo.read(sessionId));
}
async getTurn(turnId: string): Promise<Turn> {
return this.turnRuntime.getTurn(turnId);
}
// §9.1. Write order per §7: turn file first, then turn_appended, then the
// first advance — so an orphan turn (crash between the writes) is benign
// and an executing turn is always referenced.
async sendMessage(
sessionId: string,
input: z.infer<typeof UserMessage>,
config: SendMessageConfig,
): Promise<{ turnId: string }> {
return this.sessionRepo.withLock(sessionId, async () => {
const events = await this.sessionRepo.read(sessionId);
const state = reduceSession(events);
if (state.latestTurnId) {
const status = await this.latestTurnStatus(state);
if (
status !== "completed" &&
status !== "failed" &&
status !== "cancelled"
) {
throw new TurnNotSettledError(
sessionId,
state.latestTurnId,
status,
);
}
}
const turnId = await this.turnRuntime.createTurn({
agent: config.agent,
sessionId,
context: state.latestTurnId
? { previousTurnId: state.latestTurnId }
: [],
input,
config: {
humanAvailable: true,
...(config.autoPermission === undefined
? {}
: { autoPermission: config.autoPermission }),
...(config.maxModelCalls === undefined
? {}
: { maxModelCalls: config.maxModelCalls }),
},
});
const batch: Array<z.infer<typeof SessionEvent>> = [
{
type: "turn_appended",
sessionId,
ts: this.clock.now(),
turnId,
sessionSeq: state.turns.length + 1,
agentId: config.agent.agentId,
model: await this.resolvedModelOf(turnId),
},
];
if (!state.title) {
batch.push({
type: "title_changed",
sessionId,
ts: this.clock.now(),
title: defaultTitle(input),
});
}
await this.sessionRepo.append(sessionId, batch);
this.publishEntry(
sessionIndexEntry(reduceSession([...events, ...batch]), "idle"),
);
this.startTrackedAdvance(sessionId, turnId);
return { turnId };
});
}
async respondToPermission(
turnId: string,
toolCallId: string,
decision: "allow" | "deny",
metadata?: JsonValue,
): Promise<void> {
await this.advanceWithInput(turnId, {
type: "permission_decision",
toolCallId,
decision,
...(metadata === undefined ? {} : { metadata }),
});
}
async respondToAskHuman(
turnId: string,
toolCallId: string,
answer: string,
): Promise<void> {
await this.advanceWithInput(turnId, {
type: "async_tool_result",
toolCallId,
result: { output: answer, isError: false },
});
}
async deliverAsyncToolResult(
turnId: string,
toolCallId: string,
result: z.infer<typeof ToolResultData>,
): Promise<void> {
await this.advanceWithInput(turnId, {
type: "async_tool_result",
toolCallId,
result,
});
}
async stopTurn(turnId: string, reason?: string): Promise<void> {
const running = this.active.get(turnId);
if (running) {
running.controller.abort();
await running.execution.outcome.catch(() => undefined);
return;
}
await this.advanceWithInput(turnId, {
type: "cancel",
...(reason === undefined ? {} : { reason }),
});
}
// Recovery entry for idle (crash-interrupted) turns. Deliberately not run
// at startup: recovery re-issues interrupted model calls, so resumption
// must be an explicit action. Runs in the background.
async resumeTurn(sessionId: string): Promise<void> {
const state = reduceSession(await this.sessionRepo.read(sessionId));
if (!state.latestTurnId) {
throw new Error(`session ${sessionId} has no turns to resume`);
}
this.startTrackedAdvance(sessionId, state.latestTurnId);
}
async setTitle(sessionId: string, title: string): Promise<void> {
await this.sessionRepo.withLock(sessionId, async () => {
const events = await this.sessionRepo.read(sessionId);
const batch: Array<z.infer<typeof SessionEvent>> = [
{ type: "title_changed", sessionId, ts: this.clock.now(), title },
];
await this.sessionRepo.append(sessionId, batch);
const state = reduceSession([...events, ...batch]);
const existing = this.index.get(sessionId);
this.publishEntry(
sessionIndexEntry(state, existing?.latestTurnStatus ?? "none"),
);
});
}
// §9.4: removes the session file and index entry only. Referenced turn
// files stay behind as inert orphans.
async deleteSession(sessionId: string): Promise<void> {
await this.sessionRepo.withLock(sessionId, async () => {
await this.sessionRepo.delete(sessionId);
this.index.remove(sessionId);
this.sessionBus.publish({ kind: "index-changed", sessionId, entry: null });
});
}
// ------------------------------------------------------------------
// Internals
// ------------------------------------------------------------------
private async resolvedModelOf(
turnId: string,
): Promise<z.infer<typeof ModelDescriptor>> {
const turn = await this.turnRuntime.getTurn(turnId);
const created = turn.events[0];
if (created.type !== "turn_created") {
throw new Error(`turn ${turnId} has no turn_created event`);
}
return created.agent.resolved.model;
}
private async sessionIdOf(turnId: string): Promise<string | null> {
const turn = await this.turnRuntime.getTurn(turnId);
const created = turn.events[0];
if (created.type !== "turn_created") {
throw new Error(`turn ${turnId} has no turn_created event`);
}
return created.sessionId;
}
private async advanceWithInput(
turnId: string,
input: TurnExternalInput,
): Promise<void> {
const sessionId = await this.sessionIdOf(turnId);
const execution = this.startTrackedAdvance(sessionId, turnId, input);
await execution.outcome;
}
// Every advance this layer initiates: forward its stream to the bus
// tagged with sessionId, keep the abort controller for stopTurn, and
// update the index entry when the outcome settles.
private startTrackedAdvance(
sessionId: string | null,
turnId: string,
input?: TurnExternalInput,
): TurnExecution {
const controller = new AbortController();
const execution = this.turnRuntime.advanceTurn(turnId, input, {
signal: controller.signal,
});
this.active.set(turnId, { sessionId, controller, execution });
void (async () => {
try {
for await (const event of execution.events) {
if (sessionId !== null) {
this.sessionBus.publish({
kind: "turn-event",
sessionId,
turnId,
event,
});
}
}
} catch {
// Infrastructure failures surface through the outcome.
}
})();
void execution.outcome
.then((outcome) => this.onSettled(sessionId, turnId, outcome))
.catch(() => undefined)
.finally(() => this.active.delete(turnId));
return execution;
}
private onSettled(
sessionId: string | null,
turnId: string,
outcome: TurnOutcome,
): void {
if (sessionId === null) {
return;
}
const entry = this.index.get(sessionId);
// The session may have been deleted, or a newer turn appended.
if (!entry || entry.latestTurnId !== turnId) {
return;
}
this.publishEntry({
...entry,
latestTurnStatus: outcome.status,
updatedAt: this.clock.now(),
});
}
private publishEntry(entry: SessionIndexEntry): void {
this.index.upsert(entry);
this.sessionBus.publish({
kind: "index-changed",
sessionId: entry.sessionId,
entry,
});
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function defaultTitle(input: z.infer<typeof UserMessage>): string {
const text =
typeof input.content === "string"
? input.content
: input.content
.map((part) => (part.type === "text" ? part.text : ""))
.join(" ");
const collapsed = text.trim().replace(/\s+/g, " ");
if (collapsed.length === 0) {
return "New session";
}
return collapsed.length > 80 ? `${collapsed.slice(0, 79)}` : collapsed;
}

View file

@ -0,0 +1,14 @@
import type { z } from "zod";
import type { RequestedAgent, ResolvedAgent } from "@x/shared/dist/turns.js";
// Absorbs agent assembly: built-in/dynamic agent selection, user-defined
// agent loading, system-prompt augmentation, model precedence
// (override > agent > application default), tool attachment and filtering.
// The result is the immutable execution snapshot persisted in turn_created;
// the resolved system prompt is final byte-for-byte. Resolution failure
// rejects createTurn without creating a turn file.
export interface IAgentResolver {
resolve(
agent: z.infer<typeof RequestedAgent>,
): Promise<z.infer<typeof ResolvedAgent>>;
}

View file

@ -0,0 +1,112 @@
import type { z } from "zod";
import type { AssistantMessage, UserMessage } from "@x/shared/dist/message.js";
import type {
JsonValue,
RequestedAgent,
ToolResultData,
TurnContext,
TurnEvent,
TurnStreamEvent,
TurnSuspended,
TurnUsage,
} from "@x/shared/dist/turns.js";
export interface CreateTurnInput {
agent: z.infer<typeof RequestedAgent>;
sessionId?: string | null;
context: z.infer<typeof TurnContext>;
input: z.infer<typeof UserMessage>;
config: {
autoPermission?: boolean;
humanAvailable: boolean;
maxModelCalls?: number;
};
}
// Exactly one external input per advanceTurn invocation.
export type TurnExternalInput =
| {
type: "permission_decision";
toolCallId: string;
decision: "allow" | "deny";
metadata?: JsonValue;
}
| {
type: "async_tool_progress";
toolCallId: string;
progress: JsonValue;
}
| {
type: "async_tool_result";
toolCallId: string;
result: z.infer<typeof ToolResultData>;
}
| {
type: "cancel";
reason?: string;
};
export type TurnOutcome =
| {
status: "completed";
output: z.infer<typeof AssistantMessage>;
finishReason: string;
usage: z.infer<typeof TurnUsage>;
}
| {
status: "suspended";
pendingPermissions: z.infer<typeof TurnSuspended>["pendingPermissions"];
pendingAsyncTools: z.infer<typeof TurnSuspended>["pendingAsyncTools"];
usage: z.infer<typeof TurnUsage>;
}
| {
status: "failed";
error: string;
// Mirrors turn_failed.code (e.g. MODEL_CALL_LIMIT_ERROR_CODE).
code?: string;
usage: z.infer<typeof TurnUsage>;
}
| {
status: "cancelled";
reason?: string;
usage: z.infer<typeof TurnUsage>;
};
export interface TurnExecution {
events: AsyncIterable<TurnStreamEvent>;
outcome: Promise<TurnOutcome>;
}
export interface Turn {
turnId: string;
events: Array<z.infer<typeof TurnEvent>>;
}
export interface ITurnRuntime {
createTurn(input: CreateTurnInput): Promise<string>;
advanceTurn(
turnId: string,
input?: TurnExternalInput,
options?: { signal?: AbortSignal },
): TurnExecution;
getTurn(turnId: string): Promise<Turn>;
}
// An external input that does not match the turn's current durable pending
// state. Rejects the execution; nothing is appended.
export class TurnInputError extends Error {
constructor(message: string) {
super(message);
this.name = "TurnInputError";
}
}
// A live runtime dependency is missing or incompatible with the persisted
// snapshot. Rejects the execution; the turn is left unchanged so the caller
// can fix its environment and retry.
export class TurnDependencyError extends Error {
constructor(message: string) {
super(message);
this.name = "TurnDependencyError";
}
}

View file

@ -0,0 +1,5 @@
export * from "./real-agent-resolver.js";
export * from "./real-model-registry.js";
export * from "./real-permission-checker.js";
export * from "./real-permission-classifier.js";
export * from "./real-tool-registry.js";

View file

@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js";
import { composeSystemInstructions } from "../../agents/runtime.js";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { RealAgentResolver } from "./real-agent-resolver.js";
const DEFAULTS = async () => ({ model: "gpt-default", provider: "openai" });
function makeAgent(
overrides: Partial<z.infer<typeof Agent>> = {},
): z.infer<typeof Agent> {
return {
name: "copilot",
instructions: "You are Copilot.",
tools: {},
...overrides,
};
}
// A minimal fake builtin catalog (the real one is heavy and irrelevant here).
const fakeBuiltins = {
"file-list": {
description: "List files",
inputSchema: z.object({ path: z.string() }),
execute: async () => null,
},
"web-search": {
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
execute: async () => null,
isAvailable: async () => false,
},
} as unknown as typeof BuiltinTools;
function makeResolver(agent: z.infer<typeof Agent>, deps: Partial<ConstructorParameters<typeof RealAgentResolver>[0]> = {}) {
return new RealAgentResolver({
load: async () => agent,
builtins: fakeBuiltins,
defaultModel: DEFAULTS,
loadNotes: () => null,
loadWorkDir: () => null,
...deps,
});
}
describe("RealAgentResolver", () => {
it("applies model precedence: override > agent config > app default", async () => {
const withNothing = makeResolver(makeAgent());
expect((await withNothing.resolve({ agentId: "copilot" })).model).toEqual({
provider: "openai",
model: "gpt-default",
});
const withAgentModel = makeResolver(
makeAgent({ provider: "anthropic", model: "claude-x" }),
);
expect(
(await withAgentModel.resolve({ agentId: "copilot" })).model,
).toEqual({ provider: "anthropic", model: "claude-x" });
expect(
(
await withAgentModel.resolve({
agentId: "copilot",
overrides: { model: { provider: "google", model: "gemini-x" } },
})
).model,
).toEqual({ provider: "google", model: "gemini-x" });
});
it("maps builtins to descriptors with JSON schemas, filtering unavailable ones", async () => {
const resolver = makeResolver(
makeAgent({
tools: {
"file-list": { type: "builtin", name: "file-list" },
"web-search": { type: "builtin", name: "web-search" }, // unavailable
ghost: { type: "builtin", name: "ghost" }, // not in catalog
},
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools).toHaveLength(1);
expect(resolved.tools[0]).toMatchObject({
toolId: "builtin:file-list",
name: "file-list",
description: "List files",
execution: "sync",
requiresHuman: false,
});
expect(resolved.tools[0].inputSchema).toMatchObject({
type: "object",
properties: { path: { type: "string" } },
});
});
it("passes MCP schemas through and skips agent-as-tool attachments", async () => {
const resolver = makeResolver(
makeAgent({
tools: {
lookup: {
type: "mcp",
name: "lookup",
description: "Look things up",
inputSchema: { type: "object", properties: { q: { type: "string" } } },
mcpServerName: "kb",
},
subflow: { type: "agent", name: "researcher" },
},
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools).toHaveLength(1);
expect(resolved.tools[0]).toMatchObject({
toolId: "mcp:kb:lookup",
name: "lookup",
description: "Look things up",
execution: "sync",
});
});
it("maps ask-human to an async human-dependent descriptor", async () => {
const resolver = makeResolver(
makeAgent({
tools: { "ask-human": { type: "builtin", name: "ask-human" } },
}),
);
const resolved = await resolver.resolve({ agentId: "copilot" });
expect(resolved.tools[0]).toMatchObject({
toolId: "builtin:ask-human",
execution: "async",
requiresHuman: true,
});
});
it("composes the system prompt byte-identically to the shared composer", async () => {
const resolver = makeResolver(makeAgent(), {
loadNotes: () => "# Agent Notes\nremember X",
loadWorkDir: (id) => (id === "sess-1" ? "/Users/me/work" : null),
});
const resolved = await resolver.resolve({
agentId: "copilot",
overrides: {
composition: {
workDirId: "sess-1",
searchEnabled: true,
codeMode: "claude",
},
},
});
expect(resolved.systemPrompt).toBe(
composeSystemInstructions({
instructions: "You are Copilot.",
agentNotesContext: "# Agent Notes\nremember X",
userWorkDir: "/Users/me/work",
voiceInput: false,
voiceOutput: null,
searchEnabled: true,
codeMode: "claude",
codeCwd: null,
}),
);
});
it("is prompt-stable: identical composition yields identical bytes; unknown keys are ignored", async () => {
const resolver = makeResolver(makeAgent());
const a = await resolver.resolve({ agentId: "copilot" });
const b = await resolver.resolve({
agentId: "copilot",
overrides: { composition: { someUnknownKey: 42 } },
});
expect(b.systemPrompt).toBe(a.systemPrompt);
});
it("does not load notes/work-dir for non-copilot agents", async () => {
let notesLoaded = 0;
const resolver = makeResolver(makeAgent({ name: "background-task-agent" }), {
loadNotes: () => {
notesLoaded += 1;
return "notes";
},
});
await resolver.resolve({ agentId: "background-task-agent" });
expect(notesLoaded).toBe(0);
});
it("rejects when the agent cannot be loaded, creating nothing", async () => {
const resolver = new RealAgentResolver({
load: async () => {
throw new Error("no such agent");
},
builtins: fakeBuiltins,
defaultModel: DEFAULTS,
});
await expect(resolver.resolve({ agentId: "ghost" })).rejects.toThrowError(
"no such agent",
);
});
});

View file

@ -0,0 +1,202 @@
import { z } from "zod";
import type { Agent } from "@x/shared/dist/agent.js";
import {
type JsonValue,
RequestedAgent,
ResolvedAgent,
type ToolDescriptor,
} from "@x/shared/dist/turns.js";
import {
composeSystemInstructions,
loadAgent,
loadAgentNotesContext,
loadUserWorkDir,
} from "../../agents/runtime.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import { getDefaultModelAndProvider } from "../../models/defaults.js";
import type { IAgentResolver } from "../agent-resolver.js";
export const ASK_HUMAN_TOOL = "ask-human";
const ASK_HUMAN_DESCRIPTOR: z.infer<typeof ToolDescriptor> = {
toolId: `builtin:${ASK_HUMAN_TOOL}`,
name: ASK_HUMAN_TOOL,
description:
"Ask a human before proceeding. Optionally pass `options` (an array of short button labels) when a small set of choices would help the human answer quickly.",
inputSchema: {
type: "object",
properties: {
question: {
type: "string",
description: "The question to ask the human",
},
options: {
type: "array",
items: { type: "string" },
description: "Optional short button labels the human can pick from",
},
},
required: ["question"],
additionalProperties: false,
},
execution: "async",
requiresHuman: true,
};
// Recognized keys of the opaque RequestedAgent.overrides.composition value.
// Unknown keys are ignored. Prompt-affecting inputs should be session-sticky:
// every key here alters system-prompt bytes and therefore busts provider
// prefix caching when it changes between turns.
const CompositionOverrides = z.object({
workDirId: z.string().nullable().optional(),
voiceInput: z.boolean().optional(),
voiceOutput: z.enum(["summary", "full"]).nullable().optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(["claude", "codex"]).nullable().optional(),
codeCwd: z.string().nullable().optional(),
videoMode: z.boolean().optional(),
coachMode: z.boolean().optional(),
});
export interface RealAgentResolverDeps {
load?: typeof loadAgent;
builtins?: typeof BuiltinTools;
defaultModel?: () => Promise<{ model: string; provider: string }>;
loadNotes?: () => string | null;
loadWorkDir?: (workDirId: string) => string | null;
}
// Bridges the existing agent system (loadAgent + dynamic builders, the
// BuiltinTools catalog, MCP attachments) to the immutable ResolvedAgent
// snapshot. The composed system prompt is byte-identical to the old
// runtime's streamAgent assembly for the same inputs.
export class RealAgentResolver implements IAgentResolver {
private readonly load: typeof loadAgent;
private readonly builtins: typeof BuiltinTools;
private readonly defaultModel: () => Promise<{ model: string; provider: string }>;
private readonly loadNotes: () => string | null;
private readonly loadWorkDir: (workDirId: string) => string | null;
constructor(deps: RealAgentResolverDeps = {}) {
this.load = deps.load ?? loadAgent;
this.builtins = deps.builtins ?? BuiltinTools;
this.defaultModel = deps.defaultModel ?? getDefaultModelAndProvider;
this.loadNotes = deps.loadNotes ?? loadAgentNotesContext;
this.loadWorkDir = deps.loadWorkDir ?? loadUserWorkDir;
}
async resolve(
requested: z.infer<typeof RequestedAgent>,
): Promise<z.infer<typeof ResolvedAgent>> {
const agent = await this.load(requested.agentId);
if (!agent) {
throw new Error(`agent not found: ${requested.agentId}`);
}
// Model precedence: createTurn override > agent config > app default.
let model = requested.overrides?.model;
if (!model) {
const fallback = await this.defaultModel();
model = {
provider: agent.provider ?? fallback.provider,
model: agent.model ?? fallback.model,
};
}
const parsed = CompositionOverrides.safeParse(
requested.overrides?.composition ?? {},
);
const composition = parsed.success ? parsed.data : {};
// Agent notes and work-dir context are copilot-scoped, matching the
// old runtime's behavior.
const copilotContext =
requested.agentId === "copilot" || requested.agentId === "rowboatx";
const systemPrompt = composeSystemInstructions({
instructions: agent.instructions,
agentNotesContext: copilotContext ? this.loadNotes() : null,
userWorkDir:
copilotContext && composition.workDirId
? this.loadWorkDir(composition.workDirId)
: null,
voiceInput: composition.voiceInput ?? false,
voiceOutput: composition.voiceOutput ?? null,
searchEnabled: composition.searchEnabled ?? false,
codeMode: composition.codeMode ?? null,
codeCwd: composition.codeCwd ?? null,
videoMode: composition.videoMode ?? false,
coachMode: composition.coachMode ?? false,
});
const tools = await this.resolveTools(agent);
return ResolvedAgent.parse({
agentId: requested.agentId,
systemPrompt,
model,
tools,
});
}
private async resolveTools(
agent: z.infer<typeof Agent>,
): Promise<Array<z.infer<typeof ToolDescriptor>>> {
const tools: Array<z.infer<typeof ToolDescriptor>> = [];
for (const [name, attachment] of Object.entries(agent.tools ?? {})) {
if (attachment.type === "agent") {
continue; // agent-as-tool is not supported in v1
}
if (attachment.type === "mcp") {
tools.push({
toolId: `mcp:${attachment.mcpServerName}:${attachment.name}`,
name,
description: attachment.description,
inputSchema:
toJsonValue(attachment.inputSchema) ??
{ type: "object", properties: {} },
execution: "sync",
requiresHuman: false,
});
continue;
}
if (name === ASK_HUMAN_TOOL) {
tools.push(ASK_HUMAN_DESCRIPTOR);
continue;
}
const builtin = this.builtins[attachment.name];
if (!builtin) {
continue;
}
if (builtin.isAvailable && !(await builtin.isAvailable())) {
continue;
}
tools.push({
toolId: `builtin:${attachment.name}`,
name,
description: builtin.description,
inputSchema: toJsonSchema(builtin.inputSchema),
execution: "sync",
requiresHuman: false,
});
}
return tools;
}
}
function toJsonSchema(schema: unknown): JsonValue {
try {
return toJsonValue(z.toJSONSchema(schema as z.ZodType)) ?? {
type: "object",
properties: {},
};
} catch {
// An exotic zod schema must not break the whole turn.
return { type: "object", properties: {} };
}
}
function toJsonValue(value: unknown): JsonValue | undefined {
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}

View file

@ -0,0 +1,214 @@
import { describe, expect, it } from "vitest";
import type { LanguageModel } from "ai";
import type { LlmStreamEvent, ModelStreamRequest } from "../model-registry.js";
import { RealModelRegistry, type StreamTextInvoker } from "./real-model-registry.js";
type InvokerOptions = Parameters<StreamTextInvoker>[0];
function makeRegistry(parts: Array<Record<string, unknown>>, capture: InvokerOptions[]) {
const fakeModel = { modelId: "gpt-test" } as unknown as LanguageModel;
return new RealModelRegistry({
resolveProvider: async () => ({ flavor: "openai" }),
createProviderImpl: (() => ({
languageModel: () => fakeModel,
})) as never,
invoke: (options) => {
capture.push(options);
return {
fullStream: (async function* () {
yield* parts;
})(),
};
},
});
}
function request(overrides: Partial<ModelStreamRequest> = {}): ModelStreamRequest {
return {
systemPrompt: "SYS",
messages: [{ role: "user", content: "hello" }] as ModelStreamRequest["messages"],
tools: [
{
toolId: "builtin:echo",
name: "echo",
description: "Echo",
inputSchema: { type: "object", properties: {} },
execution: "sync",
requiresHuman: false,
},
],
parameters: {},
signal: new AbortController().signal,
...overrides,
};
}
async function collect(registry: RealModelRegistry, req: ModelStreamRequest) {
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
const events: LlmStreamEvent[] = [];
for await (const event of model.stream(req)) {
events.push(event);
}
return events;
}
describe("RealModelRegistry", () => {
it("normalizes one streamText step into deltas, step events, and a completed message", async () => {
const capture: InvokerOptions[] = [];
const registry = makeRegistry(
[
{ type: "start" },
{ type: "text-start" },
{ type: "text-delta", text: "Hel" },
{ type: "text-delta", text: "lo" },
{ type: "text-end" },
{ type: "tool-call", toolCallId: "tc1", toolName: "echo", input: { x: 1 } },
{
type: "finish-step",
finishReason: "tool-calls",
usage: { inputTokens: 7, outputTokens: 3, totalTokens: 10 },
providerMetadata: { openai: { cached: true } },
},
],
capture,
);
const events = await collect(registry, request());
expect(events.map((e) => e.type)).toEqual([
"step_event", // text_start
"text_delta",
"text_delta",
"step_event", // text_end
"step_event", // tool_call
"step_event", // finish_step
"completed",
]);
expect(events[3]).toEqual({
type: "step_event",
event: { type: "text_end", text: "Hello" },
});
const completed = events[events.length - 1];
expect(completed).toMatchObject({
type: "completed",
finishReason: "tool-calls",
usage: { inputTokens: 7, outputTokens: 3, totalTokens: 10 },
providerMetadata: { openai: { cached: true } },
message: {
role: "assistant",
content: [
{ type: "text", text: "Hello" },
{
type: "tool-call",
toolCallId: "tc1",
toolName: "echo",
arguments: { x: 1 },
},
],
},
});
// The invoker received the system prompt, the pre-encoded messages
// verbatim (encoding is the composer's job), and the wrapped tools.
expect(capture[0].system).toBe("SYS");
expect(capture[0].messages).toEqual([{ role: "user", content: "hello" }]);
expect(Object.keys(capture[0].tools)).toEqual(["echo"]);
});
it("accumulates reasoning separately and emits reasoning deltas", async () => {
const registry = makeRegistry(
[
{ type: "reasoning-start" },
{ type: "reasoning-delta", text: "thinking…" },
{ type: "reasoning-end" },
{ type: "text-start" },
{ type: "text-delta", text: "done" },
{ type: "text-end" },
{ type: "finish-step", finishReason: "stop", usage: {} },
],
[],
);
const events = await collect(registry, request());
expect(events.filter((e) => e.type === "reasoning_delta")).toHaveLength(1);
const completed = events[events.length - 1];
expect(
completed.type === "completed" ? completed.message.content : undefined,
).toEqual([
{ type: "reasoning", text: "thinking…" },
{ type: "text", text: "done" },
]);
});
it("throws on provider error parts (a model failure, not a completion)", async () => {
const registry = makeRegistry(
[{ type: "error", error: new Error("rate limited") }],
[],
);
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
await expect(
(async () => {
for await (const event of model.stream(request())) {
void event;
}
})(),
).rejects.toThrowError("rate limited");
});
it("encodeMessages produces the LLM-facing wire form (context woven, tool results enveloped)", async () => {
const registry = makeRegistry([], []);
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
const encoded = model.encodeMessages([
{
role: "user",
content: "list my downloads",
userMessageContext: {
currentDateTime: "2026-07-02T10:30:00Z",
middlePane: { kind: "empty" },
},
},
{ role: "tool", content: "[…files…]", toolCallId: "tc1", toolName: "file-list" },
]) as Array<{ role: string; content: unknown }>;
// The user message is the woven wire text, not the internal structure.
expect(encoded[0].role).toBe("user");
const userText = String(encoded[0].content);
expect(userText).toContain("2026-07-02T10:30:00Z");
expect(userText).toContain("list my downloads");
expect(userText).not.toContain("userMessageContext");
// Tool output rides the AI SDK tool-result envelope.
expect(encoded[1]).toMatchObject({
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "tc1",
output: { type: "text", value: "[…files…]" },
},
],
});
});
it("stops promptly when the signal aborts mid-stream", async () => {
const controller = new AbortController();
const registry = makeRegistry(
[
{ type: "text-delta", text: "a" },
{ type: "text-delta", text: "b" },
],
[],
);
const model = await registry.resolve({ provider: "openai", model: "gpt-test" });
const seen: string[] = [];
await expect(
(async () => {
for await (const event of model.stream(
request({ signal: controller.signal }),
)) {
seen.push(event.type);
controller.abort();
}
})(),
).rejects.toThrowError();
expect(seen).toEqual(["text_delta"]);
});
});

View file

@ -0,0 +1,261 @@
import {
jsonSchema,
stepCountIs,
streamText,
tool,
type LanguageModel,
type ModelMessage,
type ToolSet,
} from "ai";
import type { z } from "zod";
import type { LlmProvider } from "@x/shared/dist/models.js";
import type { AssistantContentPart } from "@x/shared/dist/message.js";
import type { JsonValue, ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js";
import { convertFromMessages } from "../../agents/runtime.js";
import { resolveProviderConfig } from "../../models/defaults.js";
import { createProvider } from "../../models/models.js";
import type {
IModelRegistry,
LlmStreamEvent,
ModelStreamRequest,
ResolvedModel,
} from "../model-registry.js";
// Injectable seam over streamText so normalization is testable without a
// provider. The bridge always requests exactly one step.
export type StreamTextInvoker = (options: {
model: LanguageModel;
system: string;
messages: ModelMessage[];
tools: ToolSet;
abortSignal: AbortSignal;
}) => { fullStream: AsyncIterable<unknown> };
const defaultInvoker: StreamTextInvoker = (options) =>
streamText({ ...options, stopWhen: stepCountIs(1) });
export interface RealModelRegistryDeps {
resolveProvider?: (name: string) => Promise<z.infer<typeof LlmProvider>>;
createProviderImpl?: typeof createProvider;
invoke?: StreamTextInvoker;
}
// Bridges models.json provider configs to live AI SDK models and normalizes
// one streamText step into LlmStreamEvents. Tools are declared without
// execute: the turn loop harvests tool calls and runs them itself.
export class RealModelRegistry implements IModelRegistry {
private readonly resolveProvider: (
name: string,
) => Promise<z.infer<typeof LlmProvider>>;
private readonly createProviderImpl: typeof createProvider;
private readonly invoke: StreamTextInvoker;
constructor(deps: RealModelRegistryDeps = {}) {
this.resolveProvider = deps.resolveProvider ?? resolveProviderConfig;
this.createProviderImpl = deps.createProviderImpl ?? createProvider;
this.invoke = deps.invoke ?? defaultInvoker;
}
async resolve(
descriptor: z.infer<typeof ModelDescriptor>,
): Promise<ResolvedModel> {
const providerConfig = await this.resolveProvider(descriptor.provider);
const provider = this.createProviderImpl(providerConfig);
const model = provider.languageModel(descriptor.model);
return {
descriptor,
// The structural -> wire conversion the app uses today: weaves
// userMessageContext into the user text, renders attachments,
// wraps tool output as tool-result parts. Deterministic and
// per-message, so composed requests are byte-stable.
encodeMessages: (messages) =>
convertFromMessages(messages) as unknown as JsonValue[],
stream: (request) => this.run(model, request),
};
}
private async *run(
model: LanguageModel,
request: ModelStreamRequest,
): AsyncGenerator<LlmStreamEvent, void, void> {
const tools: ToolSet = {};
for (const descriptor of request.tools) {
tools[descriptor.name] = tool({
...(descriptor.description
? { description: descriptor.description }
: {}),
inputSchema: jsonSchema(
(descriptor.inputSchema ?? {
type: "object",
properties: {},
}) as Parameters<typeof jsonSchema>[0],
),
});
}
const result = this.invoke({
model,
system: request.systemPrompt,
messages: request.messages as ModelMessage[],
tools,
abortSignal: request.signal,
});
const parts: Array<z.infer<typeof AssistantContentPart>> = [];
let textBuffer = "";
let reasoningBuffer = "";
let finishReason = "unknown";
let usage: z.infer<typeof TurnUsage> = {};
let providerMetadata: JsonValue | undefined;
for await (const raw of result.fullStream) {
request.signal.throwIfAborted();
const event = raw as {
type: string;
text?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
finishReason?: string;
usage?: Record<string, number | undefined>;
providerMetadata?: unknown;
error?: unknown;
};
switch (event.type) {
case "text-start":
textBuffer = "";
yield { type: "step_event", event: { type: "text_start" } };
break;
case "text-delta": {
const delta = event.text ?? "";
textBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "text") {
last.text += delta;
} else {
parts.push({ type: "text", text: delta });
}
yield { type: "text_delta", delta };
break;
}
case "text-end":
yield {
type: "step_event",
event: { type: "text_end", text: textBuffer },
};
break;
case "reasoning-start":
reasoningBuffer = "";
yield { type: "step_event", event: { type: "reasoning_start" } };
break;
case "reasoning-delta": {
const delta = event.text ?? "";
reasoningBuffer += delta;
const last = parts[parts.length - 1];
if (last?.type === "reasoning") {
last.text += delta;
} else {
parts.push({ type: "reasoning", text: delta });
}
yield { type: "reasoning_delta", delta };
break;
}
case "reasoning-end":
yield {
type: "step_event",
event: { type: "reasoning_end", text: reasoningBuffer },
};
break;
case "tool-call": {
const toolCall = {
type: "tool-call" as const,
toolCallId: String(event.toolCallId),
toolName: String(event.toolName),
arguments: event.input,
};
parts.push(toolCall);
yield { type: "step_event", event: { type: "tool_call", toolCall } };
break;
}
case "finish-step": {
finishReason = event.finishReason ?? "unknown";
usage = mapUsage(event.usage);
providerMetadata = toJsonValue(event.providerMetadata);
yield {
type: "step_event",
event: {
type: "finish_step",
finishReason,
usage,
...(providerMetadata === undefined
? {}
: { providerMetadata }),
},
};
break;
}
case "error":
throw event.error instanceof Error
? event.error
: new Error(formatStreamError(event.error));
default:
break;
}
}
yield {
type: "completed",
message: {
role: "assistant",
content: parts.length > 0 ? parts : "",
},
finishReason,
usage,
...(providerMetadata === undefined ? {} : { providerMetadata }),
};
}
}
function mapUsage(
usage: Record<string, number | undefined> | undefined,
): z.infer<typeof TurnUsage> {
const mapped: z.infer<typeof TurnUsage> = {};
if (!usage) {
return mapped;
}
for (const key of [
"inputTokens",
"outputTokens",
"totalTokens",
"reasoningTokens",
"cachedInputTokens",
] as const) {
const value = usage[key];
if (typeof value === "number" && Number.isFinite(value)) {
mapped[key] = value;
}
}
return mapped;
}
function toJsonValue(value: unknown): JsonValue | undefined {
if (value === undefined || value === null) {
return undefined;
}
try {
return JSON.parse(JSON.stringify(value)) as JsonValue;
} catch {
return undefined;
}
}
function formatStreamError(error: unknown): string {
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}

View file

@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import type { getToolPermissionMetadata } from "../../agents/runtime.js";
import { RealPermissionChecker } from "./real-permission-checker.js";
type MetadataFn = typeof getToolPermissionMetadata;
type MetadataCall = {
toolCall: Parameters<MetadataFn>[0];
attachment: Parameters<MetadataFn>[1];
};
function makeChecker(result: Awaited<ReturnType<MetadataFn>> | Error) {
const calls: MetadataCall[] = [];
const checker = new RealPermissionChecker({
getMetadata: (async (toolCall, attachment) => {
calls.push({ toolCall, attachment });
if (result instanceof Error) {
throw result;
}
return result;
}) as MetadataFn,
});
return { checker, calls };
}
const input = {
turnId: "turn-1",
toolCallId: "tc-1",
toolId: "builtin:executeCommand",
toolName: "executeCommand",
input: { command: "rm -rf /" },
};
describe("RealPermissionChecker", () => {
it("gates builtins through getToolPermissionMetadata with empty session grants", async () => {
const metadata = { kind: "command" as const, commandNames: ["rm"] };
const { checker, calls } = makeChecker(metadata);
const result = await checker.check(input);
expect(result).toEqual({ required: true, request: metadata });
expect(calls[0].toolCall).toMatchObject({
type: "tool-call",
toolCallId: "tc-1",
toolName: "executeCommand",
arguments: { command: "rm -rf /" },
});
expect(calls[0].attachment).toEqual({
type: "builtin",
name: "executeCommand",
});
});
it("returns not-required when metadata is null", async () => {
const { checker } = makeChecker(null);
expect(await checker.check(input)).toEqual({ required: false });
});
it("never gates non-builtin tools", async () => {
const { checker, calls } = makeChecker(new Error("must not be called"));
expect(
await checker.check({
...input,
toolId: "mcp:kb:search",
toolName: "search",
}),
).toEqual({ required: false });
expect(calls).toHaveLength(0);
});
it("propagates metadata errors so the loop fails closed", async () => {
const { checker } = makeChecker(new Error("policy exploded"));
await expect(checker.check(input)).rejects.toThrowError("policy exploded");
});
});

View file

@ -0,0 +1,49 @@
import type { JsonValue } from "@x/shared/dist/turns.js";
import { getToolPermissionMetadata } from "../../agents/runtime.js";
import type {
IPermissionChecker,
PermissionCheckAllowed,
PermissionCheckInput,
PermissionCheckRequired,
} from "../permission.js";
export interface RealPermissionCheckerDeps {
getMetadata?: typeof getToolPermissionMetadata;
}
// Bridges the existing deterministic permission rules: only builtins are
// gated (executeCommand via the command allowlist, file tools via workspace
// boundaries and file-access grants). Session-scoped grants are deferred, so
// the session grant inputs are always empty. A thrown metadata error
// propagates: the turn loop fails closed on checker errors.
export class RealPermissionChecker implements IPermissionChecker {
private readonly getMetadata: typeof getToolPermissionMetadata;
constructor(deps: RealPermissionCheckerDeps = {}) {
this.getMetadata = deps.getMetadata ?? getToolPermissionMetadata;
}
async check(
input: PermissionCheckInput,
): Promise<PermissionCheckAllowed | PermissionCheckRequired> {
if (!input.toolId.startsWith("builtin:")) {
return { required: false };
}
const name = input.toolId.slice("builtin:".length);
const metadata = await this.getMetadata(
{
type: "tool-call",
toolCallId: input.toolCallId,
toolName: input.toolName,
arguments: input.input,
},
{ type: "builtin", name },
new Set<string>(), // session-scoped command grants: deferred
[], // session-scoped file grants: deferred
);
if (!metadata) {
return { required: false };
}
return { required: true, request: metadata as JsonValue };
}
}

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import type { classifyToolPermissions } from "../../security/auto-permission-classifier.js";
import { RealPermissionClassifier } from "./real-permission-classifier.js";
type ClassifierFn = typeof classifyToolPermissions;
type ClassifierInput = Parameters<ClassifierFn>[0];
function makeClassifier(
decisions: Awaited<ReturnType<ClassifierFn>> | Error,
) {
const calls: ClassifierInput[] = [];
const classifier = new RealPermissionClassifier({
classifier: (async (input) => {
calls.push(input);
if (decisions instanceof Error) {
throw decisions;
}
return decisions;
}) as ClassifierFn,
});
return { classifier, calls };
}
const batch = {
turnId: "turn-1",
messages: [
{ role: "user" as const, content: "list my downloads" },
{ role: "assistant" as const, content: "sure" },
],
requests: [
{
toolCallId: "tc-1",
toolName: "file-list",
input: { path: "/Users/me/Downloads" },
request: {
kind: "file",
operation: "list",
paths: ["/Users/me/Downloads"],
pathPrefix: "/Users/me/Downloads",
},
},
],
};
describe("RealPermissionClassifier", () => {
it("adapts the batch into classifyToolPermissions candidates with conversation context", async () => {
const { classifier, calls } = makeClassifier([
{ toolCallId: "tc-1", decision: "allow", reason: "user asked for it" },
]);
const decisions = await classifier.classify(batch);
expect(decisions).toEqual([
{ toolCallId: "tc-1", decision: "allow", reason: "user asked for it" },
]);
expect(calls[0].runId).toBe("turn-1");
expect(calls[0].useCase).toBe("copilot_chat");
expect(calls[0].messages).toHaveLength(2);
expect(calls[0].candidates[0]).toMatchObject({
toolCall: {
type: "tool-call",
toolCallId: "tc-1",
toolName: "file-list",
},
permission: { kind: "file", operation: "list" },
});
});
it("returns an empty result for an empty batch without calling the LLM", async () => {
const { classifier, calls } = makeClassifier(new Error("must not be called"));
expect(
await classifier.classify({ ...batch, requests: [] }),
).toEqual([]);
expect(calls).toHaveLength(0);
});
it("omitted decisions surface as missing entries (loop treats them as defer)", async () => {
const { classifier } = makeClassifier([]);
const decisions = await classifier.classify(batch);
expect(decisions).toEqual([]);
});
it("propagates classifier failures (loop normalizes to defer)", async () => {
const { classifier } = makeClassifier(new Error("llm unavailable"));
await expect(classifier.classify(batch)).rejects.toThrowError(
"llm unavailable",
);
});
it("rejects malformed permission metadata via schema parsing", async () => {
const { classifier } = makeClassifier([]);
await expect(
classifier.classify({
...batch,
requests: [{ ...batch.requests[0], request: { kind: "nonsense" } }],
}),
).rejects.toThrowError();
});
});

View file

@ -0,0 +1,57 @@
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import { convertFromMessages } from "../../agents/runtime.js";
import type { UseCase } from "../../analytics/use_case.js";
import { classifyToolPermissions } from "../../security/auto-permission-classifier.js";
import type {
IPermissionClassifier,
PermissionClassification,
PermissionClassificationBatch,
} from "../permission.js";
export interface RealPermissionClassifierDeps {
classifier?: typeof classifyToolPermissions;
useCase?: UseCase;
}
// Bridges the existing LLM auto-permission classifier. The underlying
// classifier only ever answers allow/deny; omitted decisions surface as
// missing entries, which the turn loop records as classification failures
// and treats as defer. A parse/LLM failure throws, which the loop likewise
// normalizes to defer for the whole batch.
export class RealPermissionClassifier implements IPermissionClassifier {
private readonly classifier: typeof classifyToolPermissions;
private readonly useCase: UseCase;
constructor(deps: RealPermissionClassifierDeps = {}) {
this.classifier = deps.classifier ?? classifyToolPermissions;
this.useCase = deps.useCase ?? "copilot_chat";
}
async classify(
batch: PermissionClassificationBatch,
): Promise<PermissionClassification[]> {
if (batch.requests.length === 0) {
return [];
}
const decisions = await this.classifier({
runId: batch.turnId,
agentName: null,
messages: convertFromMessages(batch.messages),
candidates: batch.requests.map((request) => ({
toolCall: {
type: "tool-call",
toolCallId: request.toolCallId,
toolName: request.toolName,
arguments: request.input,
},
permission: ToolPermissionMetadata.parse(request.request),
})),
useCase: this.useCase,
});
return decisions.map((decision) => ({
toolCallId: decision.toolCallId,
decision: decision.decision,
reason: decision.reason,
}));
}
}

View file

@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import type { ToolDescriptor } from "@x/shared/dist/turns.js";
import type { execTool } from "../../application/lib/exec-tool.js";
import type { BuiltinTools } from "../../application/lib/builtin-tools.js";
import type { IAbortRegistry } from "../../runs/abort-registry.js";
import { TurnDependencyError } from "../api.js";
import type { SyncRuntimeTool, ToolExecutionContext } from "../tool-registry.js";
import { RealToolRegistry } from "./real-tool-registry.js";
type ExecCall = {
attachment: Parameters<typeof execTool>[0];
input: Record<string, unknown>;
ctx: NonNullable<Parameters<typeof execTool>[2]>;
};
class FakeAbortRegistry implements IAbortRegistry {
calls: string[] = [];
createForRun(runId: string): AbortSignal {
this.calls.push(`create:${runId}`);
return new AbortController().signal;
}
registerProcess(): void {}
unregisterProcess(): void {}
abort(runId: string): void {
this.calls.push(`abort:${runId}`);
}
forceAbort(): void {}
isAborted(): boolean {
return false;
}
cleanup(runId: string): void {
this.calls.push(`cleanup:${runId}`);
}
}
const fakeBuiltins = {
echo: { description: "Echo", inputSchema: {}, execute: async () => null },
} as unknown as typeof BuiltinTools;
function descriptor(
overrides: Partial<z.infer<typeof ToolDescriptor>> = {},
): z.infer<typeof ToolDescriptor> {
return {
toolId: "builtin:echo",
name: "echo",
description: "Echo",
inputSchema: {},
execution: "sync",
requiresHuman: false,
...overrides,
};
}
function makeCtx(overrides: Partial<ToolExecutionContext> = {}): ToolExecutionContext & {
progress: unknown[];
} {
const progress: unknown[] = [];
return {
turnId: "turn-1",
toolCallId: "tc-1",
signal: new AbortController().signal,
reportProgress: async (p) => {
progress.push(p);
},
progress,
...overrides,
};
}
function makeRegistry(execImpl: (call: ExecCall) => Promise<unknown>) {
const calls: ExecCall[] = [];
const abortRegistry = new FakeAbortRegistry();
const registry = new RealToolRegistry({
execToolImpl: (async (attachment, input, ctx) => {
const call = { attachment, input, ctx: ctx! };
calls.push(call);
return execImpl(call);
}) as typeof execTool,
abortRegistry,
builtins: fakeBuiltins,
});
return { registry, calls, abortRegistry };
}
describe("RealToolRegistry", () => {
it("executes builtins through execTool with a turn-scoped ToolContext", async () => {
const { registry, calls, abortRegistry } = makeRegistry(async () => ({ ok: 1 }));
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
const ctx = makeCtx();
const result = await tool.execute({ text: "hi" }, ctx);
expect(result).toEqual({ output: { ok: 1 }, isError: false });
expect(calls[0].attachment).toEqual({ type: "builtin", name: "echo" });
expect(calls[0].input).toEqual({ text: "hi" });
expect(calls[0].ctx).toMatchObject({ runId: "turn-1", toolCallId: "tc-1" });
// Abort registry bracketed per call.
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
});
it("normalizes undefined results to null and serializes objects", async () => {
const { registry } = makeRegistry(async () => undefined);
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
expect(await tool.execute({}, makeCtx())).toEqual({
output: null,
isError: false,
});
});
it("forwards tool-output-stream publishes as progress", async () => {
const { registry } = makeRegistry(async ({ ctx }) => {
await ctx.publish({
runId: "turn-1",
type: "tool-output-stream",
toolCallId: "tc-1",
toolName: "echo",
output: "chunk-1",
subflow: [],
});
return "done";
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
const ctx = makeCtx();
await tool.execute({}, ctx);
expect(ctx.progress).toEqual([{ kind: "tool-output", chunk: "chunk-1" }]);
});
it("wires the abort signal to the registry's force-kill path", async () => {
const controller = new AbortController();
const { registry, abortRegistry } = makeRegistry(async () => {
controller.abort();
return "late";
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
await tool.execute({}, makeCtx({ signal: controller.signal }));
expect(abortRegistry.calls).toEqual([
"create:turn-1",
"abort:turn-1",
"cleanup:turn-1",
]);
});
it("lets tool errors propagate (the loop converts them to error results) and still cleans up", async () => {
const { registry, abortRegistry } = makeRegistry(async () => {
throw new Error("tool exploded");
});
const tool = (await registry.resolve(descriptor())) as SyncRuntimeTool;
await expect(tool.execute({}, makeCtx())).rejects.toThrowError("tool exploded");
expect(abortRegistry.calls).toEqual(["create:turn-1", "cleanup:turn-1"]);
});
it("resolves mcp descriptors into mcp attachments (server:tool split on first colon)", async () => {
const { registry, calls } = makeRegistry(async () => "mcp result");
const tool = (await registry.resolve(
descriptor({
toolId: "mcp:kb:search:advanced",
name: "search:advanced",
description: "Search KB",
inputSchema: { type: "object" },
}),
)) as SyncRuntimeTool;
await tool.execute({ q: "x" }, makeCtx());
expect(calls[0].attachment).toEqual({
type: "mcp",
name: "search:advanced",
mcpServerName: "kb",
description: "Search KB",
inputSchema: { type: "object" },
});
});
it("resolves ask-human as an async tool with no executor", async () => {
const { registry } = makeRegistry(async () => null);
const tool = await registry.resolve(
descriptor({
toolId: "builtin:ask-human",
name: "ask-human",
execution: "async",
requiresHuman: true,
}),
);
expect("execute" in tool).toBe(false);
expect(tool.descriptor.execution).toBe("async");
});
it("rejects unknown builtins and malformed toolIds as dependency errors", async () => {
const { registry } = makeRegistry(async () => null);
await expect(
registry.resolve(descriptor({ toolId: "builtin:ghost", name: "ghost" })),
).rejects.toThrowError(TurnDependencyError);
await expect(
registry.resolve(descriptor({ toolId: "mcp:onlyserver" })),
).rejects.toThrowError(TurnDependencyError);
await expect(
registry.resolve(descriptor({ toolId: "weird:scheme" })),
).rejects.toThrowError(TurnDependencyError);
});
});

View file

@ -0,0 +1,141 @@
import type { z } from "zod";
import type { ToolAttachment } from "@x/shared/dist/agent.js";
import type { JsonValue, ToolDescriptor } from "@x/shared/dist/turns.js";
import { execTool } from "../../application/lib/exec-tool.js";
import { BuiltinTools } from "../../application/lib/builtin-tools.js";
import {
type IAbortRegistry,
InMemoryAbortRegistry,
} from "../../runs/abort-registry.js";
import { TurnDependencyError } from "../api.js";
import type {
IToolRegistry,
RuntimeTool,
SyncRuntimeTool,
ToolExecutionContext,
} from "../tool-registry.js";
import { ASK_HUMAN_TOOL } from "./real-agent-resolver.js";
export interface RealToolRegistryDeps {
execToolImpl?: typeof execTool;
abortRegistry?: IAbortRegistry;
builtins?: typeof BuiltinTools;
}
// Bridges persisted tool descriptors to the existing dispatch: builtins via
// the BuiltinTools catalog, MCP tools via execTool's MCP path. toolId encodes
// the attachment: "builtin:<name>" or "mcp:<server>:<tool>". ask-human is the
// async human-dependent tool with no in-process executor.
export class RealToolRegistry implements IToolRegistry {
private readonly execToolImpl: typeof execTool;
private readonly abortRegistry: IAbortRegistry;
private readonly builtins: typeof BuiltinTools;
constructor(deps: RealToolRegistryDeps = {}) {
this.execToolImpl = deps.execToolImpl ?? execTool;
this.abortRegistry = deps.abortRegistry ?? new InMemoryAbortRegistry();
this.builtins = deps.builtins ?? BuiltinTools;
}
async resolve(
descriptor: z.infer<typeof ToolDescriptor>,
): Promise<RuntimeTool> {
if (descriptor.toolId === `builtin:${ASK_HUMAN_TOOL}`) {
return {
descriptor: descriptor as { execution: "async" } & z.infer<
typeof ToolDescriptor
>,
};
}
if (descriptor.toolId.startsWith("builtin:")) {
const name = descriptor.toolId.slice("builtin:".length);
const builtin = this.builtins[name];
if (!builtin?.execute) {
throw new TurnDependencyError(
`no live builtin tool for ${descriptor.toolId}`,
);
}
return this.syncTool(descriptor, { type: "builtin", name });
}
if (descriptor.toolId.startsWith("mcp:")) {
const rest = descriptor.toolId.slice("mcp:".length);
const separator = rest.indexOf(":");
if (separator <= 0 || separator === rest.length - 1) {
throw new TurnDependencyError(
`malformed mcp toolId: ${descriptor.toolId}`,
);
}
return this.syncTool(descriptor, {
type: "mcp",
name: rest.slice(separator + 1),
mcpServerName: rest.slice(0, separator),
description: descriptor.description,
inputSchema: descriptor.inputSchema,
});
}
throw new TurnDependencyError(
`unrecognized toolId scheme: ${descriptor.toolId}`,
);
}
private syncTool(
descriptor: z.infer<typeof ToolDescriptor>,
attachment: z.infer<typeof ToolAttachment>,
): SyncRuntimeTool {
return {
descriptor: descriptor as { execution: "sync" } & z.infer<
typeof ToolDescriptor
>,
execute: async (input, ctx: ToolExecutionContext) => {
// AbortSignal is the primary kill path; the abort registry is
// the secondary force-kill for spawned child processes,
// bracketed per call and keyed by turn.
this.abortRegistry.createForRun(ctx.turnId);
const onAbort = () => this.abortRegistry.abort(ctx.turnId);
ctx.signal.addEventListener("abort", onAbort, { once: true });
try {
const value = await this.execToolImpl(
attachment,
asArgs(input),
{
runId: ctx.turnId,
toolCallId: ctx.toolCallId,
signal: ctx.signal,
abortRegistry: this.abortRegistry,
publish: async (event) => {
if (event.type === "tool-output-stream") {
await ctx.reportProgress({
kind: "tool-output",
chunk: event.output,
});
}
},
},
);
return {
output: toJsonValue(value === undefined ? null : value),
isError: false,
};
} finally {
ctx.signal.removeEventListener("abort", onAbort);
this.abortRegistry.cleanup(ctx.turnId);
}
},
};
}
}
function asArgs(input: unknown): Record<string, unknown> {
return input && typeof input === "object"
? (input as Record<string, unknown>)
: {};
}
function toJsonValue(value: unknown): JsonValue {
try {
const parsed: unknown = JSON.parse(JSON.stringify(value));
return parsed === undefined ? null : (parsed as JsonValue);
} catch {
return String(value);
}
}

View file

@ -0,0 +1,22 @@
import { captureLlmUsage } from "../../analytics/usage.js";
import { getCurrentUseCase } from "../../analytics/use_case.js";
import type { IUsageReporter, ModelUsageReport } from "../usage-reporter.js";
// Reports each completed model call as the same `llm_usage` PostHog event
// the old run loop emitted. The use case comes from the AsyncLocalStorage
// context the caller established (headless runners wrap startHeadlessAgent
// in withUseCase); UI-driven session turns have no context and default to
// copilot_chat — matching the old createRun default.
export class RealUsageReporter implements IUsageReporter {
reportModelUsage(report: ModelUsageReport): void {
const context = getCurrentUseCase();
captureLlmUsage({
useCase: context?.useCase ?? "copilot_chat",
...(context?.subUseCase ? { subUseCase: context.subUseCase } : {}),
agentName: report.agentId,
model: report.model.model,
provider: report.model.provider,
usage: report.usage,
});
}
}

View file

@ -0,0 +1,39 @@
// Ephemeral process-lifecycle events (turn-runtime-design.md §17). Never
// persisted, never replayed; if the process crashes the information
// disappears, which accurately reflects that no execution is known active.
export interface TurnProcessingStart {
type: "turn-processing-start";
turnId: string;
}
export interface TurnProcessingEnd {
type: "turn-processing-end";
turnId: string;
}
export type TurnLifecycleEvent = TurnProcessingStart | TurnProcessingEnd;
export interface ITurnLifecycleBus {
publish(event: TurnLifecycleEvent): void;
}
// Default in-process fan-out. Observers must never affect turn semantics, so
// listener errors are swallowed.
export class EmitterTurnLifecycleBus implements ITurnLifecycleBus {
private listeners = new Set<(event: TurnLifecycleEvent) => void>();
publish(event: TurnLifecycleEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// observational only
}
}
}
subscribe(listener: (event: TurnLifecycleEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}

View file

@ -0,0 +1,11 @@
// Deterministic timestamp seam. No IClock existed in the codebase before the
// turn runtime; production uses SystemClock, tests inject fixed clocks.
export interface IClock {
now(): string; // ISO-8601 timestamp
}
export class SystemClock implements IClock {
now(): string {
return new Date().toISOString();
}
}

View file

@ -0,0 +1,68 @@
import type { z } from "zod";
import {
type ConversationMessage,
type JsonValue,
type ResolvedAgent,
type ToolDescriptor,
type TurnState,
requestMessagesFor,
} from "@x/shared/dist/turns.js";
import type { IContextResolver } from "./context-resolver.js";
// The exact provider payload for one model call, rebuilt deterministically
// from durable state (turn-runtime-design.md §8.3):
// - systemPrompt and tools come from the resolved agent snapshot (their
// single canonical copy in turn_created),
// - messages are the cross-turn prefix plus every request's reference list
// resolved against the turn's own events, encoded to wire form.
// This is the SAME code path the loop sends through, so the debug view and
// the transmitted bytes cannot diverge.
export interface ComposedModelRequest {
systemPrompt: string;
messages: JsonValue[];
tools: Array<z.infer<typeof ToolDescriptor>>;
parameters: Record<string, JsonValue>;
}
export function composeModelRequest(
state: TurnState,
modelCallIndex: number,
// The materialized cross-turn prefix (contextResolver output). Ignored
// for inline-context turns, whose context rides the {context} ref.
resolvedPrefix: Array<z.infer<typeof ConversationMessage>>,
// The materialized agent snapshot (contextResolver.resolveAgent output —
// inherited snapshots must be resolved before composing).
agent: z.infer<typeof ResolvedAgent>,
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => JsonValue[],
): ComposedModelRequest {
const call = state.modelCalls[modelCallIndex];
if (!call) {
throw new Error(`no model call at index ${modelCallIndex}`);
}
const prefix = Array.isArray(state.definition.context) ? [] : resolvedPrefix;
const structural = [...prefix];
for (let index = 0; index <= modelCallIndex; index++) {
structural.push(...requestMessagesFor(state, index));
}
return {
systemPrompt: agent.systemPrompt,
messages: encode(structural),
tools: agent.tools,
parameters: call.request.parameters,
};
}
// Debug/materialization convenience: compose from durable state alone,
// resolving the cross-turn prefix through the context resolver.
export async function materializeModelRequest(
state: TurnState,
modelCallIndex: number,
contextResolver: IContextResolver,
encode: (messages: Array<z.infer<typeof ConversationMessage>>) => JsonValue[],
): Promise<ComposedModelRequest> {
const prefix = await contextResolver.resolve(state.definition.context);
const agent = await contextResolver.resolveAgent(
state.definition.agent.resolved,
);
return composeModelRequest(state, modelCallIndex, prefix, agent, encode);
}

View file

@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import type { z } from "zod";
import {
MODEL_CALL_LIMIT_ERROR_CODE,
type TurnContext,
TurnCorruptionError,
type TurnEvent,
} from "@x/shared/dist/turns.js";
import { TurnRepoContextResolver } from "./context-resolver.js";
import { InMemoryTurnRepo } from "./in-memory-turn-repo.js";
type TEvent = z.infer<typeof TurnEvent>;
function user(text: string) {
return { role: "user" as const, content: text };
}
function assistant(text: string) {
return { role: "assistant" as const, content: text };
}
// A minimal completed turn: input → one model call → text response.
function completedTurnLog(
turnId: string,
context: z.infer<typeof TurnContext>,
inputText: string,
responseText: string,
): TEvent[] {
const ts = "2026-07-02T10:00:00Z";
return [
{
type: "turn_created",
schemaVersion: 1,
turnId,
ts,
sessionId: "sess-1",
agent: {
requested: { agentId: "copilot" },
resolved: {
agentId: "copilot",
systemPrompt: "SYS",
model: { provider: "fake", model: "m" },
tools: [],
},
},
context,
input: user(inputText),
config: {
autoPermission: false,
humanAvailable: true,
maxModelCalls: 20,
},
},
{
type: "model_call_requested",
turnId,
ts,
modelCallIndex: 0,
request: {
...(Array.isArray(context) ? {} : { contextRef: context }),
messages:
Array.isArray(context) && context.length > 0
? ["context", "input"]
: ["input"],
parameters: {},
},
},
{
type: "model_call_completed",
turnId,
ts,
modelCallIndex: 0,
message: assistant(responseText),
finishReason: "stop",
usage: {},
},
{
type: "turn_completed",
turnId,
ts,
output: assistant(responseText),
finishReason: "stop",
usage: {},
},
];
}
// A turn that failed at the model-call limit after one tool round trip; its
// transcript is structurally complete including the synthetic closure.
function limitFailedTurnLog(turnId: string): TEvent[] {
const ts = "2026-07-02T10:00:00Z";
const echo = {
toolId: "tool.echo",
name: "echo",
description: "Echo",
inputSchema: {},
execution: "sync" as const,
requiresHuman: false,
};
const call = {
role: "assistant" as const,
content: [
{
type: "tool-call" as const,
toolCallId: "tc1",
toolName: "echo",
arguments: {},
},
],
};
return [
{
type: "turn_created",
schemaVersion: 1,
turnId,
ts,
sessionId: "sess-1",
agent: {
requested: { agentId: "copilot" },
resolved: {
agentId: "copilot",
systemPrompt: "SYS",
model: { provider: "fake", model: "m" },
tools: [echo],
},
},
context: [],
input: user("do it"),
config: {
autoPermission: false,
humanAvailable: true,
maxModelCalls: 1,
},
},
{
type: "model_call_requested",
turnId,
ts,
modelCallIndex: 0,
request: {
messages: ["input"],
parameters: {},
},
},
{
type: "model_call_completed",
turnId,
ts,
modelCallIndex: 0,
message: call,
finishReason: "tool-calls",
usage: {},
},
{
type: "tool_invocation_requested",
turnId,
ts,
toolCallId: "tc1",
toolId: "tool.echo",
toolName: "echo",
execution: "sync",
input: {},
},
{
type: "tool_result",
turnId,
ts,
toolCallId: "tc1",
toolName: "echo",
source: "sync",
result: { output: "echoed", isError: false },
},
{
type: "turn_failed",
turnId,
ts,
error: "Model call limit of 1 reached before the turn completed.",
code: MODEL_CALL_LIMIT_ERROR_CODE,
usage: {},
},
];
}
const T1 = "2026-07-02T10-00-00Z-0000001-000";
const T2 = "2026-07-02T10-00-00Z-0000002-000";
const T3 = "2026-07-02T10-00-00Z-0000003-000";
describe("TurnRepoContextResolver", () => {
it("passes inline context through unchanged", async () => {
const repo = new InMemoryTurnRepo();
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
const inline = [user("a"), assistant("b")];
expect(await resolver.resolve(inline)).toEqual(inline);
});
it("resolves a single reference to the referenced turn's transcript", async () => {
const repo = new InMemoryTurnRepo();
repo.seed(completedTurnLog(T1, [], "first question", "first answer"));
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
expect(await resolver.resolve({ previousTurnId: T1 })).toEqual([
user("first question"),
assistant("first answer"),
]);
});
it("resolves a chain of references down to the inline base", async () => {
const repo = new InMemoryTurnRepo();
repo.seed(completedTurnLog(T1, [user("preamble")], "q1", "a1"));
repo.seed(completedTurnLog(T2, { previousTurnId: T1 }, "q2", "a2"));
repo.seed(completedTurnLog(T3, { previousTurnId: T2 }, "q3", "a3"));
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
expect(await resolver.resolve({ previousTurnId: T3 })).toEqual([
user("preamble"),
user("q1"),
assistant("a1"),
user("q2"),
assistant("a2"),
user("q3"),
assistant("a3"),
]);
});
it("includes failed turns' transcripts with synthetic closures", async () => {
const repo = new InMemoryTurnRepo();
repo.seed(limitFailedTurnLog(T1));
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
const resolved = await resolver.resolve({ previousTurnId: T1 });
expect(resolved.map((m) => m.role)).toEqual(["user", "assistant", "tool"]);
expect(resolved[2]).toMatchObject({
role: "tool",
toolCallId: "tc1",
content: "echoed",
});
});
it("rejects references to missing turns as infrastructure errors", async () => {
const repo = new InMemoryTurnRepo();
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
await expect(
resolver.resolve({ previousTurnId: T1 }),
).rejects.toThrowError(/turn not found/);
});
it("resolveAgent materializes inherited snapshots through the chain", async () => {
const repo = new InMemoryTurnRepo();
repo.seed(completedTurnLog(T1, [], "q1", "a1")); // concrete base
const inheritedLog = completedTurnLog(T2, { previousTurnId: T1 }, "q2", "a2").map(
(event) =>
event.type === "turn_created"
? {
...event,
agent: {
...event.agent,
resolved: {
agentId: "copilot",
model: { provider: "fake", model: "m" },
inheritedFrom: T1,
},
},
}
: event,
);
repo.seed(inheritedLog);
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
const concrete = await resolver.resolveAgent({
agentId: "copilot",
systemPrompt: "SYS",
model: { provider: "fake", model: "m" },
tools: [],
});
expect(concrete.systemPrompt).toBe("SYS"); // passthrough
const materialized = await resolver.resolveAgent({
agentId: "copilot",
model: { provider: "fake", model: "m2" },
inheritedFrom: T2, // hops T2 -> T1
});
expect(materialized.systemPrompt).toBe("SYS");
expect(materialized.tools).toEqual([]);
// The inherited record's own model wins over the chain base's model.
expect(materialized.model).toEqual({ provider: "fake", model: "m2" });
});
it("resolveAgent rejects cyclic inheritance as corruption", async () => {
const repo = new InMemoryTurnRepo();
const cyclic = completedTurnLog(T1, [], "q1", "a1").map((event) =>
event.type === "turn_created"
? {
...event,
context: { previousTurnId: T1 },
agent: {
...event.agent,
resolved: {
agentId: "copilot",
model: { provider: "fake", model: "m" },
inheritedFrom: T1,
},
},
}
: event,
);
// Fix the request contextRef to match the now-ref context.
const fixed = cyclic.map((event) =>
event.type === "model_call_requested"
? { ...event, request: { ...event.request, contextRef: { previousTurnId: T1 } } }
: event,
);
repo.seed(fixed);
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
await expect(
resolver.resolveAgent({
agentId: "copilot",
model: { provider: "fake", model: "m" },
inheritedFrom: T1,
}),
).rejects.toThrowError(TurnCorruptionError);
});
it("rejects cyclic reference chains as corruption", async () => {
const repo = new InMemoryTurnRepo();
// Two turns referencing each other (only constructable by corruption).
repo.seed(completedTurnLog(T1, { previousTurnId: T2 }, "q1", "a1"));
repo.seed(completedTurnLog(T2, { previousTurnId: T1 }, "q2", "a2"));
const resolver = new TurnRepoContextResolver({ turnRepo: repo });
await expect(
resolver.resolve({ previousTurnId: T1 }),
).rejects.toThrowError(TurnCorruptionError);
});
});

View file

@ -0,0 +1,88 @@
import type { z } from "zod";
import {
type ConversationMessage,
type ResolvedAgent,
type ResolvedAgentSnapshot,
type TurnContext,
TurnCorruptionError,
isInheritedSnapshot,
reduceTurn,
turnTranscript,
} from "@x/shared/dist/turns.js";
import type { ITurnRepo } from "./repo.js";
// Materializes a turn's context (turn-runtime-design.md §6.6). Inline
// contexts pass through; references resolve to the referenced turn's full
// transcript by walking the chain down to its inline base. Resolution always
// reads durable state, so normal execution and crash recovery share one
// path. A missing or corrupt referenced turn is an infrastructure error.
export interface IContextResolver {
resolve(
context: z.infer<typeof TurnContext>,
): Promise<Array<z.infer<typeof ConversationMessage>>>;
// Materializes an inherited agent snapshot by walking inheritedFrom to
// the nearest concrete snapshot (same discipline as context references:
// deterministic, from durable state, cycle-checked).
resolveAgent(
resolved: z.infer<typeof ResolvedAgentSnapshot>,
): Promise<z.infer<typeof ResolvedAgent>>;
}
export class TurnRepoContextResolver implements IContextResolver {
private readonly turnRepo: ITurnRepo;
constructor({ turnRepo }: { turnRepo: ITurnRepo }) {
this.turnRepo = turnRepo;
}
async resolve(
context: z.infer<typeof TurnContext>,
): Promise<Array<z.infer<typeof ConversationMessage>>> {
// Walk the reference chain back to the inline base, then concatenate
// transcripts oldest-first. Iterative to bound stack depth; a visited
// set catches cyclic (corrupt) chains.
const segments: Array<Array<z.infer<typeof ConversationMessage>>> = [];
const visited = new Set<string>();
let current = context;
while (!Array.isArray(current)) {
const turnId = current.previousTurnId;
if (visited.has(turnId)) {
throw new TurnCorruptionError(
`cyclic context reference chain at turn ${turnId}`,
);
}
visited.add(turnId);
const events = await this.turnRepo.read(turnId);
const state = reduceTurn(events);
segments.push(turnTranscript(state));
current = state.definition.context;
}
segments.push(current);
segments.reverse();
return segments.flat();
}
async resolveAgent(
resolved: z.infer<typeof ResolvedAgentSnapshot>,
): Promise<z.infer<typeof ResolvedAgent>> {
if (!isInheritedSnapshot(resolved)) {
return resolved;
}
const visited = new Set<string>();
let current: z.infer<typeof ResolvedAgentSnapshot> = resolved;
while (isInheritedSnapshot(current)) {
const turnId = current.inheritedFrom;
if (visited.has(turnId)) {
throw new TurnCorruptionError(
`cyclic agent snapshot inheritance at turn ${turnId}`,
);
}
visited.add(turnId);
const events = await this.turnRepo.read(turnId);
current = reduceTurn(events).definition.agent.resolved;
}
// Only the heavy fields inherit; the turn's own concrete identity
// (agentId, model — a mid-session model switch) always wins.
return { ...current, agentId: resolved.agentId, model: resolved.model };
}
}

View file

@ -0,0 +1,203 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { z } from "zod";
import {
TurnCorruptionError,
TurnCreated,
TurnEvent,
} from "@x/shared/dist/turns.js";
import { FSTurnRepo } from "./fs-repo.js";
const TURN_ID = "2026-07-02T10-00-00Z-0000001-000";
function created(turnId = TURN_ID): z.infer<typeof TurnCreated> {
return {
type: "turn_created",
schemaVersion: 1,
turnId,
ts: "2026-07-02T10:00:00Z",
sessionId: null,
agent: {
requested: { agentId: "copilot" },
resolved: {
agentId: "copilot",
systemPrompt: "SYS",
model: { provider: "fake", model: "m" },
tools: [],
},
},
context: [],
input: { role: "user", content: "hello" },
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 20 },
};
}
function requested(turnId = TURN_ID): z.infer<typeof TurnEvent> {
return {
type: "model_call_requested",
turnId,
ts: "2026-07-02T10:00:01Z",
modelCallIndex: 0,
request: {
messages: ["input"],
parameters: {},
},
};
}
function failed(turnId = TURN_ID): z.infer<typeof TurnEvent> {
return {
type: "model_call_failed",
turnId,
ts: "2026-07-02T10:00:02Z",
modelCallIndex: 0,
error: "boom",
};
}
describe("FSTurnRepo", () => {
let root: string;
let repo: FSTurnRepo;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "turn-repo-"));
repo = new FSTurnRepo({ turnsRootDir: root });
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});
it("writes to a deterministic date-partitioned path", async () => {
await repo.create(created());
const file = path.join(root, "2026", "07", "02", `${TURN_ID}.jsonl`);
const raw = await fs.readFile(file, "utf8");
expect(raw.endsWith("\n")).toBe(true);
expect(JSON.parse(raw.trim()).type).toBe("turn_created");
});
it("rejects malformed and path-like turn ids", async () => {
for (const bad of [
"../../../etc/passwd",
"2026-07-02T10/evil",
"2026-07-02T10-00-00Z-0000001-000.jsonl",
"not-a-turn-id",
"",
]) {
await expect(repo.read(bad)).rejects.toThrowError(/invalid turn id/);
}
});
it("create fails if the turn already exists", async () => {
await repo.create(created());
await expect(repo.create(created())).rejects.toThrowError();
});
it("appends preserve order and read validates every line", async () => {
await repo.create(created());
await repo.append(TURN_ID, [requested()]);
await repo.append(TURN_ID, [failed()]);
const events = await repo.read(TURN_ID);
expect(events.map((e) => e.type)).toEqual([
"turn_created",
"model_call_requested",
"model_call_failed",
]);
});
it("append validates events and turn id match before writing", async () => {
await repo.create(created());
await expect(
repo.append(TURN_ID, [requested("2026-07-02T10-00-00Z-0000002-000")]),
).rejects.toThrowError(/does not match/);
await expect(
repo.append(TURN_ID, [{ type: "wat" } as unknown as z.infer<typeof TurnEvent>]),
).rejects.toThrowError();
// Nothing was written by the failed appends.
expect((await repo.read(TURN_ID)).length).toBe(1);
});
it("append never creates a missing turn file", async () => {
await expect(repo.append(TURN_ID, [requested()])).rejects.toThrowError(
/turn not found/,
);
});
it("reading a missing turn reports not found", async () => {
await expect(repo.read(TURN_ID)).rejects.toThrowError(/turn not found/);
});
async function writeRaw(content: string): Promise<void> {
const file = path.join(root, "2026", "07", "02", `${TURN_ID}.jsonl`);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content);
}
it("rejects an empty file as corrupt", async () => {
await writeRaw("");
await expect(repo.read(TURN_ID)).rejects.toThrowError(TurnCorruptionError);
});
it("rejects malformed first, middle, and final lines", async () => {
const good = JSON.stringify(created());
const req = JSON.stringify(requested());
for (const content of [
`not json\n${req}\n`,
`${good}\nnot json\n${req}\n`,
`${good}\n{"type":"wat"}\n`,
]) {
await writeRaw(content);
await expect(repo.read(TURN_ID)).rejects.toThrowError(
TurnCorruptionError,
);
}
});
it("rejects a torn final line (no trailing newline)", async () => {
const good = JSON.stringify(created());
const torn = JSON.stringify(requested()).slice(0, 20);
await writeRaw(`${good}\n${torn}`);
await expect(repo.read(TURN_ID)).rejects.toThrowError(
/does not end with a complete line/,
);
});
it("rejects unsupported schema versions on read", async () => {
const v2 = { ...created(), schemaVersion: 2 };
await writeRaw(`${JSON.stringify(v2)}\n`);
await expect(repo.read(TURN_ID)).rejects.toThrowError(TurnCorruptionError);
});
it("rejects events whose turnId does not match the file", async () => {
const other = created("2026-07-02T10-00-00Z-0000002-000");
await writeRaw(`${JSON.stringify(other)}\n`);
await expect(repo.read(TURN_ID)).rejects.toThrowError(/does not match file/);
});
it("withLock serializes work per turn", async () => {
const order: string[] = [];
await Promise.all([
repo.withLock(TURN_ID, async () => {
order.push("a-start");
await new Promise((r) => setTimeout(r, 20));
order.push("a-end");
}),
repo.withLock(TURN_ID, async () => {
order.push("b-start");
order.push("b-end");
}),
]);
expect(order).toEqual(["a-start", "a-end", "b-start", "b-end"]);
});
it("withLock releases after failures", async () => {
await expect(
repo.withLock(TURN_ID, async () => {
throw new Error("first fails");
}),
).rejects.toThrowError("first fails");
await expect(repo.withLock(TURN_ID, async () => "ok")).resolves.toBe("ok");
});
});

View file

@ -0,0 +1,128 @@
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
TurnCorruptionError,
TurnCreated,
TurnEvent,
} from "@x/shared/dist/turns.js";
import { KeyedMutex } from "./keyed-mutex.js";
import type { ITurnRepo } from "./repo.js";
// Turn IDs come from IMonotonicallyIncreasingIdGenerator and look like
// 2025-11-11T04-36-29Z-0001234-000. The repo validates the format before
// deriving a path and rejects anything path-like.
const TURN_ID_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T[A-Za-z0-9-]+$/;
export class FSTurnRepo implements ITurnRepo {
private readonly rootDir: string;
private readonly mutex = new KeyedMutex();
constructor({ turnsRootDir }: { turnsRootDir: string }) {
this.rootDir = turnsRootDir;
}
private filePath(turnId: string): string {
const match = TURN_ID_PATTERN.exec(turnId);
if (!match) {
throw new Error(`invalid turn id: ${turnId}`);
}
const [, year, month, day] = match;
return path.join(this.rootDir, year, month, day, `${turnId}.jsonl`);
}
private serialize(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
): string {
let payload = "";
for (const event of events) {
const parsed = TurnEvent.parse(event);
if (parsed.turnId !== turnId) {
throw new Error(
`event turnId ${parsed.turnId} does not match ${turnId}`,
);
}
payload += `${JSON.stringify(parsed)}\n`;
}
return payload;
}
async create(event: z.infer<typeof TurnCreated>): Promise<void> {
const parsed = TurnCreated.parse(event);
const file = this.filePath(parsed.turnId);
await fs.mkdir(path.dirname(file), { recursive: true });
// "wx" fails if the file already exists.
await fs.writeFile(file, this.serialize(parsed.turnId, [parsed]), {
flag: "wx",
});
}
async read(turnId: string): Promise<Array<z.infer<typeof TurnEvent>>> {
const file = this.filePath(turnId);
let raw: string;
try {
raw = await fs.readFile(file, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`turn not found: ${turnId}`);
}
throw error;
}
if (raw.length === 0) {
throw new TurnCorruptionError(`turn file is empty: ${turnId}`);
}
const lines = raw.split("\n");
// A well-formed file ends with a newline, leaving one trailing empty
// segment. Anything else (including a torn final line) is corrupt.
const trailing = lines.pop();
if (trailing !== "") {
throw new TurnCorruptionError(
`turn file does not end with a complete line: ${turnId}`,
);
}
const events: Array<z.infer<typeof TurnEvent>> = [];
for (const [index, line] of lines.entries()) {
let parsed: z.infer<typeof TurnEvent>;
try {
parsed = TurnEvent.parse(JSON.parse(line));
} catch (error) {
throw new TurnCorruptionError(
`malformed turn event at ${turnId}:${index + 1}: ${String(
error instanceof Error ? error.message : error,
)}`,
);
}
if (parsed.turnId !== turnId) {
throw new TurnCorruptionError(
`event turnId ${parsed.turnId} does not match file ${turnId}`,
);
}
events.push(parsed);
}
return events;
}
async append(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
): Promise<void> {
if (events.length === 0) {
return;
}
const payload = this.serialize(turnId, events);
const file = this.filePath(turnId);
// Appends must never create a file: a turn exists only via create().
// All writers hold the per-turn lock, so check-then-append is safe.
try {
await fs.access(file);
} catch {
throw new Error(`turn not found: ${turnId}`);
}
await fs.appendFile(file, payload);
}
async withLock<T>(turnId: string, fn: () => Promise<T>): Promise<T> {
return this.mutex.run(turnId, fn);
}
}

View file

@ -0,0 +1,62 @@
import { z } from "zod";
import { TurnCreated, TurnEvent } from "@x/shared/dist/turns.js";
import { KeyedMutex } from "./keyed-mutex.js";
import type { ITurnRepo } from "./repo.js";
// Test fake mirroring FSTurnRepo semantics (create-if-absent, validate on
// write and read boundaries, per-turn locking) without touching disk.
export class InMemoryTurnRepo implements ITurnRepo {
private files = new Map<string, Array<z.infer<typeof TurnEvent>>>();
private mutex = new KeyedMutex();
async create(event: z.infer<typeof TurnCreated>): Promise<void> {
const parsed = TurnCreated.parse(event);
if (this.files.has(parsed.turnId)) {
throw new Error(`turn already exists: ${parsed.turnId}`);
}
this.files.set(parsed.turnId, [structuredClone(parsed)]);
}
async read(turnId: string): Promise<Array<z.infer<typeof TurnEvent>>> {
const events = this.files.get(turnId);
if (!events) {
throw new Error(`turn not found: ${turnId}`);
}
return structuredClone(events);
}
async append(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
): Promise<void> {
const file = this.files.get(turnId);
if (!file) {
throw new Error(`turn not found: ${turnId}`);
}
for (const event of events) {
const parsed = TurnEvent.parse(event);
if (parsed.turnId !== turnId) {
throw new Error(
`event turnId ${parsed.turnId} does not match ${turnId}`,
);
}
file.push(structuredClone(parsed));
}
}
async withLock<T>(turnId: string, fn: () => Promise<T>): Promise<T> {
return this.mutex.run(turnId, fn);
}
// Test helper: seed a raw event log (validated) for recovery scenarios.
seed(events: Array<z.infer<typeof TurnEvent>>): void {
if (events.length === 0) {
throw new Error("cannot seed an empty log");
}
const turnId = events[0].turnId;
this.files.set(
turnId,
events.map((e) => structuredClone(TurnEvent.parse(e))),
);
}
}

View file

@ -0,0 +1,16 @@
export * from "./api.js";
export * from "./agent-resolver.js";
export * from "./bus.js";
export * from "./clock.js";
export * from "./compose-model-request.js";
export * from "./context-resolver.js";
export * from "./fs-repo.js";
export * from "./in-memory-turn-repo.js";
export * from "./keyed-mutex.js";
export * from "./model-registry.js";
export * from "./permission.js";
export * from "./repo.js";
export * from "./runtime.js";
export * from "./stream.js";
export * from "./tool-registry.js";
export * from "./usage-reporter.js";

View file

@ -0,0 +1,193 @@
// Runtime storage inspector. Works on turns AND sessions (auto-detected):
//
// npm run inspect -- <turnId | sessionId | path/to/*.jsonl> [modelCallIndex] [--full] [--turns]
//
// Turn mode prints, per model call, the EXACT provider payload the loop sent
// — rebuilt from the durable file by the same composer the loop transmits
// through (compose-model-request.ts). This is where the woven wire-form
// messages (user-message context, attachments, tool-result envelopes) are
// visible; the file itself stores only structural facts and references.
//
// Session mode prints the session overview (title, turns, statuses, sizes);
// pass --turns to cascade full turn inspection for every turn.
//
// --full prints untruncated system prompts and message contents.
import fs from "node:fs";
import path from "node:path";
import {
deriveTurnStatus,
reduceTurn,
type JsonValue,
type TurnEvent,
type TurnState,
} from "@x/shared/dist/turns.js";
import { reduceSession } from "@x/shared/dist/sessions.js";
import type { z } from "zod";
import { convertFromMessages } from "../agents/runtime.js";
import { WorkDir } from "../config/config.js";
import { FSSessionRepo } from "../sessions/fs-repo.js";
import { composeModelRequest } from "./compose-model-request.js";
import { TurnRepoContextResolver } from "./context-resolver.js";
import { FSTurnRepo } from "./fs-repo.js";
const turnRepo = new FSTurnRepo({
turnsRootDir: path.join(WorkDir, "storage", "turns"),
});
const sessionRepo = new FSSessionRepo({
sessionsRootDir: path.join(WorkDir, "storage", "sessions"),
});
const resolver = new TurnRepoContextResolver({ turnRepo });
const encode = (messages: Parameters<typeof convertFromMessages>[0]) =>
convertFromMessages(messages) as unknown as JsonValue[];
function usage(): never {
console.error(
"usage: inspect <turnId | sessionId | path/to/*.jsonl> [modelCallIndex] [--full] [--turns]",
);
process.exit(1);
}
function clip(text: string, full: boolean, limit = 400): string {
if (full || text.length <= limit) return text;
return `${text.slice(0, limit)}… [${text.length} chars total; pass --full]`;
}
function inputPreview(state: TurnState): string {
const content = state.definition.input.content;
const text =
typeof content === "string"
? content
: content
.map((part) => (part.type === "text" ? part.text : `<${part.type}>`))
.join(" ");
return text.replace(/\s+/g, " ").slice(0, 60);
}
async function inspectTurn(
turnId: string,
events: Array<z.infer<typeof TurnEvent>>,
onlyIndex: number | undefined,
full: boolean,
): Promise<void> {
const state = reduceTurn(events);
const prefix = await resolver.resolve(state.definition.context);
const agent = await resolver.resolveAgent(state.definition.agent.resolved);
const inherited =
"inheritedFrom" in state.definition.agent.resolved
? ` (snapshot inherited from ${state.definition.agent.resolved.inheritedFrom})`
: "";
console.log(`turn ${turnId} status ${deriveTurnStatus(state)}`);
console.log(
`agent ${agent.agentId} model ${agent.model.provider}/${agent.model.model} calls ${state.modelCalls.length}${inherited}`,
);
for (const call of state.modelCalls) {
if (onlyIndex !== undefined && call.index !== onlyIndex) continue;
const composed = composeModelRequest(state, call.index, prefix, agent, encode);
console.log(`\n━━ model call ${call.index} ━━ (as sent to the provider)`);
console.log(
`system (${composed.systemPrompt.length} chars): ${clip(composed.systemPrompt, full)}`,
);
console.log(
`tools (${composed.tools.length}): ${composed.tools.map((t) => t.name).join(", ")}`,
);
console.log(`messages (${composed.messages.length}):`);
for (const message of composed.messages) {
const m = message as { role?: string; content?: unknown };
const content =
typeof m.content === "string" ? m.content : JSON.stringify(m.content);
console.log(` [${m.role}] ${clip(content, full)}`);
}
if (call.error !== undefined) {
console.log(` → failed: ${call.error}`);
} else if (call.response !== undefined) {
const response =
typeof call.response.content === "string"
? call.response.content
: JSON.stringify(call.response.content);
console.log(` → response (${call.finishReason}): ${clip(response, full)}`);
}
}
}
async function inspectSession(
sessionId: string,
full: boolean,
cascade: boolean,
): Promise<void> {
const state = reduceSession(await sessionRepo.read(sessionId));
console.log(`session ${sessionId}`);
console.log(
`title "${state.title ?? ""}" turns ${state.turns.length} created ${state.createdAt} updated ${state.updatedAt}`,
);
for (const ref of state.turns) {
try {
const events = await turnRepo.read(ref.turnId);
const turnState = reduceTurn(events);
const bytes = JSON.stringify(events).length;
console.log(
` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} ${deriveTurnStatus(turnState).padEnd(9)} ${turnState.modelCalls.length} calls ~${Math.round(bytes / 1024)}KB "${inputPreview(turnState)}"`,
);
} catch (error) {
console.log(
` ${String(ref.sessionSeq).padStart(3)}. ${ref.turnId} UNREADABLE: ${error instanceof Error ? error.message : error}`,
);
}
}
if (cascade) {
for (const ref of state.turns) {
console.log(`\n════════ turn ${ref.sessionSeq} ════════`);
try {
await inspectTurn(ref.turnId, await turnRepo.read(ref.turnId), undefined, full);
} catch (error) {
console.log(`unreadable: ${error instanceof Error ? error.message : error}`);
}
}
} else {
console.log(`\n(inspect a turn: npm run inspect -- <turnId>; whole session: --turns)`);
}
}
async function main(): Promise<void> {
const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith("--")));
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const full = flags.has("--full");
const cascade = flags.has("--turns");
const target = args[0];
if (!target) usage();
const onlyIndex = args[1] !== undefined ? Number(args[1]) : undefined;
const id = target.endsWith(".jsonl") ? path.basename(target, ".jsonl") : target;
// Direct file path: session files live under storage/sessions.
if (target.endsWith(".jsonl") && fs.existsSync(target)) {
if (path.resolve(target).includes(`${path.sep}sessions${path.sep}`)) {
await inspectSession(id, full, cascade);
return;
}
const events = fs
.readFileSync(target, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as z.infer<typeof TurnEvent>);
await inspectTurn(id, events, onlyIndex, full);
return;
}
// Bare id: try turn first, then session.
try {
const events = await turnRepo.read(id);
await inspectTurn(id, events, onlyIndex, full);
} catch (error) {
if (!(error instanceof Error) || !/turn not found/.test(error.message)) {
throw error;
}
await inspectSession(id, full, cascade);
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});

View file

@ -0,0 +1,21 @@
// In-process per-key exclusion. Cross-process coordination is explicitly out
// of scope for the turn runtime (single Electron main process).
export class KeyedMutex {
private tails = new Map<string, Promise<unknown>>();
async run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = this.tails.get(key) ?? Promise.resolve();
const next = prev.then(
() => fn(),
() => fn(),
);
this.tails.set(key, next);
try {
return await next;
} finally {
if (this.tails.get(key) === next) {
this.tails.delete(key);
}
}
}
}

View file

@ -0,0 +1,54 @@
import type { z } from "zod";
import type { AssistantMessage } from "@x/shared/dist/message.js";
import type {
ConversationMessage,
DurableLlmStepStreamEvent,
JsonValue,
ModelDescriptor,
ToolDescriptor,
TurnUsage,
} from "@x/shared/dist/turns.js";
// One stream() call performs exactly one model step; the turn loop drives
// multi-step behavior. The stream yields normalized events and must end with
// exactly one "completed" event, or throw (a throw is a model failure; an
// abort-triggered throw is cancellation).
export type LlmStreamEvent =
| { type: "text_delta"; delta: string }
| { type: "reasoning_delta"; delta: string }
| { type: "step_event"; event: z.infer<typeof DurableLlmStepStreamEvent> }
| {
type: "completed";
message: z.infer<typeof AssistantMessage>;
finishReason: string;
usage: z.infer<typeof TurnUsage>;
providerMetadata?: JsonValue;
};
export interface ModelStreamRequest {
systemPrompt: string;
// Provider wire-form messages: the output of encodeMessages. The loop
// builds these through the shared request composer, so what is sent is
// exactly what composeModelRequest reproduces from the durable log.
messages: JsonValue[];
tools: Array<z.infer<typeof ToolDescriptor>>;
parameters: Record<string, JsonValue>;
signal: AbortSignal;
}
export interface ResolvedModel {
descriptor: z.infer<typeof ModelDescriptor>;
// Deterministic per-message structural -> wire conversion (e.g. weaving
// userMessageContext into the user text, tool-result enveloping).
encodeMessages(
messages: Array<z.infer<typeof ConversationMessage>>,
): JsonValue[];
stream(request: ModelStreamRequest): AsyncIterable<LlmStreamEvent>;
}
// Resolves the persisted model descriptor to a live model during advanceTurn.
// A rejection is an infrastructure error: the execution is rejected and the
// turn is left unchanged.
export interface IModelRegistry {
resolve(descriptor: z.infer<typeof ModelDescriptor>): Promise<ResolvedModel>;
}

View file

@ -0,0 +1,61 @@
import type { z } from "zod";
import type { ConversationMessage, JsonValue } from "@x/shared/dist/turns.js";
export interface PermissionCheckInput {
turnId: string;
toolCallId: string;
toolId: string;
toolName: string;
input: unknown;
}
export interface PermissionCheckAllowed {
required: false;
}
export interface PermissionCheckRequired {
required: true;
// Presentation payload persisted on tool_permission_required and shown to
// the human/classifier.
request: JsonValue;
}
// Tool-specific policy (command analysis, filesystem boundaries, allowlists)
// lives behind this seam, outside the loop. A thrown error fails closed: the
// call is recorded as permission-required and never executes automatically.
export interface IPermissionChecker {
check(
input: PermissionCheckInput,
): Promise<PermissionCheckAllowed | PermissionCheckRequired>;
}
export interface PermissionClassificationInput {
toolCallId: string;
toolName: string;
input: unknown;
request: JsonValue;
}
export interface PermissionClassification {
toolCallId: string;
decision: "allow" | "deny" | "defer";
reason: string;
}
export interface PermissionClassificationBatch {
turnId: string;
// Conversation context for the classifier: the turn's resolved context
// plus current-turn settled messages.
messages: Array<z.infer<typeof ConversationMessage>>;
requests: PermissionClassificationInput[];
}
// Handles all permission-required calls from one model response in one batch
// when automatic permission is enabled. Internal model calls are opaque to
// the turn loop. Failures and omitted decisions normalize to defer.
export interface IPermissionClassifier {
classify(
batch: PermissionClassificationBatch,
signal: AbortSignal,
): Promise<PermissionClassification[]>;
}

View file

@ -0,0 +1,15 @@
import type { z } from "zod";
import type { TurnCreated, TurnEvent } from "@x/shared/dist/turns.js";
// Loop-facing repository contract. Listing, deletion, session lookup, and
// presentation metadata are deliberately not part of it.
export interface ITurnRepo {
// Fails if the turn already exists.
create(event: z.infer<typeof TurnCreated>): Promise<void>;
// Validates every line strictly; corrupt files are rejected whole.
read(turnId: string): Promise<Array<z.infer<typeof TurnEvent>>>;
// Validates events before writing.
append(turnId: string, events: Array<z.infer<typeof TurnEvent>>): Promise<void>;
// In-process per-turn exclusion.
withLock<T>(turnId: string, fn: () => Promise<T>): Promise<T>;
}

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,89 @@
import { describe, expect, it } from "vitest";
import { HotStream } from "./stream.js";
async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const out: T[] = [];
for await (const item of iterable) {
out.push(item);
}
return out;
}
describe("HotStream", () => {
it("buffers events pushed before a consumer attaches", async () => {
const stream = new HotStream<number, string>();
stream.push(1);
stream.push(2);
stream.end("done");
expect(await collect(stream.events)).toEqual([1, 2]);
expect(await stream.outcome).toBe("done");
});
it("outcome resolves without draining events", async () => {
const stream = new HotStream<number, string>();
stream.push(1);
stream.push(2);
stream.end("done");
expect(await stream.outcome).toBe("done");
});
it("delivers events in order across await boundaries", async () => {
const stream = new HotStream<number, string>();
const consumer = collect(stream.events);
stream.push(1);
await Promise.resolve();
stream.push(2);
stream.push(3);
stream.end("done");
expect(await consumer).toEqual([1, 2, 3]);
});
it("closing the consumer drops future events without affecting outcome", async () => {
const stream = new HotStream<number, string>();
stream.push(1);
stream.push(2);
for await (const event of stream.events) {
expect(event).toBe(1);
break; // closes the iterator
}
stream.push(3); // dropped
stream.end("done");
expect(await collect(stream.events)).toEqual([]);
expect(await stream.outcome).toBe("done");
});
it("failure drains queued events, then throws, and rejects outcome with the same error", async () => {
const stream = new HotStream<number, string>();
const boom = new Error("boom");
stream.push(1);
stream.fail(boom);
const seen: number[] = [];
await expect(
(async () => {
for await (const event of stream.events) {
seen.push(event);
}
})(),
).rejects.toBe(boom);
expect(seen).toEqual([1]);
await expect(stream.outcome).rejects.toBe(boom);
});
it("ignores pushes and settlement after completion", async () => {
const stream = new HotStream<number, string>();
stream.end("first");
stream.push(9);
stream.end("second");
stream.fail(new Error("late"));
expect(await stream.outcome).toBe("first");
expect(await collect(stream.events)).toEqual([]);
});
it("a waiting consumer wakes on end", async () => {
const stream = new HotStream<number, string>();
const consumer = collect(stream.events);
await Promise.resolve();
stream.end("done");
expect(await consumer).toEqual([]);
});
});

View file

@ -0,0 +1,94 @@
// Hot execution stream (turn-runtime-design.md §16). Execution starts
// independently of event consumption; events buffer in an unbounded in-memory
// queue until the single assumed consumer attaches. If the consumer closes,
// subsequent events are dropped; closing never cancels execution. On
// infrastructure failure, iteration drains already-queued events and then
// throws, and the outcome rejects with the same error.
export class HotStream<TEvent, TOutcome> {
private queue: TEvent[] = [];
private waiters: Array<() => void> = [];
private done = false;
private failure: { error: unknown } | null = null;
private consumerClosed = false;
readonly outcome: Promise<TOutcome>;
private resolveOutcome!: (outcome: TOutcome) => void;
private rejectOutcome!: (error: unknown) => void;
constructor() {
this.outcome = new Promise<TOutcome>((resolve, reject) => {
this.resolveOutcome = resolve;
this.rejectOutcome = reject;
});
// The outcome may legitimately never be awaited (fire-and-forget
// callers); don't surface unhandled rejections for it.
this.outcome.catch(() => undefined);
}
push(event: TEvent): void {
if (this.done || this.consumerClosed) {
return;
}
this.queue.push(event);
this.wake();
}
end(outcome: TOutcome): void {
if (this.done) {
return;
}
this.done = true;
this.resolveOutcome(outcome);
this.wake();
}
fail(error: unknown): void {
if (this.done) {
return;
}
this.done = true;
this.failure = { error };
this.rejectOutcome(error);
this.wake();
}
private wake(): void {
const waiters = this.waiters;
this.waiters = [];
for (const waiter of waiters) {
waiter();
}
}
get events(): AsyncIterable<TEvent> {
return {
[Symbol.asyncIterator]: (): AsyncIterator<TEvent> => ({
next: async (): Promise<IteratorResult<TEvent>> => {
for (;;) {
if (this.consumerClosed) {
return { value: undefined, done: true };
}
const event = this.queue.shift();
if (event !== undefined) {
return { value: event, done: false };
}
if (this.done) {
if (this.failure) {
throw this.failure.error;
}
return { value: undefined, done: true };
}
await new Promise<void>((resolve) =>
this.waiters.push(resolve),
);
}
},
return: async (): Promise<IteratorResult<TEvent>> => {
this.consumerClosed = true;
this.queue.length = 0;
return { value: undefined, done: true };
},
}),
};
}
}

View file

@ -0,0 +1,37 @@
import type { z } from "zod";
import type {
JsonValue,
ToolDescriptor,
ToolResultData,
} from "@x/shared/dist/turns.js";
export interface ToolExecutionContext {
turnId: string;
toolCallId: string;
signal: AbortSignal;
// The loop appends a durable tool_progress event before resolving.
reportProgress(progress: JsonValue): Promise<void>;
}
export interface SyncRuntimeTool {
descriptor: z.infer<typeof ToolDescriptor> & { execution: "sync" };
execute(
input: unknown,
context: ToolExecutionContext,
): Promise<z.infer<typeof ToolResultData>>;
}
// An async tool has no in-process executor. Its invocation is exposed
// externally and its progress/result arrives later through advanceTurn.
export interface AsyncRuntimeTool {
descriptor: z.infer<typeof ToolDescriptor> & { execution: "async" };
}
export type RuntimeTool = SyncRuntimeTool | AsyncRuntimeTool;
// Resolves persisted tool descriptors to live implementations during
// advanceTurn. A rejection or a descriptor mismatch is an infrastructure
// error: the execution is rejected and the turn is left unchanged.
export interface IToolRegistry {
resolve(descriptor: z.infer<typeof ToolDescriptor>): Promise<RuntimeTool>;
}

View file

@ -0,0 +1,19 @@
import type { z } from "zod";
import type { ModelDescriptor, TurnUsage } from "@x/shared/dist/turns.js";
// Analytics seam for the turn loop: invoked once per completed model call,
// after the durable model_call_completed append. Implementations must never
// throw into the loop and must not block it (fire-and-forget).
export interface ModelUsageReport {
agentId: string;
model: z.infer<typeof ModelDescriptor>;
usage: z.infer<typeof TurnUsage>;
}
export interface IUsageReporter {
reportModelUsage(report: ModelUsageReport): void;
}
export class NoopUsageReporter implements IUsageReporter {
reportModelUsage(): void {}
}

View file

@ -32,46 +32,57 @@ export async function getVoiceConfig(): Promise<VoiceConfig> {
};
}
export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> {
async function resolveTtsEndpoint(streaming: boolean): Promise<{ url: string; headers: Record<string, string> }> {
const config = await getVoiceConfig();
const signedIn = await isSignedIn();
let url: string;
let headers: Record<string, string>;
if (signedIn) {
const voiceId = config.elevenlabs?.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
const voiceId = config.elevenlabs?.voiceId || 's3TPKV1kjDlVtZbl4Ksh';
const accessToken = await getAccessToken();
url = `${API_URL}/v1/voice/text-to-speech/${voiceId}`;
headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
// The proxy has no dedicated /stream route — the same endpoint is
// used and the body is consumed progressively; if the proxy buffers,
// streaming degrades to today's full-body latency, never worse.
return {
url: `${API_URL}/v1/voice/text-to-speech/${voiceId}`,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
};
console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId);
} else {
if (!config.elevenlabs) {
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
}
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
headers = {
}
if (!config.elevenlabs) {
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
}
const voiceId = config.elevenlabs.voiceId || 's3TPKV1kjDlVtZbl4Ksh';
return {
url: `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}${streaming ? '/stream' : ''}`,
headers: {
'xi-api-key': config.elevenlabs.apiKey,
'Content-Type': 'application/json',
};
console.log('[voice] synthesizing speech via ElevenLabs, text length:', text.length, 'voiceId:', voiceId);
}
},
};
}
function ttsRequestBody(text: string): string {
return JSON.stringify({
text,
model_id: 'eleven_flash_v2_5',
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
},
});
}
export async function synthesizeSpeech(text: string): Promise<{ audioBase64: string; mimeType: string }> {
const { url, headers } = await resolveTtsEndpoint(false);
console.log('[voice] synthesizing speech, text length:', text.length);
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({
text,
model_id: 'eleven_flash_v2_5',
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
},
}),
body: ttsRequestBody(text),
});
if (!response.ok) {
@ -85,3 +96,42 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
console.log('[voice] synthesized audio, base64 length:', audioBase64.length);
return { audioBase64, mimeType: 'audio/mpeg' };
}
/**
* Streaming synthesis: invokes `onChunk` with MP3 bytes as they arrive so
* playback can start on the first chunk. Resolves when the stream ends;
* rejects on HTTP/stream errors. Abort via the provided signal.
*/
export async function synthesizeSpeechStream(
text: string,
onChunk: (chunk: Buffer) => void,
signal?: AbortSignal,
): Promise<void> {
const { url, headers } = await resolveTtsEndpoint(true);
console.log('[voice] streaming speech synthesis, text length:', text.length);
const response = await fetch(url, {
method: 'POST',
headers,
body: ttsRequestBody(text),
signal: signal ?? null,
});
if (!response.ok) {
const errText = await response.text().catch(() => 'Unknown error');
console.error('[voice] TTS stream API error:', response.status, errText);
throw new Error(`TTS API error ${response.status}: ${errText}`);
}
if (!response.body) {
throw new Error('TTS API returned no body');
}
const reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value && value.byteLength > 0) {
onChunk(Buffer.from(value));
}
}
}