mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/disk-skills
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
This commit is contained in:
commit
239080ae56
106 changed files with 18168 additions and 707 deletions
40
.github/workflows/x-tests.yml
vendored
Normal file
40
.github/workflows/x-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: apps/x tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/x/**"
|
||||
- ".github/workflows/x-tests.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/x/**"
|
||||
- ".github/workflows/x-tests.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/x
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: apps/x/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Builds @x/shared (core/renderer tests import its dist), then runs
|
||||
# the shared, core, and renderer vitest suites in order.
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@
|
|||
data/
|
||||
.venv/
|
||||
.claude/
|
||||
|
||||
# Local-only meeting-prep planning doc
|
||||
/PLAN.md
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ Long-form docs for specific features. Read the relevant file before making chang
|
|||
|---------|-----|
|
||||
| Live Notes — single `live:` frontmatter block (one objective + optional cron / windows / eventMatchCriteria) that turns a note into a self-updating artifact, panel UI, Copilot skill, prompts catalog | `apps/x/LIVE_NOTE.md` |
|
||||
| Analytics — PostHog event catalog, person properties, use-case taxonomy, how to add a new event | `apps/x/ANALYTICS.md` |
|
||||
| Turn/session runtime — event-sourced storage, reference model, the `npm run inspect` debugger | `AGENTS.md` (repo root), `apps/x/packages/core/docs/turn-runtime-design.md`, `session-design.md` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
|
|
|
|||
1
apps/x/.gitignore
vendored
1
apps/x/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
test-fixtures/
|
||||
*.tsbuildinfo
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ const execFileAsync = promisify(execFile);
|
|||
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
||||
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
||||
import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
||||
|
|
@ -62,6 +64,9 @@ import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
|||
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
||||
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
||||
import { search } from '@x/core/dist/search/search.js';
|
||||
import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js';
|
||||
import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js';
|
||||
import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js';
|
||||
import { versionHistory, voice } from '@x/core';
|
||||
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
||||
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
||||
|
|
@ -507,7 +512,25 @@ function queueChange(relPath: string): void {
|
|||
/**
|
||||
* Handle workspace change event from core watcher
|
||||
*/
|
||||
function touchesKnowledge(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): boolean {
|
||||
const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/');
|
||||
switch (event.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
return hit(event.path);
|
||||
case 'moved':
|
||||
return hit(event.from) || hit(event.to);
|
||||
case 'bulkChanged':
|
||||
return !event.paths || event.paths.some(hit);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
||||
// Any knowledge-base change drops the cached index so the next read rebuilds.
|
||||
if (touchesKnowledge(event)) invalidateKnowledgeIndex();
|
||||
// Debounce 'changed' events, emit others immediately
|
||||
if (event.type === 'changed' && event.path) {
|
||||
queueChange(event.path);
|
||||
|
|
@ -613,6 +636,25 @@ export async function startRunsWatcher(): Promise<void> {
|
|||
});
|
||||
}
|
||||
|
||||
// New runtime: session bus → renderer windows (session-design.md §10).
|
||||
function emitSessionEvent(event: SessionBusEvent): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('sessions:events', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sessionsWatcher: (() => void) | null = null;
|
||||
export function startSessionsWatcher(): void {
|
||||
if (sessionsWatcher) {
|
||||
return;
|
||||
}
|
||||
const sessionBus = container.resolve<EmitterSessionBus>('sessionBus');
|
||||
sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event));
|
||||
}
|
||||
|
||||
let servicesWatcher: (() => void) | null = null;
|
||||
export async function startServicesWatcher(): Promise<void> {
|
||||
if (servicesWatcher) {
|
||||
|
|
@ -820,6 +862,82 @@ export function setupIpcHandlers() {
|
|||
await runsCore.deleteRun(args.runId);
|
||||
return { success: true };
|
||||
},
|
||||
// ── New runtime: sessions + turns ─────────────────────────
|
||||
// Thin pass-throughs to the sessions service. sendMessage returns the
|
||||
// turnId immediately; the turn advances in the background and the
|
||||
// renderer reconciles via the sessions:events feed. Input-routing calls
|
||||
// settle with that advance's outcome (the renderer fire-and-forgets).
|
||||
'sessions:create': async (_event, args) => {
|
||||
const sessionId = await container.resolve<ISessions>('sessions').createSession(args);
|
||||
return { sessionId };
|
||||
},
|
||||
'sessions:list': async () => {
|
||||
return { sessions: container.resolve<ISessions>('sessions').listSessions() };
|
||||
},
|
||||
'sessions:get': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').getSession(args.sessionId);
|
||||
},
|
||||
'sessions:getTurn': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').getTurn(args.turnId);
|
||||
},
|
||||
'sessions:sendMessage': async (_event, args) => {
|
||||
return container.resolve<ISessions>('sessions').sendMessage(args.sessionId, args.input, args.config);
|
||||
},
|
||||
'sessions:respondToPermission': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:respondToAskHuman': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:stopTurn': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').stopTurn(args.turnId, args.reason);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:resumeTurn': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').resumeTurn(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:setTitle': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').setTitle(args.sessionId, args.title);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:delete': async (_event, args) => {
|
||||
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
|
||||
return { success: true };
|
||||
},
|
||||
'sessions:downloadLog': async (event, args) => {
|
||||
// Concatenate the session's turn logs into one JSONL for debugging.
|
||||
const sessions = container.resolve<ISessions>('sessions');
|
||||
const state = await sessions.getSession(args.sessionId);
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const result = await dialog.showSaveDialog(win!, {
|
||||
defaultPath: `${args.sessionId}.jsonl.log`,
|
||||
filters: [
|
||||
{ name: 'Chat Log', extensions: ['log'] },
|
||||
{ name: 'JSONL', extensions: ['jsonl'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
return { success: false };
|
||||
}
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
for (const ref of state.turns) {
|
||||
const turn = await sessions.getTurn(ref.turnId);
|
||||
for (const turnEvent of turn.events) {
|
||||
lines.push(JSON.stringify(turnEvent));
|
||||
}
|
||||
}
|
||||
await fs.writeFile(result.filePath, lines.join('\n') + '\n');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
||||
return { success: false, error: message };
|
||||
}
|
||||
},
|
||||
'runs:downloadLog': async (event, args) => {
|
||||
const runFileName = `${args.runId}.jsonl`;
|
||||
if (path.basename(runFileName) !== runFileName) {
|
||||
|
|
@ -1544,6 +1662,11 @@ export function setupIpcHandlers() {
|
|||
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
||||
return { notes };
|
||||
},
|
||||
'meeting-prep:resolve': async (_event, args) => {
|
||||
const result = await resolveMeetingPrep(args.attendees);
|
||||
const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null;
|
||||
return { ...result, prepNote };
|
||||
},
|
||||
'inline-task:classifySchedule': async (_event, args) => {
|
||||
const schedule = await classifySchedule(args.instruction);
|
||||
return { schedule };
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { app, BrowserWindow, desktopCapturer, protocol, net, shell, session, typ
|
|||
import path from "node:path";
|
||||
import {
|
||||
setupIpcHandlers,
|
||||
startRunsWatcher,
|
||||
startRunsWatcher, startSessionsWatcher,
|
||||
startCodeSessionStatusWatcher,
|
||||
startServicesWatcher,
|
||||
startLiveNoteAgentWatcher,
|
||||
|
|
@ -27,6 +27,7 @@ import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"
|
|||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
|
||||
import { init as initLiveNoteScheduler } from "@x/core/dist/knowledge/live-note/scheduler.js";
|
||||
import { init as initEventProcessor, registerConsumer } from "@x/core/dist/events/init.js";
|
||||
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
|
||||
|
|
@ -45,6 +46,7 @@ import { execFileSync } from "node:child_process";
|
|||
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
|
||||
import container, { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
|
||||
import type { CodeModeManager } from "@x/core/dist/code-mode/acp/manager.js";
|
||||
import type { ISessions } from "@x/core/dist/sessions/index.js";
|
||||
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
|
||||
import { setupBrowserEventForwarding } from "./browser/ipc.js";
|
||||
import { ElectronBrowserControlService } from "./browser/control-service.js";
|
||||
|
|
@ -388,6 +390,11 @@ app.whenReady().then(async () => {
|
|||
// start runs watcher
|
||||
startRunsWatcher();
|
||||
|
||||
// New runtime: build the in-memory session index (startup scan) before the
|
||||
// renderer can list sessions, then forward the session bus to windows.
|
||||
await container.resolve<ISessions>('sessions').initialize();
|
||||
startSessionsWatcher();
|
||||
|
||||
// start code-session status tracker (derives working/needs-you/idle + notifications)
|
||||
startCodeSessionStatusWatcher();
|
||||
|
||||
|
|
@ -455,6 +462,9 @@ app.whenReady().then(async () => {
|
|||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start meeting prep scheduler (generates prep notes ~6h before a meeting)
|
||||
void initMeetingPrep();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
|
|
@ -83,6 +85,9 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
|
@ -91,9 +96,11 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useState, useRef } from 'react'
|
||||
import { workspace } from '@x/shared';
|
||||
import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js';
|
||||
import { RunEvent } from '@x/shared/src/runs.js';
|
||||
import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
||||
import './App.css'
|
||||
import z from 'zod';
|
||||
|
|
@ -9,6 +9,7 @@ import { CheckIcon, LoaderIcon, PanelLeftIcon, ArrowLeft, ArrowRight, MessageSqu
|
|||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor, type MarkdownEditorHandle } from './components/markdown-editor';
|
||||
import { ChatSidebar } from './components/chat-sidebar';
|
||||
import { useSessionChat } from '@/hooks/useSessionChat';
|
||||
import { ChatHeader } from './components/chat-header';
|
||||
import { ChatEmptyState } from './components/chat-empty-state';
|
||||
import { ChatInputWithMentions, type PermissionMode, type StagedAttachment } from './components/chat-input-with-mentions';
|
||||
|
|
@ -96,7 +97,6 @@ import {
|
|||
type ChatViewportAnchorState,
|
||||
type ChatTabViewState,
|
||||
type ConversationItem,
|
||||
type ToolCall,
|
||||
createEmptyChatTabViewState,
|
||||
getWebSearchCardData,
|
||||
getAppActionCardData,
|
||||
|
|
@ -126,7 +126,6 @@ import { useTheme } from '@/contexts/theme-context'
|
|||
|
||||
type DirEntry = z.infer<typeof workspace.DirEntry>
|
||||
type RunEventType = z.infer<typeof RunEvent>
|
||||
type ListRunsResponseType = z.infer<typeof ListRunsResponse>
|
||||
|
||||
interface TreeNode extends DirEntry {
|
||||
children?: TreeNode[]
|
||||
|
|
@ -938,6 +937,10 @@ function App() {
|
|||
}, [conversation])
|
||||
const [, setModelUsage] = useState<LanguageModelUsage | null>(null)
|
||||
const [runId, setRunId] = useState<string | null>(null)
|
||||
// New runtime: the active session's chat data + actions. All logic lives in
|
||||
// SessionChatStore (tested headlessly); the hook is a thin subscription.
|
||||
// runId IS the session id in the sessions runtime.
|
||||
const sessionChat = useSessionChat(runId)
|
||||
const runIdRef = useRef<string | null>(null)
|
||||
const loadRunRequestIdRef = useRef(0)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
|
@ -945,7 +948,19 @@ function App() {
|
|||
const processingRunIdsRef = useRef<Set<string>>(new Set())
|
||||
const streamingBuffersRef = useRef<Map<string, { assistant: string }>>(new Map())
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
|
||||
const [, setStopClickedAt] = useState<number | null>(null)
|
||||
// Sessions runtime: hook-derived activity for the ACTIVE chat.
|
||||
// activeIsProcessing blocks the composer until the turn settles;
|
||||
// activeIsThinking ("actively working") drives shimmers and disables
|
||||
// permission/ask-human buttons — false while waiting on the user.
|
||||
const activeIsProcessing = sessionChat.chatState?.isProcessing ?? isProcessing
|
||||
const activeIsThinking = sessionChat.chatState ? sessionChat.chatState.isThinking : isProcessing
|
||||
// A failed session load must be visible, not a blank chat.
|
||||
const sessionLoadErrorItems = React.useMemo<ConversationItem[]>(() => (
|
||||
sessionChat.error
|
||||
? [{ id: 'session-load-error', kind: 'error', message: `Failed to load chat: ${sessionChat.error}`, timestamp: 0 }]
|
||||
: []
|
||||
), [sessionChat.error])
|
||||
const [agentId] = useState<string>('copilot')
|
||||
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
|
|
@ -965,6 +980,28 @@ function App() {
|
|||
const ttsRef = useRef(tts)
|
||||
ttsRef.current = tts
|
||||
|
||||
// Speak newly completed <voice> blocks from the new runtime's live stream
|
||||
// (parity with the legacy text-delta voice extraction below). The store
|
||||
// accumulates completed blocks in chatState.voiceSegments; we speak only
|
||||
// segments that appeared after the current session became active.
|
||||
const spokenVoiceRef = useRef<{ key: string | null; count: number }>({ key: null, count: 0 })
|
||||
const voiceSegments = sessionChat.chatState?.voiceSegments
|
||||
useEffect(() => {
|
||||
if (!voiceSegments) return
|
||||
if (spokenVoiceRef.current.key !== runId) {
|
||||
// Session switch: skip anything already streamed before we arrived.
|
||||
spokenVoiceRef.current = { key: runId, count: voiceSegments.length }
|
||||
return
|
||||
}
|
||||
while (spokenVoiceRef.current.count < voiceSegments.length) {
|
||||
const segment = voiceSegments[spokenVoiceRef.current.count]
|
||||
spokenVoiceRef.current.count += 1
|
||||
if (ttsEnabledRef.current) {
|
||||
ttsRef.current.speak(segment)
|
||||
}
|
||||
}
|
||||
}, [voiceSegments, runId])
|
||||
|
||||
const voice = useVoiceMode()
|
||||
const voiceRef = useRef(voice)
|
||||
voiceRef.current = voice
|
||||
|
|
@ -1960,21 +1997,16 @@ function App() {
|
|||
// Load runs list (all pages)
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
const allRuns: RunListItem[] = []
|
||||
let cursor: string | undefined = undefined
|
||||
|
||||
// Fetch all pages
|
||||
do {
|
||||
const result: ListRunsResponseType = await window.ipc.invoke('runs:list', { cursor })
|
||||
allRuns.push(...result.runs)
|
||||
cursor = result.nextCursor
|
||||
} while (cursor)
|
||||
|
||||
// Filter for copilot chats only (Code-section sessions live in the Code view)
|
||||
const copilotRuns = allRuns.filter((run: RunListItem) => run.agentId === 'copilot' && run.useCase !== 'code_session')
|
||||
setRuns(copilotRuns)
|
||||
const { sessions } = await window.ipc.invoke('sessions:list', {})
|
||||
setRuns(sessions.map((entry) => ({
|
||||
id: entry.sessionId,
|
||||
title: entry.title ?? 'New chat',
|
||||
createdAt: entry.createdAt,
|
||||
modifiedAt: entry.updatedAt,
|
||||
agentId: entry.lastAgentId ?? 'copilot',
|
||||
})))
|
||||
} catch (err) {
|
||||
console.error('Failed to load runs:', err)
|
||||
console.error('Failed to load sessions:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
@ -2073,193 +2105,30 @@ function App() {
|
|||
}
|
||||
}, [backgroundTasks, loadBackgroundTasks])
|
||||
|
||||
// Load a specific run and populate conversation
|
||||
// Switch the active session. The useSessionChat hook loads and follows the
|
||||
// conversation; this only resets composer/tab state.
|
||||
const loadRun = useCallback(async (id: string) => {
|
||||
const requestId = (loadRunRequestIdRef.current += 1)
|
||||
setConversation([])
|
||||
setCurrentAssistantMessage('')
|
||||
setRunId(id)
|
||||
setMessage('')
|
||||
setIsProcessing(false)
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
setPendingPermissionRequests(new Map())
|
||||
setPendingAskHumanRequests(new Map())
|
||||
setAllPermissionRequests(new Map())
|
||||
setPermissionResponses(new Map())
|
||||
setAutoPermissionDecisions(new Map())
|
||||
try {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Parse the log events into conversation items
|
||||
const items: ConversationItem[] = []
|
||||
const toolCallMap = new Map<string, ToolCall>()
|
||||
|
||||
for (const event of run.log) {
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
const msg = event.message
|
||||
if (msg.role === 'user' || msg.role === 'assistant') {
|
||||
// Extract text content from message
|
||||
let textContent = ''
|
||||
let msgAttachments: ChatMessage['attachments'] = undefined
|
||||
if (typeof msg.content === 'string') {
|
||||
textContent = msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
const contentParts = msg.content as Array<{
|
||||
type: string
|
||||
text?: string
|
||||
path?: string
|
||||
filename?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
arguments?: ToolUIPart['input']
|
||||
}>
|
||||
|
||||
textContent = contentParts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text || '')
|
||||
.join('')
|
||||
|
||||
const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path)
|
||||
if (attachmentParts.length > 0) {
|
||||
msgAttachments = attachmentParts.map((part) => ({
|
||||
path: part.path!,
|
||||
filename: part.filename || part.path!.split('/').pop() || part.path!,
|
||||
mimeType: part.mimeType || 'application/octet-stream',
|
||||
size: part.size,
|
||||
}))
|
||||
}
|
||||
|
||||
// Also extract tool-call parts from assistant messages
|
||||
if (msg.role === 'assistant') {
|
||||
for (const part of contentParts) {
|
||||
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
|
||||
const toolCall: ToolCall = {
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: normalizeToolInput(part.arguments),
|
||||
status: 'pending',
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
items.push(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (textContent || msgAttachments) {
|
||||
items.push({
|
||||
id: event.messageId,
|
||||
role: msg.role,
|
||||
content: textContent,
|
||||
attachments: msgAttachments,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-invocation': {
|
||||
// Update existing tool call status or create new one
|
||||
const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existingTool) {
|
||||
existingTool.input = normalizeToolInput(event.input)
|
||||
existingTool.status = 'running'
|
||||
} else {
|
||||
const toolCall: ToolCall = {
|
||||
id: event.toolCallId || `tool-${Date.now()}-${Math.random()}`,
|
||||
name: event.toolName,
|
||||
input: normalizeToolInput(event.input),
|
||||
status: 'running',
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
}
|
||||
toolCallMap.set(toolCall.id, toolCall)
|
||||
items.push(toolCall)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool-result': {
|
||||
const existingTool = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
|
||||
if (existingTool) {
|
||||
existingTool.result = event.result
|
||||
existingTool.status = 'completed'
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
items.push({
|
||||
id: `error-${Date.now()}-${Math.random()}`,
|
||||
kind: 'error',
|
||||
message: event.error,
|
||||
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'llm-stream-event': {
|
||||
// We don't need to reconstruct streaming events for history
|
||||
// Reasoning is captured in the final message
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Track permission requests and responses from history
|
||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
const permResponseMap = new Map<string, 'approve' | 'deny'>()
|
||||
const autoPermissionDecisions = new Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>()
|
||||
const askHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
const respondedAskHumanIds = new Set<string>()
|
||||
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'tool-permission-request') {
|
||||
allPermissionRequests.set(event.toolCall.toolCallId, event)
|
||||
} else if (event.type === 'tool-permission-response') {
|
||||
permResponseMap.set(event.toolCallId, event.response)
|
||||
} else if (event.type === 'tool-permission-auto-decision') {
|
||||
autoPermissionDecisions.set(event.toolCallId, event)
|
||||
} else if (event.type === 'ask-human-request') {
|
||||
askHumanRequests.set(event.toolCallId, event)
|
||||
} else if (event.type === 'ask-human-response') {
|
||||
respondedAskHumanIds.add(event.toolCallId)
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Separate pending vs responded permission requests
|
||||
const pendingPerms = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
for (const [id, req] of allPermissionRequests.entries()) {
|
||||
if (!permResponseMap.has(id)) {
|
||||
pendingPerms.set(id, req)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingAsks = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
for (const [id, req] of askHumanRequests.entries()) {
|
||||
if (!respondedAskHumanIds.has(id)) {
|
||||
pendingAsks.set(id, req)
|
||||
}
|
||||
}
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
|
||||
// Set the conversation and runId
|
||||
setConversation(items)
|
||||
setRunId(id)
|
||||
setMessage('')
|
||||
// Reconcile composer state with THIS run. Loading a run while another one
|
||||
// is mid-turn (e.g. binding a code session steals the single chat tab)
|
||||
// must not leave isProcessing/isStopping pointing at the old run — that
|
||||
// wedges the composer: stop targets the new run (a no-op) while the old
|
||||
// run's processing-end arrives flagged as non-active and clears nothing.
|
||||
setIsProcessing(processingRunIdsRef.current.has(id))
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
setCurrentAssistantMessage(streamingBuffersRef.current.get(id)?.assistant ?? '')
|
||||
setPendingPermissionRequests(pendingPerms)
|
||||
setPendingAskHumanRequests(pendingAsks)
|
||||
setAllPermissionRequests(allPermissionRequests)
|
||||
setPermissionResponses(permResponseMap)
|
||||
setAutoPermissionDecisions(autoPermissionDecisions)
|
||||
|
||||
// Restore the run's per-chat work directory into the tab it was loaded into.
|
||||
// Restore the session's per-chat work directory into the active tab.
|
||||
const tabId = activeChatTabIdRef.current
|
||||
const wd = await loadRunWorkDir(id)
|
||||
if (loadRunRequestIdRef.current !== requestId) return
|
||||
setWorkDirByTab((prev) => ({ ...prev, [tabId]: wd }))
|
||||
} catch (err) {
|
||||
console.error('Failed to load run:', err)
|
||||
console.error('Failed to load session work dir:', err)
|
||||
}
|
||||
}, [loadRunWorkDir])
|
||||
|
||||
|
|
@ -2710,7 +2579,7 @@ function App() {
|
|||
codeMode?: 'claude' | 'codex',
|
||||
permissionMode?: PermissionMode,
|
||||
) => {
|
||||
if (isProcessing) return
|
||||
if (activeIsProcessing) return
|
||||
|
||||
const submitTabId = activeChatTabIdRef.current
|
||||
const { text } = message
|
||||
|
|
@ -2743,15 +2612,11 @@ function App() {
|
|||
let currentRunId = runId
|
||||
let isNewRun = false
|
||||
let newRunCreatedAt: string | null = null
|
||||
const selected = selectedModelByTabRef.current.get(submitTabId)
|
||||
if (!currentRunId) {
|
||||
const selected = selectedModelByTabRef.current.get(submitTabId)
|
||||
const run = await window.ipc.invoke('runs:create', {
|
||||
agentId,
|
||||
...(selected ? { model: selected.model, provider: selected.provider } : {}),
|
||||
permissionMode: permissionMode ?? 'manual',
|
||||
})
|
||||
currentRunId = run.id
|
||||
newRunCreatedAt = run.createdAt
|
||||
const createdSession = await window.ipc.invoke('sessions:create', {})
|
||||
currentRunId = createdSession.sessionId
|
||||
newRunCreatedAt = new Date().toISOString()
|
||||
setRunId(currentRunId)
|
||||
analytics.chatSessionCreated(currentRunId)
|
||||
// Update active chat tab's runId to the new run
|
||||
|
|
@ -2770,6 +2635,30 @@ function App() {
|
|||
let titleSource = userMessage
|
||||
const hasMentions = (mentions?.length ?? 0) > 0
|
||||
|
||||
// Per-message turn config. Composition inputs land in the system prompt
|
||||
// via the agent resolver; keep them session-sticky where possible so the
|
||||
// provider prefix cache survives across turns.
|
||||
const sendConfig = {
|
||||
agent: {
|
||||
agentId,
|
||||
overrides: {
|
||||
...(selected ? { model: { provider: selected.provider, model: selected.model } } : {}),
|
||||
composition: {
|
||||
workDirId: currentRunId,
|
||||
...(pendingVoiceInputRef.current ? { voiceInput: true } : {}),
|
||||
...(ttsEnabledRef.current ? { voiceOutput: ttsModeRef.current } : {}),
|
||||
...(searchEnabled ? { searchEnabled: true } : {}),
|
||||
...(codeMode ? { codeMode } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
autoPermission: (permissionMode ?? 'manual') === 'auto',
|
||||
}
|
||||
const userMessageContextFor = (middlePane: Awaited<ReturnType<typeof buildMiddlePaneContext>>) => ({
|
||||
currentDateTime: new Date().toISOString(),
|
||||
middlePane: middlePane ?? { kind: 'empty' as const },
|
||||
})
|
||||
|
||||
if (hasAttachments || hasMentions) {
|
||||
type ContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
|
|
@ -2812,17 +2701,15 @@ function App() {
|
|||
titleSource = stagedAttachments[0]?.filename ?? mentions?.[0]?.displayName ?? mentions?.[0]?.path ?? ''
|
||||
}
|
||||
|
||||
// Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema.
|
||||
const attachmentPayload = contentParts as unknown as string
|
||||
const middlePaneContext = await buildMiddlePaneContext()
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: attachmentPayload,
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
codeMode: codeMode || undefined,
|
||||
middlePaneContext,
|
||||
await window.ipc.invoke('sessions:sendMessage', {
|
||||
sessionId: currentRunId,
|
||||
input: {
|
||||
role: 'user',
|
||||
content: contentParts,
|
||||
userMessageContext: userMessageContextFor(middlePaneContext),
|
||||
},
|
||||
config: sendConfig,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
|
|
@ -2831,14 +2718,14 @@ function App() {
|
|||
})
|
||||
} else {
|
||||
const middlePaneContext = await buildMiddlePaneContext()
|
||||
await window.ipc.invoke('runs:createMessage', {
|
||||
runId: currentRunId,
|
||||
message: userMessage,
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
|
||||
searchEnabled: searchEnabled || undefined,
|
||||
codeMode: codeMode || undefined,
|
||||
middlePaneContext,
|
||||
await window.ipc.invoke('sessions:sendMessage', {
|
||||
sessionId: currentRunId,
|
||||
input: {
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
userMessageContext: userMessageContextFor(middlePaneContext),
|
||||
},
|
||||
config: sendConfig,
|
||||
})
|
||||
analytics.chatMessageSent({
|
||||
voiceInput: pendingVoiceInputRef.current || undefined,
|
||||
|
|
@ -2875,20 +2762,24 @@ function App() {
|
|||
handlePromptSubmitRef.current?.({ text: `${name} connected successfully.`, files: [] })
|
||||
}, [])
|
||||
|
||||
// The composer's stop state clears when the active turn settles.
|
||||
useEffect(() => {
|
||||
if (sessionChat.chatState && !sessionChat.chatState.isProcessing) {
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
}
|
||||
}, [sessionChat.chatState])
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!runId) return
|
||||
const now = Date.now()
|
||||
const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000
|
||||
|
||||
setStopClickedAt(now)
|
||||
setStopClickedAt(Date.now())
|
||||
setIsStopping(true)
|
||||
|
||||
try {
|
||||
await window.ipc.invoke('runs:stop', { runId, force: isForce })
|
||||
await sessionChat.stop()
|
||||
} catch (error) {
|
||||
console.error('Failed to stop run:', error)
|
||||
console.error('Failed to stop turn:', error)
|
||||
}
|
||||
}, [runId, isStopping, stopClickedAt])
|
||||
}, [runId, sessionChat])
|
||||
|
||||
const handlePermissionResponse = useCallback(async (
|
||||
toolCallId: string,
|
||||
|
|
@ -2898,33 +2789,17 @@ function App() {
|
|||
) => {
|
||||
if (!runId) return
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
setPermissionResponses(prev => {
|
||||
const next = new Map(prev)
|
||||
next.set(toolCallId, response)
|
||||
return next
|
||||
})
|
||||
setPendingPermissionRequests(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
|
||||
void subflow // subflows retired with the runs runtime
|
||||
try {
|
||||
await window.ipc.invoke('runs:authorizePermission', {
|
||||
runId,
|
||||
authorization: { subflow, toolCallId, response, scope }
|
||||
})
|
||||
await sessionChat.respondToPermission(
|
||||
toolCallId,
|
||||
response === 'approve' ? 'allow' : 'deny',
|
||||
scope ? { scope } : undefined,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to authorize permission:', error)
|
||||
// Revert the optimistic update on error
|
||||
setPermissionResponses(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(toolCallId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [runId])
|
||||
}, [runId, sessionChat])
|
||||
|
||||
// Answer a mid-run permission request from a code_agent_run coding turn. The
|
||||
// pending ask lives on the tool call itself, so we optimistically clear it and
|
||||
|
|
@ -2948,15 +2823,13 @@ function App() {
|
|||
|
||||
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
|
||||
if (!runId) return
|
||||
void subflow // subflows retired with the runs runtime
|
||||
try {
|
||||
await window.ipc.invoke('runs:provideHumanInput', {
|
||||
runId,
|
||||
reply: { subflow, toolCallId, response }
|
||||
})
|
||||
await sessionChat.answerAskHuman(toolCallId, response)
|
||||
} catch (error) {
|
||||
console.error('Failed to provide human input:', error)
|
||||
}
|
||||
}, [runId])
|
||||
}, [runId, sessionChat])
|
||||
|
||||
const dismissBrowserOverlay = useCallback(() => {
|
||||
setIsBrowserOpen(false)
|
||||
|
|
@ -5238,6 +5111,20 @@ function App() {
|
|||
return () => window.removeEventListener('email-block:draft-with-assistant', handler)
|
||||
}, [])
|
||||
|
||||
// Meeting prep: create a person note for an unmatched attendee via Copilot.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const pending = window.__pendingMeetingPrepCreate
|
||||
if (pending) {
|
||||
setPresetMessage(pending.prompt)
|
||||
setIsChatSidebarOpen(true)
|
||||
window.__pendingMeetingPrepCreate = undefined
|
||||
}
|
||||
}
|
||||
window.addEventListener('meeting-prep:create-note', handler)
|
||||
return () => window.removeEventListener('meeting-prep:create-note', handler)
|
||||
}, [])
|
||||
|
||||
const resolveWikiFilePath = useCallback((wikiPath: string) => {
|
||||
const normalized = normalizeWikiPath(wikiPath)
|
||||
const { path: basePath } = splitWikiFragment(normalized)
|
||||
|
|
@ -5573,16 +5460,24 @@ function App() {
|
|||
return null
|
||||
}
|
||||
|
||||
const activeChatTabState = React.useMemo<ChatTabViewState>(() => ({
|
||||
runId,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
}), [
|
||||
// The active chat's view state, backed by the sessions hook (legacy
|
||||
// standalone states remain only as the pre-load fallback until stage 7).
|
||||
const activeChatTabState = React.useMemo<ChatTabViewState>(() => (
|
||||
sessionChat.chatState
|
||||
? { runId, ...sessionChat.chatState }
|
||||
: {
|
||||
runId,
|
||||
conversation: sessionLoadErrorItems.length > 0 ? sessionLoadErrorItems : conversation,
|
||||
currentAssistantMessage,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
}
|
||||
), [
|
||||
runId,
|
||||
sessionChat.chatState,
|
||||
sessionLoadErrorItems,
|
||||
conversation,
|
||||
currentAssistantMessage,
|
||||
pendingAskHumanRequests,
|
||||
|
|
@ -5595,6 +5490,10 @@ function App() {
|
|||
if (tabId === activeChatTabId) return activeChatTabState
|
||||
return chatViewStateByTab[tabId] ?? emptyChatTabState
|
||||
}, [activeChatTabId, activeChatTabState, chatViewStateByTab, emptyChatTabState])
|
||||
const chatTabStatesForRender = React.useMemo(() => ({
|
||||
...chatViewStateByTab,
|
||||
[activeChatTabId]: activeChatTabState,
|
||||
}), [chatViewStateByTab, activeChatTabId, activeChatTabState])
|
||||
const selectedTask = selectedBackgroundTask
|
||||
? backgroundTasks.find(t => t.name === selectedBackgroundTask)
|
||||
: null
|
||||
|
|
@ -5993,7 +5892,7 @@ function App() {
|
|||
onSelectRun={(rid) => void navigateToView({ type: 'chat', runId: rid })}
|
||||
onDeleteRun={async (rid) => {
|
||||
try {
|
||||
await window.ipc.invoke('runs:delete', { runId: rid })
|
||||
await window.ipc.invoke('sessions:delete', { sessionId: rid })
|
||||
await loadRuns()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete run:', err)
|
||||
|
|
@ -6292,7 +6191,7 @@ function App() {
|
|||
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && activeIsThinking}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -6310,7 +6209,7 @@ function App() {
|
|||
query={request.query}
|
||||
options={request.options}
|
||||
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && activeIsThinking}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -6322,7 +6221,7 @@ function App() {
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
{isActive && activeIsThinking && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
@ -6358,7 +6257,7 @@ function App() {
|
|||
visibleFiles={visibleKnowledgeFiles}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onStop={handleStop}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && activeIsProcessing}
|
||||
isStopping={isActive && isStopping}
|
||||
isActive={isActive}
|
||||
presetMessage={isActive ? presetMessage : undefined}
|
||||
|
|
@ -6440,11 +6339,11 @@ function App() {
|
|||
}}
|
||||
onOpenChatHistory={() => void navigateToView({ type: 'chat-history' })}
|
||||
onOpenFullScreen={toggleRightPaneMaximize}
|
||||
conversation={conversation}
|
||||
currentAssistantMessage={currentAssistantMessage}
|
||||
chatTabStates={chatViewStateByTab}
|
||||
conversation={activeChatTabState.conversation}
|
||||
currentAssistantMessage={activeChatTabState.currentAssistantMessage}
|
||||
chatTabStates={chatTabStatesForRender}
|
||||
viewportAnchors={chatViewportAnchorByTab}
|
||||
isProcessing={isProcessing}
|
||||
isProcessing={activeIsProcessing}
|
||||
isStopping={isStopping}
|
||||
onStop={handleStop}
|
||||
onSubmit={handlePromptSubmit}
|
||||
|
|
@ -6475,10 +6374,11 @@ function App() {
|
|||
? { title: activeCodeSession.session.title }
|
||||
: null
|
||||
}
|
||||
pendingAskHumanRequests={pendingAskHumanRequests}
|
||||
allPermissionRequests={allPermissionRequests}
|
||||
permissionResponses={permissionResponses}
|
||||
autoPermissionDecisions={autoPermissionDecisions}
|
||||
pendingAskHumanRequests={activeChatTabState.pendingAskHumanRequests}
|
||||
allPermissionRequests={activeChatTabState.allPermissionRequests}
|
||||
permissionResponses={activeChatTabState.permissionResponses}
|
||||
autoPermissionDecisions={activeChatTabState.autoPermissionDecisions}
|
||||
isThinking={activeIsThinking}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
onAskHumanResponse={handleAskHumanResponse}
|
||||
isToolOpenForTab={isToolOpenForTab}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ import {
|
|||
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
|
||||
Code2, FolderOpen, LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import type { z } from 'zod'
|
||||
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -17,7 +15,7 @@ import { useBackgroundTaskAgentStatus } from '@/hooks/use-bg-task-agent-status'
|
|||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
import { RichMarkdownViewer } from '@/components/rich-markdown-viewer'
|
||||
import { HtmlFileViewer } from '@/components/html-file-viewer'
|
||||
|
|
@ -970,10 +968,9 @@ function SetupTab({
|
|||
// Runs history tab — list + drill-down transcript view
|
||||
//
|
||||
// Source of truth: `bg-tasks/<slug>/runs.log` — a plain-text file with one
|
||||
// runId per line (newest first). The actual transcripts live at the global
|
||||
// `$WorkDir/runs/<runId>.jsonl`, so this tab fetches runIds via the bg-task
|
||||
// IPC, then loads each Run through the standard `runs:fetch`. No bg-task-
|
||||
// specific transcript path or schema needed.
|
||||
// turn id per line (newest first). Transcripts live in the turn runtime's
|
||||
// storage; this tab fetches ids via the bg-task IPC, then loads each through
|
||||
// the shared agent-transcript loader (turn-first, legacy-run fallback).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunRowSummary {
|
||||
|
|
@ -984,27 +981,6 @@ interface RunRowSummary {
|
|||
error?: string
|
||||
}
|
||||
|
||||
// Pull the bits we want to display for a row out of a full Run's event log.
|
||||
function summarizeRun(run: z.infer<typeof Run>): RunRowSummary {
|
||||
const out: RunRowSummary = { runId: run.id, createdAt: run.createdAt, trigger: run.subUseCase }
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask }) {
|
||||
const [rows, setRows] = useState<RunRowSummary[]>([])
|
||||
|
|
@ -1016,19 +992,25 @@ function RunsHistoryTab({ slug, task }: { slug: string; task: BackgroundTask })
|
|||
setLoading(true)
|
||||
try {
|
||||
const { runIds } = await window.ipc.invoke('bg-task:listRunIds', { slug, limit: 100 })
|
||||
// Fetch each Run in parallel via the canonical IPC. Runs whose
|
||||
// jsonl no longer exists (deleted manually, never written, …) are
|
||||
// dropped silently.
|
||||
// Fetch transcripts in parallel (turn-first, legacy-run
|
||||
// fallback). Ids whose files no longer exist keep a bare row so
|
||||
// the user knows the run happened.
|
||||
const settled = await Promise.allSettled(
|
||||
runIds.map(runId => window.ipc.invoke('runs:fetch', { runId }))
|
||||
runIds.map(runId => fetchAgentRunTranscript(runId))
|
||||
)
|
||||
const next: RunRowSummary[] = []
|
||||
for (let i = 0; i < settled.length; i++) {
|
||||
const r = settled[i]
|
||||
if (r.status === 'fulfilled' && r.value) {
|
||||
next.push(summarizeRun(r.value))
|
||||
if (r.status === 'fulfilled') {
|
||||
const t = r.value
|
||||
next.push({
|
||||
runId: t.id,
|
||||
...(t.createdAt === undefined ? {} : { createdAt: t.createdAt }),
|
||||
...(t.trigger === undefined ? {} : { trigger: t.trigger }),
|
||||
...(t.summary === undefined ? {} : { summary: t.summary }),
|
||||
...(t.error === undefined ? {} : { error: t.error }),
|
||||
})
|
||||
} else {
|
||||
// Keep the row visible with just the id so the user knows it exists.
|
||||
next.push({ runId: runIds[i] })
|
||||
}
|
||||
}
|
||||
|
|
@ -1134,7 +1116,7 @@ function RunTranscriptView({
|
|||
isInFlight: boolean
|
||||
onBack: () => void
|
||||
}) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -1144,15 +1126,13 @@ function RunTranscriptView({
|
|||
setError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
// Bg-task transcripts now live at the global runs/ location —
|
||||
// same path resolution as every other run, no special handling.
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
|
|
@ -1160,8 +1140,8 @@ function RunTranscriptView({
|
|||
return () => { cancelled = true }
|
||||
}, [runId])
|
||||
|
||||
const summary = run ? summarizeRun(run) : undefined
|
||||
const items: ConversationItem[] = run ? runLogToConversation(run.log) : []
|
||||
const summary = transcript ?? undefined
|
||||
const items: ConversationItem[] = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
|
@ -1221,10 +1201,10 @@ function RunTranscriptView({
|
|||
Couldn't load transcript: {error}
|
||||
</div>
|
||||
)}
|
||||
{run && !loading && items.length === 0 && (
|
||||
{transcript && !loading && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loading && items.length > 0 && (
|
||||
{transcript && !loading && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -342,22 +342,15 @@ function ChatInputInner({
|
|||
}
|
||||
})
|
||||
|
||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||
// Sessions runtime: model and permission mode are per-message turn config,
|
||||
// so nothing is frozen for an existing chat — the picker stays live.
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setLockedModel(null)
|
||||
setPermissionMode('auto')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
window.ipc.invoke('runs:fetch', { runId }).then((run) => {
|
||||
if (cancelled) return
|
||||
if (run.provider && run.model) {
|
||||
setLockedModel({ provider: run.provider, model: run.model })
|
||||
}
|
||||
setPermissionMode(run.permissionMode ?? 'manual')
|
||||
}).catch(() => { /* legacy run or fetch failure — leave unlocked */ })
|
||||
return () => { cancelled = true }
|
||||
setLockedModel(null)
|
||||
}, [runId])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,10 @@ interface ChatSidebarProps {
|
|||
chatTabStates?: Record<string, ChatTabViewState>
|
||||
viewportAnchors?: Record<string, ChatViewportAnchorState>
|
||||
isProcessing: boolean
|
||||
// Actively working (sessions runtime). When provided, drives the shimmer
|
||||
// instead of isProcessing so waiting on a permission/ask-human doesn't
|
||||
// render a "Thinking…" under the card.
|
||||
isThinking?: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex', permissionMode?: PermissionMode) => void
|
||||
|
|
@ -211,6 +215,7 @@ export function ChatSidebar({
|
|||
chatTabStates = {},
|
||||
viewportAnchors = {},
|
||||
isProcessing,
|
||||
isThinking,
|
||||
isStopping,
|
||||
onStop,
|
||||
onSubmit,
|
||||
|
|
@ -373,7 +378,14 @@ export function ChatSidebar({
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
// Session-first (new runtime); legacy runs fallback covers old
|
||||
// background tabs until stage 7 removes the runs runtime.
|
||||
let result: { success: boolean; error?: string }
|
||||
try {
|
||||
result = await window.ipc.invoke('sessions:downloadLog', { sessionId: activeRunId })
|
||||
} catch {
|
||||
result = await window.ipc.invoke('runs:downloadLog', { runId: activeRunId })
|
||||
}
|
||||
if (result.success) {
|
||||
toast.success('Chat log saved')
|
||||
} else if (result.error) {
|
||||
|
|
@ -725,7 +737,7 @@ export function ChatSidebar({
|
|||
onApproveSession={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
onDeny={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
response={response}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -742,7 +754,7 @@ export function ChatSidebar({
|
|||
key={request.toolCallId}
|
||||
query={request.query}
|
||||
onResponse={(response) => onAskHumanResponse(request.toolCallId, request.subflow, response)}
|
||||
isProcessing={isActive && isProcessing}
|
||||
isProcessing={isActive && (isThinking ?? isProcessing)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -754,7 +766,7 @@ export function ChatSidebar({
|
|||
</Message>
|
||||
)}
|
||||
|
||||
{isActive && isProcessing && !tabState.currentAssistantMessage && (
|
||||
{isActive && (isThinking ?? isProcessing) && !tabState.currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<Shimmer duration={1}>Thinking...</Shimmer>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import {
|
|||
ChevronDown, ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { LiveNoteSchema, type LiveNote, type Triggers } from '@x/shared/dist/live-note.js'
|
||||
import type { Run } from '@x/shared/dist/runs.js'
|
||||
import type z from 'zod'
|
||||
import { useLiveNoteAgentStatus } from '@/hooks/use-live-note-agent-status'
|
||||
import { formatRelativeTime } from '@/lib/relative-time'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { fetchAgentRunTranscript, type AgentRunTranscript } from '@/lib/agent-transcript'
|
||||
import { CompactConversation } from '@/components/compact-conversation'
|
||||
|
||||
export type OpenLiveNotePanelDetail = {
|
||||
|
|
@ -661,7 +659,7 @@ function SectionRegion({ label, children }: { label?: string; children: React.Re
|
|||
}
|
||||
|
||||
function LastRunTab({ live }: { live: LiveNote }) {
|
||||
const [run, setRun] = useState<z.infer<typeof Run> | null>(null)
|
||||
const [transcript, setTranscript] = useState<AgentRunTranscript | null>(null)
|
||||
const [loadingRun, setLoadingRun] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -669,7 +667,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
setFetchError(null)
|
||||
setLoadingRun(false)
|
||||
return
|
||||
|
|
@ -679,13 +677,13 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
setFetchError(null)
|
||||
void (async () => {
|
||||
try {
|
||||
const r = await window.ipc.invoke('runs:fetch', { runId })
|
||||
const t = await fetchAgentRunTranscript(runId)
|
||||
if (cancelled) return
|
||||
setRun(r)
|
||||
setTranscript(t)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setFetchError(err instanceof Error ? err.message : String(err))
|
||||
setRun(null)
|
||||
setTranscript(null)
|
||||
} finally {
|
||||
if (!cancelled) setLoadingRun(false)
|
||||
}
|
||||
|
|
@ -704,7 +702,7 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
}
|
||||
|
||||
const isError = !!live.lastRunError
|
||||
const items = run ? runLogToConversation(run.log) : []
|
||||
const items = transcript?.items ?? []
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-4">
|
||||
|
|
@ -751,10 +749,10 @@ function LastRunTab({ live }: { live: LiveNote }) {
|
|||
Couldn't load transcript: {fetchError}
|
||||
</div>
|
||||
)}
|
||||
{run && !loadingRun && items.length === 0 && (
|
||||
{transcript && !loadingRun && items.length === 0 && (
|
||||
<p className="text-xs italic text-muted-foreground">No messages or tool calls recorded.</p>
|
||||
)}
|
||||
{run && !loadingRun && items.length > 0 && (
|
||||
{transcript && !loadingRun && items.length > 0 && (
|
||||
<CompactConversation items={items} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Calendar, ChevronDown, Clock, ExternalLink, Loader2, MapPin, Mic, Square, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Calendar, ChevronDown, ChevronRight, Clock, ExternalLink, FileText, Loader2, MapPin, Mic, Sparkles, Square, UserPlus, UserRound, UsersRound, Video, X } from 'lucide-react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -14,6 +15,39 @@ const MEETINGS_ROOT = 'knowledge/Meetings'
|
|||
const CALENDAR_DIR = 'calendar_sync'
|
||||
const UPCOMING_MAX_DAYS = 4 // today + next 3
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__pendingMeetingPrepCreate?: { prompt: string }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the `meeting-prep:resolve` IPC response shape.
|
||||
type PrepNote = {
|
||||
path: string
|
||||
name: string
|
||||
role?: string
|
||||
organization?: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepAttendee = {
|
||||
label: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
note: PrepNote | null
|
||||
}
|
||||
type PrepOrg = {
|
||||
path: string
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
type PrepResult = {
|
||||
attendees: PrepAttendee[]
|
||||
organizations: PrepOrg[]
|
||||
prepNote: { path: string; brief: string } | null
|
||||
matchedCount: number
|
||||
unmatchedCount: number
|
||||
}
|
||||
|
||||
type MeetingNoteRow = {
|
||||
path: string
|
||||
name: string
|
||||
|
|
@ -358,7 +392,178 @@ function parseDescriptionParts(value: string): DescriptionPart[] {
|
|||
}).filter((part) => part.text.length > 0)
|
||||
}
|
||||
|
||||
function UpcomingEvents() {
|
||||
// Hand the unmatched attendee off to the Copilot to research + create a note.
|
||||
function requestCreateNote(attendee: PrepAttendee, meetingSummary: string) {
|
||||
const who = attendee.displayName || attendee.label
|
||||
const email = attendee.email ? ` <${attendee.email}>` : ''
|
||||
window.__pendingMeetingPrepCreate = {
|
||||
prompt: `Create a person note in my knowledge base for ${who}${email}. They're attending my "${meetingSummary}" meeting. Pull together what you know about them from my emails, past meetings, and calendar.`,
|
||||
}
|
||||
window.dispatchEvent(new Event('meeting-prep:create-note'))
|
||||
}
|
||||
|
||||
// One note row (used for both people and organizations): a clickable row that
|
||||
// navigates to the note. The markdown is NOT rendered inline in the card.
|
||||
function PrepNoteRow({ title, subtitle, path, onOpenNote }: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
path: string
|
||||
onOpenNote: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(path)}
|
||||
title={`Open ${title}`}
|
||||
className="flex w-full items-center gap-3 border-b px-5 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[12.5px] font-semibold text-foreground">{title}</span>
|
||||
{subtitle ? <span className="truncate text-[11px] text-muted-foreground">{subtitle}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepAttendeeNote({ attendee, onOpenNote }: { attendee: PrepAttendee; onOpenNote: (path: string) => void }) {
|
||||
const note = attendee.note
|
||||
if (!note) return null
|
||||
const subtitle = [note.role, note.organization].filter(Boolean).join(' · ')
|
||||
return <PrepNoteRow title={note.name} subtitle={subtitle || undefined} path={note.path} onOpenNote={onOpenNote} />
|
||||
}
|
||||
|
||||
function PrepUnmatchedSection({ attendees, meetingSummary }: { attendees: PrepAttendee[]; meetingSummary: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
if (attendees.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{open ? <ChevronDown className="size-4 shrink-0 text-muted-foreground" /> : <ChevronRight className="size-4 shrink-0 text-muted-foreground" />}
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{attendees.length} {attendees.length === 1 ? 'other' : 'others'} — no notes yet
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="flex flex-col gap-1 px-5 pb-3 pl-12">
|
||||
{attendees.map((att, idx) => (
|
||||
<div key={`${att.email ?? att.label}-${idx}`} className="flex items-center justify-between gap-3 py-1">
|
||||
<span className="min-w-0 truncate text-sm text-foreground">{att.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => requestCreateNote(att, meetingSummary)}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<UserPlus className="size-3.5" />
|
||||
Create note
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline prep for a single event: resolves the attendees against the knowledge
|
||||
// base and renders their notes directly beneath the event row. Re-resolves when
|
||||
// a person note changes (e.g. after "Create note") so it stays fresh.
|
||||
function InlineMeetingPrep({ event, onOpenNote }: { event: UpcomingEvent; onOpenNote: (path: string) => void }) {
|
||||
const [prep, setPrep] = useState<PrepResult | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
try {
|
||||
const attendees = event.attendees.map((a) => ({ email: a.email, displayName: a.displayName, self: a.self }))
|
||||
const result = await window.ipc.invoke('meeting-prep:resolve', { attendees, eventId: event.id })
|
||||
if (!cancelled) setPrep(result)
|
||||
} catch (err) {
|
||||
console.error('Meeting prep failed:', err)
|
||||
if (!cancelled) setPrep(null)
|
||||
}
|
||||
}
|
||||
void run()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [event.id, refreshTick])
|
||||
|
||||
// Refresh when a People note is created/changed so newly-created notes appear.
|
||||
useEffect(() => {
|
||||
const isPeoplePath = (p: string | undefined) =>
|
||||
typeof p === 'string' && p.startsWith('knowledge/People/')
|
||||
const cleanup = window.ipc.on('workspace:didChange', (e) => {
|
||||
switch (e.type) {
|
||||
case 'created':
|
||||
case 'changed':
|
||||
case 'deleted':
|
||||
if (isPeoplePath(e.path)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'moved':
|
||||
if (isPeoplePath(e.from) || isPeoplePath(e.to)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
case 'bulkChanged':
|
||||
if (!e.paths || e.paths.some(isPeoplePath)) setRefreshTick((t) => t + 1)
|
||||
break
|
||||
}
|
||||
})
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
if (!prep || prep.attendees.length === 0) return null
|
||||
|
||||
const matched = prep.attendees.filter((a) => a.note)
|
||||
const unmatched = prep.attendees.filter((a) => !a.note)
|
||||
|
||||
return (
|
||||
<div className="bg-muted/10">
|
||||
{prep.prepNote && prep.prepNote.brief ? (
|
||||
<div className="border-b px-5 pb-3 pt-3">
|
||||
<Streamdown className="prose prose-sm dark:prose-invert max-w-none text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5 [&_p]:text-[12.5px] [&_li]:text-[12.5px]">
|
||||
{prep.prepNote.brief}
|
||||
</Streamdown>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenNote(prep.prepNote!.path)}
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText className="size-3.5" />
|
||||
Open full prep
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1.5 px-5 pb-1 pt-2.5">
|
||||
<UsersRound className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">People</span>
|
||||
</div>
|
||||
{matched.map((att, idx) => (
|
||||
<PrepAttendeeNote key={att.note!.path + idx} attendee={att} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
<PrepUnmatchedSection attendees={unmatched} meetingSummary={event.summary} />
|
||||
{prep.organizations.length > 0 ? (
|
||||
<>
|
||||
<div className="px-5 pb-1 pt-2.5">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{prep.organizations.length === 1 ? 'Company' : 'Companies'}
|
||||
</span>
|
||||
</div>
|
||||
{prep.organizations.map((org) => (
|
||||
<PrepNoteRow key={org.path} title={org.name} subtitle="Organization" path={org.path} onOpenNote={onOpenNote} />
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UpcomingEvents({ onOpenNote }: { onOpenNote: (path: string) => void }) {
|
||||
const [events, setEvents] = useState<UpcomingEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -487,6 +692,20 @@ function UpcomingEvents() {
|
|||
return selectVisibleDays(window)
|
||||
}, [events])
|
||||
|
||||
// The next meeting that's worth prepping for — soonest timed event with at
|
||||
// least one other attendee that hasn't ended. Its row gets inline prep.
|
||||
// `events` is sorted (all-day first, then by start), so `find` returns it.
|
||||
const prepEventId = useMemo(() => {
|
||||
const nowMs = Date.now()
|
||||
const candidate = events.find((ev) => {
|
||||
if (ev.isAllDay) return false
|
||||
if (ev.attendees.every((a) => a.self)) return false
|
||||
const endMs = ev.end ? ev.end.getTime() : ev.start.getTime() + 30 * 60 * 1000
|
||||
return endMs > nowMs
|
||||
})
|
||||
return candidate?.id ?? null
|
||||
}, [events])
|
||||
|
||||
const totalVisible = visibleDays.reduce((s, d) => s + d.events.length, 0)
|
||||
const now = new Date()
|
||||
const todayKey = localDateKey(now)
|
||||
|
|
@ -532,6 +751,8 @@ function UpcomingEvents() {
|
|||
key={day.dateKey}
|
||||
day={day}
|
||||
isToday={day.dateKey === todayKey}
|
||||
prepEventId={prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -542,7 +763,7 @@ function UpcomingEvents() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean }) {
|
||||
function UpcomingDayCard({ day, isToday, prepEventId, onOpenNote }: { day: DayGroup; isToday: boolean; prepEventId: string | null; onOpenNote: (path: string) => void }) {
|
||||
const dayNum = day.date.getDate()
|
||||
const month = day.date.toLocaleDateString([], { month: 'short' })
|
||||
const weekday = day.date.toLocaleDateString([], { weekday: 'short' })
|
||||
|
|
@ -573,7 +794,13 @@ function UpcomingDayCard({ day, isToday }: { day: DayGroup; isToday: boolean })
|
|||
</div>
|
||||
) : (
|
||||
day.events.map((ev, idx) => (
|
||||
<UpcomingEventItem key={ev.id} event={ev} isLast={idx === count - 1} />
|
||||
<UpcomingEventItem
|
||||
key={ev.id}
|
||||
event={ev}
|
||||
isLast={idx === count - 1}
|
||||
isPrepTarget={ev.id === prepEventId}
|
||||
onOpenNote={onOpenNote}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -588,14 +815,20 @@ function NowBadge() {
|
|||
)
|
||||
}
|
||||
|
||||
function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: boolean }) {
|
||||
function UpcomingEventItem({ event, isLast, isPrepTarget, onOpenNote }: { event: UpcomingEvent; isLast: boolean; isPrepTarget: boolean; onOpenNote: (path: string) => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The next meeting auto-expands its prep; any other meeting with attendees
|
||||
// can be expanded on demand via the Prep toggle (resolves lazily on open).
|
||||
const prepEligible = !event.isAllDay && event.attendees.some((a) => !a.self)
|
||||
const [prepOpen, setPrepOpen] = useState(isPrepTarget)
|
||||
const showPrep = prepEligible && prepOpen
|
||||
const isNow = isEventNow(event)
|
||||
const platform = meetingPlatformLabel(event.conferenceLink)
|
||||
const subtitle = platform ?? event.location
|
||||
const titleAndLocation = event.location ? `${event.summary} · ${event.location}` : event.summary
|
||||
|
||||
return (
|
||||
<div className={cn(!isLast && 'border-b')}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
|
|
@ -604,7 +837,7 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
title={titleAndLocation}
|
||||
className={cn(
|
||||
'group flex w-full cursor-pointer items-center gap-4 px-5 py-3 text-left transition-colors',
|
||||
!isLast && 'border-b',
|
||||
showPrep && 'border-b',
|
||||
isNow ? 'bg-muted' : 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
|
|
@ -625,7 +858,24 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="shrink-0">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{prepEligible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setPrepOpen((v) => !v) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-expanded={prepOpen}
|
||||
title={prepOpen ? 'Hide prep' : 'Show meeting prep'}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors',
|
||||
prepOpen ? 'bg-accent text-foreground' : 'bg-background text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Prep
|
||||
<ChevronDown className={cn('size-3 transition-transform', prepOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
{event.conferenceLink ? (
|
||||
<SplitJoinButton
|
||||
onJoinAndNotes={() => triggerMeetingCapture(event, true)}
|
||||
|
|
@ -647,6 +897,8 @@ function UpcomingEventItem({ event, isLast }: { event: UpcomingEvent; isLast: bo
|
|||
</PopoverTrigger>
|
||||
<EventDetailsPopover event={event} onClose={() => setOpen(false)} />
|
||||
</Popover>
|
||||
{showPrep ? <InlineMeetingPrep event={event} onOpenNote={onOpenNote} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -917,6 +1169,9 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
|
||||
const rows = entries
|
||||
.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.md'))
|
||||
// Generated prep notes live under Meetings/prep/ — they're upcoming
|
||||
// prep, not past meeting notes, so keep them out of this table.
|
||||
.filter((entry) => !entry.path.startsWith(`${MEETINGS_ROOT}/prep/`))
|
||||
.map((entry) => {
|
||||
const relative = entry.path.slice(`${MEETINGS_ROOT}/`.length)
|
||||
const parts = relative.split('/')
|
||||
|
|
@ -1017,7 +1272,7 @@ export function MeetingsView({ onOpenNote, onTakeMeetingNotes, meetingState, mee
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<UpcomingEvents />
|
||||
<UpcomingEvents onOpenNote={onOpenNote} />
|
||||
<div className="p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
|
|
|
|||
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
123
apps/x/apps/renderer/src/hooks/useSessionChat.test.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
import type { SessionsClient } from '@/lib/session-chat/client'
|
||||
import type { SessionFeedListener } from '@/lib/session-chat/feed'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
requested,
|
||||
sessionState,
|
||||
turnCompleted,
|
||||
user,
|
||||
} from '@/lib/session-chat/test-fixtures'
|
||||
import { isChatMessage } from '@/lib/chat-conversation'
|
||||
import { useSessionChat } from './useSessionChat'
|
||||
|
||||
const S1 = 'sess-1'
|
||||
|
||||
function makeDeps() {
|
||||
const calls: Array<{ method: string; args: unknown[] }> = []
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
let unsubscribed = 0
|
||||
const sessions = new Map([[S1, sessionState(S1, ['turn-1'])]])
|
||||
const turns = new Map([['turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1')]])
|
||||
const client: SessionsClient = {
|
||||
create: async () => ({ sessionId: 'x' }),
|
||||
list: async () => ({ sessions: [] }),
|
||||
get: async (sessionId) => {
|
||||
const state = sessions.get(sessionId)
|
||||
if (!state) throw new Error('session not found')
|
||||
return state
|
||||
},
|
||||
getTurn: async (turnId) => ({ turnId, events: turns.get(turnId) ?? [] }),
|
||||
sendMessage: async (...args) => {
|
||||
calls.push({ method: 'sendMessage', args })
|
||||
return { turnId: 'turn-2' }
|
||||
},
|
||||
respondToPermission: async (...args) => {
|
||||
calls.push({ method: 'respondToPermission', args })
|
||||
},
|
||||
respondToAskHuman: async (...args) => {
|
||||
calls.push({ method: 'respondToAskHuman', args })
|
||||
},
|
||||
stopTurn: async (...args) => {
|
||||
calls.push({ method: 'stopTurn', args })
|
||||
},
|
||||
resumeTurn: async () => undefined,
|
||||
setTitle: async () => undefined,
|
||||
delete: async () => undefined,
|
||||
}
|
||||
return {
|
||||
deps: {
|
||||
client,
|
||||
subscribeFeed: (listener: SessionFeedListener) => {
|
||||
emit = listener
|
||||
return () => {
|
||||
unsubscribed += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
calls,
|
||||
emit: (event: SessionBusEvent) => emit(event),
|
||||
getUnsubscribed: () => unsubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useSessionChat', () => {
|
||||
it('seeds from the session, follows live events, and routes actions', async () => {
|
||||
const { deps, calls, emit } = makeDeps()
|
||||
const { result } = renderHook(() => useSessionChat(S1, deps), { wrapper: StrictMode })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.latestTurnId).toBe('turn-1')
|
||||
})
|
||||
expect(
|
||||
result.current.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['q1', 'a1'])
|
||||
|
||||
// A new turn streams in over the feed.
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: created('turn-2', S1, user('q2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: requested('turn-2', 0) })
|
||||
emit({
|
||||
kind: 'turn-event',
|
||||
sessionId: S1,
|
||||
turnId: 'turn-2',
|
||||
event: { type: 'text_delta', turnId: 'turn-2', modelCallIndex: 0, delta: 'a2…' },
|
||||
})
|
||||
})
|
||||
expect(result.current.latestTurnId).toBe('turn-2')
|
||||
expect(result.current.chatState?.currentAssistantMessage).toBe('a2…')
|
||||
expect(result.current.chatState?.isProcessing).toBe(true)
|
||||
|
||||
act(() => {
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: completed('turn-2', 0, assistantText('a2')) })
|
||||
emit({ kind: 'turn-event', sessionId: S1, turnId: 'turn-2', event: turnCompleted('turn-2', 'a2') })
|
||||
})
|
||||
expect(result.current.chatState?.isProcessing).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.respondToPermission('tc1', 'deny')
|
||||
await result.current.stop()
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{ method: 'respondToPermission', args: ['turn-2', 'tc1', 'deny', undefined] },
|
||||
{ method: 'stopTurn', args: ['turn-2'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('unsubscribes from the feed on unmount (StrictMode double-mounts included)', async () => {
|
||||
const { deps, getUnsubscribed } = makeDeps()
|
||||
const { unmount } = renderHook(() => useSessionChat(S1, deps), {
|
||||
wrapper: StrictMode,
|
||||
})
|
||||
unmount()
|
||||
// StrictMode's simulated cleanup plus the real unmount: every subscribe
|
||||
// was matched by an unsubscribe.
|
||||
expect(getUnsubscribed()).toBe(2)
|
||||
})
|
||||
})
|
||||
36
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal file
36
apps/x/apps/renderer/src/hooks/useSessionChat.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { ipcSessionsClient } from '@/lib/session-chat/client'
|
||||
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
|
||||
import { SessionChatStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
|
||||
|
||||
const defaultDeps: SessionChatStoreDeps = {
|
||||
client: ipcSessionsClient,
|
||||
subscribeFeed: subscribeSessionFeed,
|
||||
}
|
||||
|
||||
// Thin subscription over SessionChatStore — all logic (seeding, feed events,
|
||||
// reducer, overlay, action routing) lives in the store, which is unit-tested
|
||||
// without React. `deps` is injectable for tests.
|
||||
export function useSessionChat(
|
||||
sessionId: string | null,
|
||||
deps: SessionChatStoreDeps = defaultDeps,
|
||||
) {
|
||||
const [store] = useState(() => new SessionChatStore(deps))
|
||||
useEffect(() => store.connect(), [store])
|
||||
useEffect(() => {
|
||||
void store.setSession(sessionId)
|
||||
}, [store, sessionId])
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
||||
return useMemo(
|
||||
() => ({
|
||||
...snapshot,
|
||||
sendMessage: store.sendMessage,
|
||||
respondToPermission: store.respondToPermission,
|
||||
answerAskHuman: store.answerAskHuman,
|
||||
stop: store.stop,
|
||||
}),
|
||||
[snapshot, store],
|
||||
)
|
||||
}
|
||||
|
||||
export type SessionChat = ReturnType<typeof useSessionChat>
|
||||
33
apps/x/apps/renderer/src/hooks/useSessions.ts
Normal file
33
apps/x/apps/renderer/src/hooks/useSessions.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import { ipcSessionsClient } from '@/lib/session-chat/client'
|
||||
import { subscribeSessionFeed } from '@/lib/session-chat/feed'
|
||||
import { SessionListStore, type SessionChatStoreDeps } from '@/lib/session-chat/store'
|
||||
|
||||
const defaultDeps: SessionChatStoreDeps = {
|
||||
client: ipcSessionsClient,
|
||||
subscribeFeed: subscribeSessionFeed,
|
||||
}
|
||||
|
||||
// The session list (chat history sidebar): seeded from sessions:list, kept
|
||||
// current by index-changed feed events. Logic lives in SessionListStore.
|
||||
export function useSessions(deps: SessionChatStoreDeps = defaultDeps) {
|
||||
const [store] = useState(() => new SessionListStore(deps))
|
||||
useEffect(() => {
|
||||
const disconnect = store.connect()
|
||||
void store.load()
|
||||
return disconnect
|
||||
}, [store])
|
||||
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
||||
const client = deps.client
|
||||
return useMemo(
|
||||
() => ({
|
||||
...snapshot,
|
||||
createSession: (input: { title?: string } = {}) => client.create(input),
|
||||
deleteSession: (sessionId: string) => client.delete(sessionId),
|
||||
setTitle: (sessionId: string, title: string) => client.setTitle(sessionId, title),
|
||||
}),
|
||||
[snapshot, client],
|
||||
)
|
||||
}
|
||||
|
||||
export type Sessions = ReturnType<typeof useSessions>
|
||||
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
28
apps/x/apps/renderer/src/lib/agent-transcript.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { turnToTranscript } from './agent-transcript'
|
||||
import { completedTurnLog, created, requested, user } from './session-chat/test-fixtures'
|
||||
|
||||
describe('turnToTranscript', () => {
|
||||
it('maps a completed turn to id/createdAt/summary/items', () => {
|
||||
const events = completedTurnLog('turn-1', 'sess-1', 'do the thing', 'all done')
|
||||
const t = turnToTranscript('turn-1', events)
|
||||
expect(t.id).toBe('turn-1')
|
||||
expect(t.createdAt).toBeDefined()
|
||||
expect(t.summary).toBe('all done')
|
||||
expect(t.error).toBeUndefined()
|
||||
expect(t.trigger).toBeUndefined()
|
||||
expect(t.items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('surfaces turn_failed as error with no summary', () => {
|
||||
const events = [
|
||||
created('turn-2', 'sess-1', user('go')),
|
||||
requested('turn-2', 0),
|
||||
{ type: 'model_call_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed' as const, turnId: 'turn-2', ts: '2026-07-02T10:00:00Z', error: 'boom', usage: {} },
|
||||
]
|
||||
const t = turnToTranscript('turn-2', events)
|
||||
expect(t.error).toBe('boom')
|
||||
expect(t.summary).toBeUndefined()
|
||||
})
|
||||
})
|
||||
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
89
apps/x/apps/renderer/src/lib/agent-transcript.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { z } from 'zod'
|
||||
import type { Run } from '@x/shared/src/runs.js'
|
||||
import { reduceTurn, type TurnEvent } from '@x/shared/src/turns.js'
|
||||
import type { ConversationItem } from '@/lib/chat-conversation'
|
||||
import { runLogToConversation } from '@/lib/run-to-conversation'
|
||||
import { buildTurnConversation } from '@/lib/session-chat/turn-view'
|
||||
|
||||
// Unified read model for a headless agent run's transcript, whether the id
|
||||
// is a turn (new runtime) or a legacy run (pre-turn-runtime files under
|
||||
// $WorkDir/runs/). Used by the background-tasks and live-note history views.
|
||||
|
||||
export interface AgentRunTranscript {
|
||||
id: string
|
||||
createdAt?: string
|
||||
// Legacy runs only (subUseCase); turns carry the trigger inside the
|
||||
// message text instead.
|
||||
trigger?: string
|
||||
summary?: string
|
||||
error?: string
|
||||
items: ConversationItem[]
|
||||
}
|
||||
|
||||
export function turnToTranscript(
|
||||
turnId: string,
|
||||
events: Array<z.infer<typeof TurnEvent>>,
|
||||
): AgentRunTranscript {
|
||||
const state = reduceTurn(events)
|
||||
const out: AgentRunTranscript = {
|
||||
id: turnId,
|
||||
createdAt: state.definition.ts,
|
||||
items: buildTurnConversation(state),
|
||||
}
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
out.error = state.terminal.error
|
||||
}
|
||||
for (let i = state.modelCalls.length - 1; i >= 0; i--) {
|
||||
const response = state.modelCalls[i].response
|
||||
if (!response) continue
|
||||
const content = response.content
|
||||
const text =
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content.map((p) => (p.type === 'text' ? p.text : '')).join('')
|
||||
if (text) {
|
||||
out.summary = text
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function runToTranscript(run: z.infer<typeof Run>): AgentRunTranscript {
|
||||
const out: AgentRunTranscript = {
|
||||
id: run.id,
|
||||
createdAt: run.createdAt,
|
||||
trigger: run.subUseCase,
|
||||
items: runLogToConversation(run.log),
|
||||
}
|
||||
for (const event of run.log) {
|
||||
if (event.type === 'error' && typeof event.error === 'string') {
|
||||
out.error = event.error
|
||||
} else if (event.type === 'message' && event.message?.role === 'assistant') {
|
||||
const content = event.message.content
|
||||
if (typeof content === 'string') {
|
||||
out.summary = content
|
||||
} else if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => ('text' in p ? p.text : ''))
|
||||
.join('')
|
||||
if (text) out.summary = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Turn-first with a legacy-run fallback so histories recorded before the
|
||||
// turn-runtime migration stay readable. The fallback goes away with the
|
||||
// runs runtime (stage 7).
|
||||
export async function fetchAgentRunTranscript(id: string): Promise<AgentRunTranscript> {
|
||||
try {
|
||||
const turn = await window.ipc.invoke('sessions:getTurn', { turnId: id })
|
||||
return turnToTranscript(id, turn.events)
|
||||
} catch {
|
||||
const run = await window.ipc.invoke('runs:fetch', { runId: id })
|
||||
return runToTranscript(run)
|
||||
}
|
||||
}
|
||||
70
apps/x/apps/renderer/src/lib/session-chat/client.ts
Normal file
70
apps/x/apps/renderer/src/lib/session-chat/client.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { z } from 'zod'
|
||||
import type { UserMessage } from '@x/shared/src/message.js'
|
||||
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
|
||||
import type { JsonValue, RequestedAgent, TurnEvent } from '@x/shared/src/turns.js'
|
||||
|
||||
// Narrow, injectable surface over the sessions IPC channels so stores are
|
||||
// testable with a plain fake instead of a window.ipc stub.
|
||||
export interface SendMessageConfig {
|
||||
agent: z.infer<typeof RequestedAgent>
|
||||
autoPermission?: boolean
|
||||
maxModelCalls?: number
|
||||
}
|
||||
|
||||
export interface SessionsClient {
|
||||
create(input: { title?: string }): Promise<{ sessionId: string }>
|
||||
list(): Promise<{ sessions: SessionIndexEntry[] }>
|
||||
get(sessionId: string): Promise<SessionState>
|
||||
getTurn(turnId: string): Promise<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
input: z.infer<typeof UserMessage>,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }>
|
||||
respondToPermission(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
metadata?: JsonValue,
|
||||
): Promise<void>
|
||||
respondToAskHuman(turnId: string, toolCallId: string, answer: string): Promise<void>
|
||||
stopTurn(turnId: string, reason?: string): Promise<void>
|
||||
resumeTurn(sessionId: string): Promise<void>
|
||||
setTitle(sessionId: string, title: string): Promise<void>
|
||||
delete(sessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
export const ipcSessionsClient: SessionsClient = {
|
||||
create: (input) => window.ipc.invoke('sessions:create', input),
|
||||
list: () => window.ipc.invoke('sessions:list', {}),
|
||||
get: (sessionId) => window.ipc.invoke('sessions:get', { sessionId }),
|
||||
getTurn: (turnId) => window.ipc.invoke('sessions:getTurn', { turnId }),
|
||||
sendMessage: (sessionId, input, config) =>
|
||||
window.ipc.invoke('sessions:sendMessage', { sessionId, input, config }),
|
||||
respondToPermission: async (turnId, toolCallId, decision, metadata) => {
|
||||
await window.ipc.invoke('sessions:respondToPermission', {
|
||||
turnId,
|
||||
toolCallId,
|
||||
decision,
|
||||
...(metadata === undefined ? {} : { metadata }),
|
||||
})
|
||||
},
|
||||
respondToAskHuman: async (turnId, toolCallId, answer) => {
|
||||
await window.ipc.invoke('sessions:respondToAskHuman', { turnId, toolCallId, answer })
|
||||
},
|
||||
stopTurn: async (turnId, reason) => {
|
||||
await window.ipc.invoke('sessions:stopTurn', {
|
||||
turnId,
|
||||
...(reason === undefined ? {} : { reason }),
|
||||
})
|
||||
},
|
||||
resumeTurn: async (sessionId) => {
|
||||
await window.ipc.invoke('sessions:resumeTurn', { sessionId })
|
||||
},
|
||||
setTitle: async (sessionId, title) => {
|
||||
await window.ipc.invoke('sessions:setTitle', { sessionId, title })
|
||||
},
|
||||
delete: async (sessionId) => {
|
||||
await window.ipc.invoke('sessions:delete', { sessionId })
|
||||
},
|
||||
}
|
||||
52
apps/x/apps/renderer/src/lib/session-chat/feed.test.ts
Normal file
52
apps/x/apps/renderer/src/lib/session-chat/feed.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
import { createSessionFeed, type SessionFeedListener } from './feed'
|
||||
|
||||
const event: SessionBusEvent = {
|
||||
kind: 'index-changed',
|
||||
sessionId: 's1',
|
||||
entry: null,
|
||||
}
|
||||
|
||||
describe('createSessionFeed', () => {
|
||||
it('starts the source lazily on first subscribe and fans events out', () => {
|
||||
let sourceStarted = 0
|
||||
let push: SessionFeedListener = () => undefined
|
||||
const feed = createSessionFeed((listener) => {
|
||||
sourceStarted += 1
|
||||
push = listener
|
||||
return () => undefined
|
||||
})
|
||||
expect(sourceStarted).toBe(0)
|
||||
|
||||
const seenA: SessionBusEvent[] = []
|
||||
const seenB: SessionBusEvent[] = []
|
||||
feed.subscribe((e) => seenA.push(e))
|
||||
feed.subscribe((e) => seenB.push(e))
|
||||
expect(sourceStarted).toBe(1) // one shared IPC listener
|
||||
|
||||
push(event)
|
||||
expect(seenA).toEqual([event])
|
||||
expect(seenB).toEqual([event])
|
||||
})
|
||||
|
||||
it('unsubscribes cleanly and isolates a throwing listener', () => {
|
||||
let push: SessionFeedListener = () => undefined
|
||||
const feed = createSessionFeed((listener) => {
|
||||
push = listener
|
||||
return () => undefined
|
||||
})
|
||||
const seen: SessionBusEvent[] = []
|
||||
feed.subscribe(() => {
|
||||
throw new Error('bad subscriber')
|
||||
})
|
||||
const unsubscribe = feed.subscribe((e) => seen.push(e))
|
||||
|
||||
push(event)
|
||||
expect(seen).toEqual([event])
|
||||
|
||||
unsubscribe()
|
||||
push(event)
|
||||
expect(seen).toEqual([event])
|
||||
})
|
||||
})
|
||||
42
apps/x/apps/renderer/src/lib/session-chat/feed.ts
Normal file
42
apps/x/apps/renderer/src/lib/session-chat/feed.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { SessionBusEvent } from '@x/shared/src/sessions.js'
|
||||
|
||||
export type SessionFeedListener = (event: SessionBusEvent) => void
|
||||
export type SessionFeedSource = (listener: SessionFeedListener) => () => void
|
||||
|
||||
// One shared consumer of the sessions:events push channel; stores tap this
|
||||
// fan-out instead of each opening their own IPC listener. Factory so tests
|
||||
// can drive a fake source.
|
||||
export function createSessionFeed(source: SessionFeedSource) {
|
||||
const listeners = new Set<SessionFeedListener>()
|
||||
let detach: (() => void) | null = null
|
||||
|
||||
const ensureStarted = () => {
|
||||
if (detach) return
|
||||
detach = source((event) => {
|
||||
// Copy so (un)subscribing during dispatch is safe.
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(event)
|
||||
} catch {
|
||||
// A misbehaving subscriber must never break the feed.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe(listener: SessionFeedListener): () => void {
|
||||
ensureStarted()
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const appFeed = createSessionFeed((listener) => window.ipc.on('sessions:events', listener))
|
||||
|
||||
export function subscribeSessionFeed(listener: SessionFeedListener): () => void {
|
||||
return appFeed.subscribe(listener)
|
||||
}
|
||||
314
apps/x/apps/renderer/src/lib/session-chat/store.test.ts
Normal file
314
apps/x/apps/renderer/src/lib/session-chat/store.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionBusEvent, SessionState } from '@x/shared/src/sessions.js'
|
||||
import { isChatMessage } from '@/lib/chat-conversation'
|
||||
import type { SessionsClient } from './client'
|
||||
import type { SessionFeedListener } from './feed'
|
||||
import { SessionChatStore, SessionListStore } from './store'
|
||||
import {
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
indexEntry,
|
||||
requested,
|
||||
sessionState,
|
||||
turnCompleted,
|
||||
user,
|
||||
type TEvent,
|
||||
} from './test-fixtures'
|
||||
|
||||
const S1 = 'sess-1'
|
||||
|
||||
class FakeClient implements SessionsClient {
|
||||
sessions = new Map<string, SessionState>()
|
||||
turns = new Map<string, TEvent[]>()
|
||||
calls: Array<{ method: string; args: unknown[] }> = []
|
||||
// When set, get() defers until the returned resolver is invoked.
|
||||
deferredGet: (() => void) | null = null
|
||||
|
||||
private record(method: string, ...args: unknown[]) {
|
||||
this.calls.push({ method, args })
|
||||
}
|
||||
|
||||
async create(input: { title?: string }) {
|
||||
this.record('create', input)
|
||||
return { sessionId: 'new-session' }
|
||||
}
|
||||
async list() {
|
||||
this.record('list')
|
||||
return { sessions: [...this.sessions.keys()].map((id) => indexEntry(id)) }
|
||||
}
|
||||
async get(sessionId: string) {
|
||||
this.record('get', sessionId)
|
||||
if (this.deferredGet === null) {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (!state) throw new Error(`session not found: ${sessionId}`)
|
||||
return state
|
||||
}
|
||||
return new Promise<SessionState>((resolve, reject) => {
|
||||
this.deferredGet = () => {
|
||||
const state = this.sessions.get(sessionId)
|
||||
if (state) resolve(state)
|
||||
else reject(new Error(`session not found: ${sessionId}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
async getTurn(turnId: string) {
|
||||
this.record('getTurn', turnId)
|
||||
const events = this.turns.get(turnId)
|
||||
if (!events) throw new Error(`turn not found: ${turnId}`)
|
||||
return { turnId, events }
|
||||
}
|
||||
async sendMessage(sessionId: string, input: unknown, config: unknown) {
|
||||
this.record('sendMessage', sessionId, input, config)
|
||||
return { turnId: 'turn-next' }
|
||||
}
|
||||
async respondToPermission(...args: unknown[]) {
|
||||
this.record('respondToPermission', ...args)
|
||||
}
|
||||
async respondToAskHuman(...args: unknown[]) {
|
||||
this.record('respondToAskHuman', ...args)
|
||||
}
|
||||
async stopTurn(...args: unknown[]) {
|
||||
this.record('stopTurn', ...args)
|
||||
}
|
||||
async resumeTurn(...args: unknown[]) {
|
||||
this.record('resumeTurn', ...args)
|
||||
}
|
||||
async setTitle(...args: unknown[]) {
|
||||
this.record('setTitle', ...args)
|
||||
}
|
||||
async delete(...args: unknown[]) {
|
||||
this.record('delete', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
const client = new FakeClient()
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
let subscribed = 0
|
||||
let unsubscribed = 0
|
||||
const store = new SessionChatStore({
|
||||
client,
|
||||
subscribeFeed: (listener) => {
|
||||
subscribed += 1
|
||||
emit = listener
|
||||
return () => {
|
||||
unsubscribed += 1
|
||||
emit = () => undefined
|
||||
}
|
||||
},
|
||||
})
|
||||
const disconnect = store.connect()
|
||||
return {
|
||||
client,
|
||||
store,
|
||||
disconnect,
|
||||
emit: (event: SessionBusEvent) => emit(event),
|
||||
getSubscribed: () => subscribed,
|
||||
getUnsubscribed: () => unsubscribed,
|
||||
}
|
||||
}
|
||||
|
||||
function turnEvent(
|
||||
sessionId: string,
|
||||
turnId: string,
|
||||
event:
|
||||
| TEvent
|
||||
| { type: 'text_delta' | 'reasoning_delta'; turnId: string; modelCallIndex: number; delta: string },
|
||||
): SessionBusEvent {
|
||||
return { kind: 'turn-event', sessionId, turnId, event }
|
||||
}
|
||||
|
||||
describe('SessionChatStore', () => {
|
||||
it('seeds a session: prior turns frozen, latest live, conversation composed', async () => {
|
||||
const { client, store } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1', 'turn-2']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
client.turns.set('turn-2', completedTurnLog('turn-2', S1, 'second?', 'second answer'))
|
||||
|
||||
await store.setSession(S1)
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.loading).toBe(false)
|
||||
expect(snapshot.latestTurnId).toBe('turn-2')
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['first?', 'first answer', 'second?', 'second answer'])
|
||||
expect(snapshot.chatState?.isProcessing).toBe(false)
|
||||
})
|
||||
|
||||
it('applies live durable events through the shared reducer', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
expect(store.getSnapshot().chatState?.isThinking).toBe(true)
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.latestTurnId).toBe('turn-1')
|
||||
expect(snapshot.chatState?.isProcessing).toBe(false)
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['go', 'done'])
|
||||
})
|
||||
|
||||
it('accumulates text deltas and clears them on the canonical response', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'he' }))
|
||||
emit(turnEvent(S1, 'turn-1', { type: 'text_delta', turnId: 'turn-1', modelCallIndex: 0, delta: 'y' }))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('hey')
|
||||
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('hey'))))
|
||||
expect(store.getSnapshot().chatState?.currentAssistantMessage).toBe('')
|
||||
})
|
||||
|
||||
it('freezes the previous latest turn when a new turn starts', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'q1', 'a1'))
|
||||
await store.setSession(S1)
|
||||
|
||||
emit(turnEvent(S1, 'turn-2', created('turn-2', S1, user('q2'))))
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.latestTurnId).toBe('turn-2')
|
||||
expect(
|
||||
snapshot.chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['q1', 'a1', 'q2'])
|
||||
})
|
||||
|
||||
it('ignores events for other sessions', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent('other-session', 'turn-x', created('turn-x', 'other-session')))
|
||||
expect(store.getSnapshot().chatState?.conversation).toEqual([])
|
||||
})
|
||||
|
||||
it('reconciles an unknown mid-turn event by refetching the turn', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
// The feed attached mid-turn: we never saw turn_created for turn-9.
|
||||
client.turns.set('turn-9', [
|
||||
created('turn-9', S1, user('missed')),
|
||||
requested('turn-9', 0),
|
||||
])
|
||||
emit(turnEvent(S1, 'turn-9', completed('turn-9', 0, assistantText('caught up'))))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(store.getSnapshot().latestTurnId).toBe('turn-9')
|
||||
})
|
||||
|
||||
it('routes actions against the latest turn', async () => {
|
||||
const { client, store, emit } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1)))
|
||||
|
||||
await store.sendMessage(user('next'), { agent: { agentId: 'copilot' } })
|
||||
await store.respondToPermission('tc1', 'allow')
|
||||
await store.answerAskHuman('ah1', '42')
|
||||
await store.stop()
|
||||
|
||||
expect(client.calls.filter((c) => c.method !== 'get' && c.method !== 'getTurn')).toEqual([
|
||||
{ method: 'sendMessage', args: [S1, user('next'), { agent: { agentId: 'copilot' } }] },
|
||||
{ method: 'respondToPermission', args: ['turn-1', 'tc1', 'allow', undefined] },
|
||||
{ method: 'respondToAskHuman', args: ['turn-1', 'ah1', '42'] },
|
||||
{ method: 'stopTurn', args: ['turn-1'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects sendMessage without an active session', async () => {
|
||||
const { store } = makeStore()
|
||||
await expect(
|
||||
store.sendMessage(user('x'), { agent: { agentId: 'copilot' } }),
|
||||
).rejects.toThrowError('No active session')
|
||||
})
|
||||
|
||||
it('drops stale loads after a session switch', async () => {
|
||||
const { client, store } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, ['turn-1']))
|
||||
client.turns.set('turn-1', completedTurnLog('turn-1', S1, 'old', 'old answer'))
|
||||
client.sessions.set('sess-2', sessionState('sess-2', []))
|
||||
|
||||
client.deferredGet = () => undefined // arm deferral
|
||||
const first = store.setSession(S1)
|
||||
const release = client.deferredGet
|
||||
client.deferredGet = null
|
||||
const second = store.setSession('sess-2')
|
||||
release?.() // S1's get resolves after the switch
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(store.getSnapshot().sessionId).toBe('sess-2')
|
||||
expect(store.getSnapshot().chatState?.conversation).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces load errors and disconnects its feed subscription', async () => {
|
||||
const { store, disconnect, getUnsubscribed } = makeStore()
|
||||
await store.setSession('missing-session')
|
||||
expect(store.getSnapshot().error).toMatch(/session not found/)
|
||||
disconnect()
|
||||
expect(getUnsubscribed()).toBe(1)
|
||||
void store
|
||||
})
|
||||
|
||||
it('survives a StrictMode-style connect -> cleanup -> connect cycle', async () => {
|
||||
// React StrictMode runs every effect's mount/cleanup/mount cycle in dev;
|
||||
// the feed must re-attach or the store goes permanently deaf (the bug
|
||||
// this test pins: a constructor-made subscription torn down by the first
|
||||
// cleanup was never restored, leaving the chat stuck at "Thinking…").
|
||||
const { client, store, disconnect, emit, getSubscribed } = makeStore()
|
||||
client.sessions.set(S1, sessionState(S1, []))
|
||||
disconnect()
|
||||
const disconnect2 = store.connect()
|
||||
expect(getSubscribed()).toBe(2)
|
||||
|
||||
await store.setSession(S1)
|
||||
emit(turnEvent(S1, 'turn-1', created('turn-1', S1, user('go'))))
|
||||
emit(turnEvent(S1, 'turn-1', requested('turn-1', 0)))
|
||||
emit(turnEvent(S1, 'turn-1', completed('turn-1', 0, assistantText('done'))))
|
||||
emit(turnEvent(S1, 'turn-1', turnCompleted('turn-1')))
|
||||
expect(store.getSnapshot().chatState?.isProcessing).toBe(false)
|
||||
expect(
|
||||
store.getSnapshot().chatState?.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['go', 'done'])
|
||||
disconnect2()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionListStore', () => {
|
||||
it('loads, applies index updates and deletions, and sorts by updatedAt', async () => {
|
||||
const client = new FakeClient()
|
||||
client.sessions.set('a', sessionState('a', []))
|
||||
client.sessions.set('b', sessionState('b', []))
|
||||
let emit: SessionFeedListener = () => undefined
|
||||
const store = new SessionListStore({
|
||||
client,
|
||||
subscribeFeed: (listener) => {
|
||||
emit = listener
|
||||
return () => undefined
|
||||
},
|
||||
})
|
||||
store.connect()
|
||||
await store.load()
|
||||
expect(store.getSnapshot().sessions.map((s) => s.sessionId).sort()).toEqual(['a', 'b'])
|
||||
|
||||
emit({
|
||||
kind: 'index-changed',
|
||||
sessionId: 'c',
|
||||
entry: indexEntry('c', { updatedAt: '2026-07-03T00:00:00Z' }),
|
||||
})
|
||||
expect(store.getSnapshot().sessions[0].sessionId).toBe('c')
|
||||
|
||||
emit({ kind: 'index-changed', sessionId: 'a', entry: null })
|
||||
expect(store.getSnapshot().sessions.map((s) => s.sessionId)).toEqual(['c', 'b'])
|
||||
})
|
||||
})
|
||||
314
apps/x/apps/renderer/src/lib/session-chat/store.ts
Normal file
314
apps/x/apps/renderer/src/lib/session-chat/store.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import type { z } from 'zod'
|
||||
import type { UserMessage } from '@x/shared/src/message.js'
|
||||
import type { SessionBusEvent, SessionIndexEntry } from '@x/shared/src/sessions.js'
|
||||
import {
|
||||
reduceTurn,
|
||||
type JsonValue,
|
||||
type TurnEvent,
|
||||
type TurnState,
|
||||
type TurnStreamEvent,
|
||||
} from '@x/shared/src/turns.js'
|
||||
import type { SendMessageConfig, SessionsClient } from './client'
|
||||
import type { SessionFeedListener } from './feed'
|
||||
import {
|
||||
applyOverlay,
|
||||
buildSessionChatState,
|
||||
emptyOverlay,
|
||||
type LiveOverlay,
|
||||
type SessionChatState,
|
||||
} from './turn-view'
|
||||
|
||||
type TEvent = z.infer<typeof TurnEvent>
|
||||
|
||||
export interface SessionChatSnapshot {
|
||||
sessionId: string | null
|
||||
chatState: SessionChatState | null
|
||||
latestTurnId: string | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SessionChatStoreDeps {
|
||||
client: SessionsClient
|
||||
subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
}
|
||||
|
||||
// Framework-agnostic controller for one active session's chat. Owns all the
|
||||
// logic (seeding via getSession/getTurn, applying live feed events with the
|
||||
// shared reducer, the ephemeral overlay, action routing); the useSessionChat
|
||||
// hook is a thin useSyncExternalStore subscription over it.
|
||||
export class SessionChatStore {
|
||||
private readonly client: SessionsClient
|
||||
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
private feedDisconnect: (() => void) | null = null
|
||||
private readonly listeners = new Set<() => void>()
|
||||
|
||||
private sessionId: string | null = null
|
||||
// Settled earlier turns, reduced once and frozen.
|
||||
private priorTurns: TurnState[] = []
|
||||
// The latest turn's raw event log; re-reduced on each durable event.
|
||||
private latestEvents: TEvent[] | null = null
|
||||
private overlay: LiveOverlay = emptyOverlay()
|
||||
private loading = false
|
||||
private error: string | null = null
|
||||
// Guards stale async loads after a session switch.
|
||||
private generation = 0
|
||||
|
||||
private snapshot: SessionChatSnapshot = {
|
||||
sessionId: null,
|
||||
chatState: null,
|
||||
latestTurnId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
constructor(deps: SessionChatStoreDeps) {
|
||||
this.client = deps.client
|
||||
this.subscribeFeed = deps.subscribeFeed
|
||||
}
|
||||
|
||||
// Feed attachment is effect-managed and idempotent so React StrictMode's
|
||||
// mount -> cleanup -> mount cycle re-attaches cleanly (a constructor-made
|
||||
// subscription would be torn down by the first cleanup and never restored).
|
||||
connect(): () => void {
|
||||
if (!this.feedDisconnect) {
|
||||
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
|
||||
}
|
||||
return () => {
|
||||
this.feedDisconnect?.()
|
||||
this.feedDisconnect = null
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (onChange: () => void): (() => void) => {
|
||||
this.listeners.add(onChange)
|
||||
return () => {
|
||||
this.listeners.delete(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot = (): SessionChatSnapshot => this.snapshot
|
||||
|
||||
async setSession(sessionId: string | null): Promise<void> {
|
||||
if (sessionId === this.sessionId) return
|
||||
this.generation += 1
|
||||
const generation = this.generation
|
||||
this.sessionId = sessionId
|
||||
this.priorTurns = []
|
||||
this.latestEvents = null
|
||||
this.overlay = emptyOverlay()
|
||||
this.error = null
|
||||
this.loading = sessionId !== null
|
||||
this.emit()
|
||||
if (sessionId === null) return
|
||||
|
||||
try {
|
||||
const state = await this.client.get(sessionId)
|
||||
const turns = await Promise.all(
|
||||
state.turns.map((ref) => this.client.getTurn(ref.turnId)),
|
||||
)
|
||||
if (generation !== this.generation) return
|
||||
const reduced = turns.map((turn) => reduceTurn(turn.events))
|
||||
this.priorTurns = reduced.slice(0, -1)
|
||||
this.latestEvents = turns.length > 0 ? turns[turns.length - 1].events : null
|
||||
this.loading = false
|
||||
this.emit()
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return
|
||||
console.error('[session-chat] failed to load session', sessionId, error)
|
||||
this.loading = false
|
||||
this.error = error instanceof Error ? error.message : String(error)
|
||||
this.emit()
|
||||
}
|
||||
}
|
||||
|
||||
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
|
||||
if (event.kind !== 'turn-event' || event.sessionId !== this.sessionId) return
|
||||
const turnEvent = event.event
|
||||
if (isDurable(turnEvent)) {
|
||||
if (turnEvent.type === 'turn_created') {
|
||||
// A new turn started for this session: freeze the previous latest.
|
||||
this.freezeLatest()
|
||||
this.latestEvents = [turnEvent]
|
||||
this.overlay = emptyOverlay()
|
||||
} else if (this.latestEvents && this.latestEvents[0].turnId === turnEvent.turnId) {
|
||||
this.latestEvents.push(turnEvent)
|
||||
} else {
|
||||
// An event for a turn we haven't seen (missed turn_created, e.g. the
|
||||
// feed attached mid-turn): reconcile by refetching that turn.
|
||||
void this.reloadTurn(event.turnId)
|
||||
return
|
||||
}
|
||||
}
|
||||
this.overlay = applyOverlay(this.overlay, turnEvent)
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private freezeLatest(): void {
|
||||
if (!this.latestEvents) return
|
||||
try {
|
||||
this.priorTurns = [...this.priorTurns, reduceTurn(this.latestEvents)]
|
||||
} catch {
|
||||
// A turn we can't reduce is dropped from history rather than wedging
|
||||
// the whole conversation.
|
||||
}
|
||||
this.latestEvents = null
|
||||
}
|
||||
|
||||
private async reloadTurn(turnId: string): Promise<void> {
|
||||
const generation = this.generation
|
||||
try {
|
||||
const turn = await this.client.getTurn(turnId)
|
||||
if (generation !== this.generation) return
|
||||
if (this.latestEvents && this.latestEvents[0].turnId !== turnId) {
|
||||
this.freezeLatest()
|
||||
}
|
||||
this.latestEvents = turn.events
|
||||
this.emit()
|
||||
} catch (error) {
|
||||
// The next snapshot-worthy event will retry.
|
||||
console.error('[session-chat] failed to reload turn', turnId, error)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
sendMessage = async (
|
||||
input: z.infer<typeof UserMessage>,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }> => {
|
||||
if (!this.sessionId) throw new Error('No active session')
|
||||
return this.client.sendMessage(this.sessionId, input, config)
|
||||
}
|
||||
|
||||
respondToPermission = async (
|
||||
toolCallId: string,
|
||||
decision: 'allow' | 'deny',
|
||||
metadata?: JsonValue,
|
||||
): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.respondToPermission(turnId, toolCallId, decision, metadata)
|
||||
}
|
||||
|
||||
answerAskHuman = async (toolCallId: string, answer: string): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.respondToAskHuman(turnId, toolCallId, answer)
|
||||
}
|
||||
|
||||
stop = async (): Promise<void> => {
|
||||
const turnId = this.snapshot.latestTurnId
|
||||
if (!turnId) return
|
||||
await this.client.stopTurn(turnId)
|
||||
}
|
||||
|
||||
// ── Derivation ──────────────────────────────────────────────────────────
|
||||
|
||||
private emit(): void {
|
||||
this.snapshot = this.derive()
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
private derive(): SessionChatSnapshot {
|
||||
let turns = this.priorTurns
|
||||
let error = this.error
|
||||
if (this.latestEvents) {
|
||||
try {
|
||||
turns = [...this.priorTurns, reduceTurn(this.latestEvents)]
|
||||
} catch (reduceError) {
|
||||
error =
|
||||
reduceError instanceof Error ? reduceError.message : String(reduceError)
|
||||
}
|
||||
}
|
||||
const latest = turns[turns.length - 1]
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
chatState:
|
||||
this.sessionId !== null && !this.loading
|
||||
? buildSessionChatState(turns, this.overlay)
|
||||
: null,
|
||||
latestTurnId: latest?.definition.turnId ?? null,
|
||||
loading: this.loading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isDurable(event: TurnStreamEvent): event is TEvent {
|
||||
return event.type !== 'text_delta' && event.type !== 'reasoning_delta'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session list store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SessionListSnapshot {
|
||||
sessions: SessionIndexEntry[]
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export class SessionListStore {
|
||||
private readonly client: SessionsClient
|
||||
private readonly subscribeFeed: (listener: SessionFeedListener) => () => void
|
||||
private feedDisconnect: (() => void) | null = null
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private entries = new Map<string, SessionIndexEntry>()
|
||||
private loading = true
|
||||
private snapshot: SessionListSnapshot = { sessions: [], loading: true }
|
||||
|
||||
constructor(deps: SessionChatStoreDeps) {
|
||||
this.client = deps.client
|
||||
this.subscribeFeed = deps.subscribeFeed
|
||||
}
|
||||
|
||||
connect(): () => void {
|
||||
if (!this.feedDisconnect) {
|
||||
this.feedDisconnect = this.subscribeFeed(this.onFeedEvent)
|
||||
}
|
||||
return () => {
|
||||
this.feedDisconnect?.()
|
||||
this.feedDisconnect = null
|
||||
}
|
||||
}
|
||||
|
||||
subscribe = (onChange: () => void): (() => void) => {
|
||||
this.listeners.add(onChange)
|
||||
return () => {
|
||||
this.listeners.delete(onChange)
|
||||
}
|
||||
}
|
||||
|
||||
getSnapshot = (): SessionListSnapshot => this.snapshot
|
||||
|
||||
async load(): Promise<void> {
|
||||
const { sessions } = await this.client.list()
|
||||
this.entries = new Map(sessions.map((entry) => [entry.sessionId, entry]))
|
||||
this.loading = false
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private onFeedEvent: SessionFeedListener = (event: SessionBusEvent) => {
|
||||
if (event.kind !== 'index-changed') return
|
||||
if (event.entry === null) {
|
||||
this.entries.delete(event.sessionId)
|
||||
} else {
|
||||
this.entries.set(event.sessionId, event.entry)
|
||||
}
|
||||
this.emit()
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.snapshot = {
|
||||
sessions: [...this.entries.values()].sort((a, b) =>
|
||||
b.updatedAt.localeCompare(a.updatedAt),
|
||||
),
|
||||
loading: this.loading,
|
||||
}
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
171
apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts
Normal file
171
apps/x/apps/renderer/src/lib/session-chat/test-fixtures.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import type { z } from 'zod'
|
||||
import type { SessionIndexEntry, SessionState } from '@x/shared/src/sessions.js'
|
||||
import type { ResolvedAgent, TurnEvent } from '@x/shared/src/turns.js'
|
||||
|
||||
// Compact turn-event builders for renderer tests. Sequences must satisfy the
|
||||
// shared reducer's invariants (reduceTurn is what the store runs on them).
|
||||
|
||||
export type TEvent = z.infer<typeof TurnEvent>
|
||||
|
||||
export const TS = '2026-07-02T10:00:00Z'
|
||||
|
||||
export const FIXTURE_AGENT: z.infer<typeof ResolvedAgent> = {
|
||||
agentId: 'copilot',
|
||||
systemPrompt: 'SYS',
|
||||
model: { provider: 'openai', model: 'gpt-fixture' },
|
||||
tools: [],
|
||||
}
|
||||
|
||||
export function user(text: string) {
|
||||
return { role: 'user' as const, content: text }
|
||||
}
|
||||
|
||||
export function assistantText(text: string) {
|
||||
return { role: 'assistant' as const, content: text }
|
||||
}
|
||||
|
||||
export function toolCallPart(id: string, name: string, args: unknown = {}) {
|
||||
return { type: 'tool-call' as const, toolCallId: id, toolName: name, arguments: args }
|
||||
}
|
||||
|
||||
export function created(
|
||||
turnId: string,
|
||||
sessionId: string,
|
||||
input: ReturnType<typeof user> = user('hello'),
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'turn_created',
|
||||
schemaVersion: 1,
|
||||
turnId,
|
||||
ts: TS,
|
||||
sessionId,
|
||||
agent: { requested: { agentId: 'copilot' }, resolved: FIXTURE_AGENT },
|
||||
context: [],
|
||||
input,
|
||||
config: { autoPermission: false, humanAvailable: true, maxModelCalls: 20 },
|
||||
}
|
||||
}
|
||||
|
||||
export function requested(
|
||||
turnId: string,
|
||||
index: number,
|
||||
refs: string[] = ['input'],
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
request: {
|
||||
messages: refs,
|
||||
parameters: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function completed(
|
||||
turnId: string,
|
||||
index: number,
|
||||
message: { role: 'assistant'; content: unknown },
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'model_call_completed',
|
||||
turnId,
|
||||
ts: TS,
|
||||
modelCallIndex: index,
|
||||
message: message as never,
|
||||
finishReason: 'stop',
|
||||
usage: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function invocation(turnId: string, toolCallId: string, name: string): TEvent {
|
||||
return {
|
||||
type: 'tool_invocation_requested',
|
||||
turnId,
|
||||
ts: TS,
|
||||
toolCallId,
|
||||
toolId: `builtin:${name}`,
|
||||
toolName: name,
|
||||
execution: 'sync',
|
||||
input: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function toolResult(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
name: string,
|
||||
output: unknown = 'ok',
|
||||
): TEvent {
|
||||
return {
|
||||
type: 'tool_result',
|
||||
turnId,
|
||||
ts: TS,
|
||||
toolCallId,
|
||||
toolName: name,
|
||||
source: 'sync',
|
||||
result: { output: output as never, isError: false },
|
||||
}
|
||||
}
|
||||
|
||||
export function turnCompleted(turnId: string, text = 'done'): TEvent {
|
||||
return {
|
||||
type: 'turn_completed',
|
||||
turnId,
|
||||
ts: TS,
|
||||
output: assistantText(text),
|
||||
finishReason: 'stop',
|
||||
usage: {},
|
||||
}
|
||||
}
|
||||
|
||||
// A settled single-response turn.
|
||||
export function completedTurnLog(
|
||||
turnId: string,
|
||||
sessionId: string,
|
||||
question: string,
|
||||
answer: string,
|
||||
): TEvent[] {
|
||||
return [
|
||||
created(turnId, sessionId, user(question)),
|
||||
requested(turnId, 0),
|
||||
completed(turnId, 0, assistantText(answer)),
|
||||
turnCompleted(turnId, answer),
|
||||
]
|
||||
}
|
||||
|
||||
export function indexEntry(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionIndexEntry> = {},
|
||||
): SessionIndexEntry {
|
||||
return {
|
||||
sessionId,
|
||||
title: `Session ${sessionId}`,
|
||||
createdAt: TS,
|
||||
updatedAt: TS,
|
||||
turnCount: 1,
|
||||
latestTurnStatus: 'completed',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionState(
|
||||
sessionId: string,
|
||||
turnIds: string[],
|
||||
): SessionState {
|
||||
return {
|
||||
definition: { type: 'session_created', schemaVersion: 1, sessionId, ts: TS },
|
||||
title: `Session ${sessionId}`,
|
||||
turns: turnIds.map((turnId, i) => ({
|
||||
turnId,
|
||||
sessionSeq: i + 1,
|
||||
agentId: 'copilot',
|
||||
model: FIXTURE_AGENT.model,
|
||||
ts: TS,
|
||||
})),
|
||||
latestTurnId: turnIds[turnIds.length - 1],
|
||||
createdAt: TS,
|
||||
updatedAt: TS,
|
||||
}
|
||||
}
|
||||
314
apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts
Normal file
314
apps/x/apps/renderer/src/lib/session-chat/turn-view.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { reduceTurn } from '@x/shared/src/turns.js'
|
||||
import { isChatMessage, isErrorMessage, isToolCall } from '@/lib/chat-conversation'
|
||||
import {
|
||||
applyOverlay,
|
||||
buildSessionChatState,
|
||||
buildTurnConversation,
|
||||
emptyOverlay,
|
||||
} from './turn-view'
|
||||
import {
|
||||
TS,
|
||||
assistantText,
|
||||
completed,
|
||||
completedTurnLog,
|
||||
created,
|
||||
invocation,
|
||||
requested,
|
||||
toolCallPart,
|
||||
toolResult,
|
||||
turnCompleted,
|
||||
user,
|
||||
type TEvent,
|
||||
} from './test-fixtures'
|
||||
|
||||
const T1 = 'turn-1'
|
||||
const S1 = 'sess-1'
|
||||
|
||||
function assistantCalls(...parts: Array<ReturnType<typeof toolCallPart>>) {
|
||||
return { role: 'assistant' as const, content: parts }
|
||||
}
|
||||
|
||||
describe('applyOverlay', () => {
|
||||
it('accumulates text and reasoning deltas', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'he' })
|
||||
overlay = applyOverlay(overlay, { type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: 'y' })
|
||||
overlay = applyOverlay(overlay, {
|
||||
type: 'reasoning_delta',
|
||||
turnId: T1,
|
||||
modelCallIndex: 0,
|
||||
delta: 'hmm',
|
||||
})
|
||||
expect(overlay.text).toBe('hey')
|
||||
expect(overlay.reasoning).toBe('hmm')
|
||||
})
|
||||
|
||||
it('clears text/reasoning when the canonical model response arrives', () => {
|
||||
let overlay = { ...emptyOverlay(), text: 'streaming', reasoning: 'thinking' }
|
||||
overlay = applyOverlay(overlay, completed(T1, 0, assistantText('final')))
|
||||
expect(overlay.text).toBe('')
|
||||
expect(overlay.reasoning).toBe('')
|
||||
})
|
||||
|
||||
it('accumulates tool-output progress chunks and drops them on the terminal result', () => {
|
||||
let overlay = emptyOverlay()
|
||||
const progress = (chunk: string): TEvent => ({
|
||||
type: 'tool_progress',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'tc1',
|
||||
source: 'sync',
|
||||
progress: { kind: 'tool-output', chunk },
|
||||
})
|
||||
overlay = applyOverlay(overlay, progress('$ ls\n'))
|
||||
overlay = applyOverlay(overlay, progress('README.md\n'))
|
||||
expect(overlay.toolOutput.tc1).toBe('$ ls\nREADME.md\n')
|
||||
|
||||
overlay = applyOverlay(overlay, toolResult(T1, 'tc1', 'executeCommand'))
|
||||
expect(overlay.toolOutput.tc1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores non-output progress payloads', () => {
|
||||
const overlay = applyOverlay(emptyOverlay(), {
|
||||
type: 'tool_progress',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'tc1',
|
||||
source: 'sync',
|
||||
progress: { pct: 50 },
|
||||
})
|
||||
expect(overlay.toolOutput).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('voice output', () => {
|
||||
const delta = (d: string): Parameters<typeof applyOverlay>[1] =>
|
||||
({ type: 'text_delta', turnId: T1, modelCallIndex: 0, delta: d })
|
||||
|
||||
it('extracts completed <voice> blocks across split deltas', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('Sure! <voi'))
|
||||
expect(overlay.voiceSegments).toEqual([])
|
||||
overlay = applyOverlay(overlay, delta('ce>hello there</voice> and <voice>bye'))
|
||||
expect(overlay.voiceSegments).toEqual(['hello there'])
|
||||
overlay = applyOverlay(overlay, delta('</voice> done'))
|
||||
expect(overlay.voiceSegments).toEqual(['hello there', 'bye'])
|
||||
})
|
||||
|
||||
it('keeps segments but resets the scan on model_call_completed', () => {
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('<voice>one</voice>'))
|
||||
overlay = applyOverlay(overlay, completed(T1, 0, assistantText('<voice>one</voice>')))
|
||||
expect(overlay.voiceSegments).toEqual(['one'])
|
||||
expect(overlay.text).toBe('')
|
||||
overlay = applyOverlay(overlay, delta('<voice>two</voice>'))
|
||||
expect(overlay.voiceSegments).toEqual(['one', 'two'])
|
||||
})
|
||||
|
||||
it('strips voice tags from the streaming message and exposes segments on state', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
let overlay = emptyOverlay()
|
||||
overlay = applyOverlay(overlay, delta('Plan: <voice>speak this</voice> rest'))
|
||||
const state = buildSessionChatState([turn], overlay)
|
||||
expect(state.currentAssistantMessage).toBe('Plan: speak this rest')
|
||||
expect(state.voiceSegments).toEqual(['speak this'])
|
||||
})
|
||||
|
||||
it('strips voice tags from persisted assistant messages', () => {
|
||||
const state = reduceTurn(
|
||||
completedTurnLog(T1, S1, 'q', 'Sure. <voice>Here you go.</voice> Done.'),
|
||||
)
|
||||
const items = buildTurnConversation(state)
|
||||
const assistant = items.find((i) => isChatMessage(i) && i.role === 'assistant')
|
||||
expect(isChatMessage(assistant!) && assistant.content).toBe('Sure. Here you go. Done.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTurnConversation', () => {
|
||||
it('maps user input, assistant text, and settled tool calls', () => {
|
||||
const call = assistantCalls(toolCallPart('tc1', 'echo', { x: 1 }))
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, user('run it')),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, call),
|
||||
invocation(T1, 'tc1', 'echo'),
|
||||
toolResult(T1, 'tc1', 'echo', { echoed: true }),
|
||||
requested(T1, 1, [
|
||||
'assistant:0',
|
||||
'toolResult:tc1',
|
||||
]),
|
||||
completed(T1, 1, assistantText('all done')),
|
||||
turnCompleted(T1, 'all done'),
|
||||
])
|
||||
const items = buildTurnConversation(state)
|
||||
expect(items.map((i) => (isToolCall(i) ? `tool:${i.name}:${i.status}` : isChatMessage(i) ? `${i.role}` : 'x'))).toEqual([
|
||||
'user',
|
||||
'tool:echo:completed',
|
||||
'assistant',
|
||||
])
|
||||
const tool = items.find(isToolCall)
|
||||
expect(tool?.result).toEqual({ echoed: true })
|
||||
expect(tool?.input).toEqual({ x: 1 })
|
||||
})
|
||||
|
||||
it('marks permission-pending tools pending and running tools running', () => {
|
||||
const state = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand'), toolCallPart('r1', 'echo'))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'p1',
|
||||
toolName: 'executeCommand',
|
||||
request: { kind: 'command', commandNames: ['rm'] },
|
||||
},
|
||||
invocation(T1, 'r1', 'echo'),
|
||||
])
|
||||
const items = buildTurnConversation(state).filter(isToolCall)
|
||||
expect(items.map((i) => i.status)).toEqual(['pending', 'running'])
|
||||
})
|
||||
|
||||
it('renders user attachments and a failed turn as an error item', () => {
|
||||
const input = {
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{ type: 'text' as const, text: 'see file' },
|
||||
{ type: 'attachment' as const, path: '/tmp/a.png', filename: 'a.png', mimeType: 'image/png' },
|
||||
],
|
||||
}
|
||||
const state = reduceTurn([
|
||||
created(T1, S1, input as never),
|
||||
requested(T1, 0),
|
||||
{ type: 'model_call_failed', turnId: T1, ts: TS, modelCallIndex: 0, error: 'boom' },
|
||||
{ type: 'turn_failed', turnId: T1, ts: TS, error: 'boom', usage: {} },
|
||||
])
|
||||
const items = buildTurnConversation(state)
|
||||
expect(isChatMessage(items[0]) && items[0].attachments?.[0].filename).toBe('a.png')
|
||||
expect(isErrorMessage(items[1]) && items[1].message).toBe('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSessionChatState', () => {
|
||||
it('composes the conversation across turns and derives processing flags', () => {
|
||||
const prior = reduceTurn(completedTurnLog('turn-1', S1, 'first?', 'first answer'))
|
||||
const latest = reduceTurn([
|
||||
created('turn-2', S1, user('second?')),
|
||||
requested('turn-2', 0),
|
||||
])
|
||||
const state = buildSessionChatState([prior, latest], emptyOverlay())
|
||||
expect(
|
||||
state.conversation.filter(isChatMessage).map((m) => m.content),
|
||||
).toEqual(['first?', 'first answer', 'second?'])
|
||||
expect(state.isProcessing).toBe(true) // latest turn idle = actively working
|
||||
expect(state.isThinking).toBe(true)
|
||||
})
|
||||
|
||||
it('is settled (not processing) when the latest turn is terminal', () => {
|
||||
const turn = reduceTurn(completedTurnLog(T1, S1, 'q', 'a'))
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.isProcessing).toBe(false)
|
||||
expect(state.isThinking).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes pending permissions as request events; waiting is not thinking', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('p1', 'executeCommand', { command: 'rm -rf /' }))),
|
||||
{
|
||||
type: 'tool_permission_required',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'p1',
|
||||
toolName: 'executeCommand',
|
||||
request: { kind: 'command', commandNames: ['rm'] },
|
||||
},
|
||||
{
|
||||
type: 'turn_suspended',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
pendingPermissions: [
|
||||
{ toolCallId: 'p1', toolName: 'executeCommand', request: { kind: 'command', commandNames: ['rm'] } },
|
||||
],
|
||||
pendingAsyncTools: [],
|
||||
usage: {},
|
||||
},
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
const request = state.allPermissionRequests.get('p1')
|
||||
expect(request).toMatchObject({
|
||||
type: 'tool-permission-request',
|
||||
toolCall: { toolCallId: 'p1', toolName: 'executeCommand' },
|
||||
permission: { kind: 'command', commandNames: ['rm'] },
|
||||
})
|
||||
expect(state.isProcessing).toBe(true)
|
||||
expect(state.isThinking).toBe(false)
|
||||
})
|
||||
|
||||
it('maps human decisions to responses and classifier decisions to auto-decisions', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('h1', 'echo'), toolCallPart('c1', 'echo'))),
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'h1', toolName: 'echo', request: { kind: 'command', commandNames: ['x'] } },
|
||||
{ type: 'tool_permission_required', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', request: { kind: 'command', commandNames: ['y'] } },
|
||||
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'h1', decision: 'allow', source: 'human' },
|
||||
{ type: 'tool_permission_resolved', turnId: T1, ts: TS, toolCallId: 'c1', decision: 'deny', source: 'classifier', reason: 'risky' },
|
||||
{ type: 'tool_result', turnId: T1, ts: TS, toolCallId: 'c1', toolName: 'echo', source: 'runtime', result: { output: 'denied', isError: true } },
|
||||
invocation(T1, 'h1', 'echo'),
|
||||
toolResult(T1, 'h1', 'echo'),
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.permissionResponses.get('h1')).toBe('approve')
|
||||
expect(state.autoPermissionDecisions.get('c1')).toMatchObject({
|
||||
decision: 'deny',
|
||||
reason: 'risky',
|
||||
})
|
||||
})
|
||||
|
||||
it('exposes pending ask-human calls with question and options', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('ah1', 'ask-human', { question: 'Deploy?', options: ['Yes', 'No'] }))),
|
||||
{
|
||||
type: 'tool_invocation_requested',
|
||||
turnId: T1,
|
||||
ts: TS,
|
||||
toolCallId: 'ah1',
|
||||
toolId: 'builtin:ask-human',
|
||||
toolName: 'ask-human',
|
||||
execution: 'async',
|
||||
input: { question: 'Deploy?', options: ['Yes', 'No'] },
|
||||
},
|
||||
])
|
||||
const state = buildSessionChatState([turn], emptyOverlay())
|
||||
expect(state.pendingAskHumanRequests.get('ah1')).toMatchObject({
|
||||
query: 'Deploy?',
|
||||
options: ['Yes', 'No'],
|
||||
})
|
||||
expect(state.isThinking).toBe(false) // waiting on the human, no shimmer
|
||||
})
|
||||
|
||||
it('stitches live tool output onto the matching tool item', () => {
|
||||
const turn = reduceTurn([
|
||||
created(T1, S1),
|
||||
requested(T1, 0),
|
||||
completed(T1, 0, assistantCalls(toolCallPart('tc1', 'executeCommand'))),
|
||||
invocation(T1, 'tc1', 'executeCommand'),
|
||||
])
|
||||
const overlay = { ...emptyOverlay(), toolOutput: { tc1: 'partial output' } }
|
||||
const state = buildSessionChatState([turn], overlay)
|
||||
const tool = state.conversation.filter(isToolCall)[0]
|
||||
expect(tool.streamingOutput).toBe('partial output')
|
||||
})
|
||||
|
||||
it('surfaces streaming text as currentAssistantMessage', () => {
|
||||
const turn = reduceTurn([created(T1, S1), requested(T1, 0)])
|
||||
const state = buildSessionChatState([turn], { ...emptyOverlay(), text: 'typing…' })
|
||||
expect(state.currentAssistantMessage).toBe('typing…')
|
||||
})
|
||||
})
|
||||
348
apps/x/apps/renderer/src/lib/session-chat/turn-view.ts
Normal file
348
apps/x/apps/renderer/src/lib/session-chat/turn-view.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
import type { z } from 'zod'
|
||||
import type {
|
||||
AskHumanRequestEvent,
|
||||
ToolPermissionAutoDecisionEvent,
|
||||
ToolPermissionMetadata,
|
||||
ToolPermissionRequestEvent,
|
||||
} from '@x/shared/src/runs.js'
|
||||
import {
|
||||
deriveTurnStatus,
|
||||
outstandingAsyncTools,
|
||||
outstandingPermissions,
|
||||
type ToolCallState,
|
||||
type TurnState,
|
||||
type TurnStreamEvent,
|
||||
} from '@x/shared/src/turns.js'
|
||||
import type {
|
||||
ChatMessage,
|
||||
ConversationItem,
|
||||
ErrorMessage,
|
||||
MessageAttachment,
|
||||
PermissionResponse,
|
||||
ToolCall,
|
||||
} from '@/lib/chat-conversation'
|
||||
|
||||
// Pure derivations from reduced turn state (+ the ephemeral live overlay) to
|
||||
// the view shapes the existing chat components already consume. No IPC, no
|
||||
// React — everything here is unit-testable with plain state values.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live overlay: ephemeral streaming buffers (turn-runtime-design.md §14.4).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LiveOverlay = {
|
||||
text: string
|
||||
reasoning: string
|
||||
toolOutput: Record<string, string>
|
||||
// Contents of completed <voice>…</voice> blocks seen while streaming, in
|
||||
// order, monotonically growing for the lifetime of the overlay (i.e. one
|
||||
// active turn). Consumers speak segments beyond what they've already
|
||||
// spoken; the overlay reset on turn switch starts a fresh list.
|
||||
voiceSegments: string[]
|
||||
// Scan cursor into `text` — everything before it has been checked for
|
||||
// complete voice blocks.
|
||||
voiceScanIndex: number
|
||||
}
|
||||
|
||||
export const emptyOverlay = (): LiveOverlay => ({
|
||||
text: '',
|
||||
reasoning: '',
|
||||
toolOutput: {},
|
||||
voiceSegments: [],
|
||||
voiceScanIndex: 0,
|
||||
})
|
||||
|
||||
// The model emits <voice>…</voice> around speakable text when voice output
|
||||
// is enabled; tags are never shown to the user.
|
||||
export function stripVoiceTags(text: string): string {
|
||||
return text.replace(/<\/?voice>/g, '')
|
||||
}
|
||||
|
||||
const VOICE_BLOCK = /<voice>([\s\S]*?)<\/voice>/g
|
||||
|
||||
// Accumulates deltas; canonical durable events supersede the buffers (the
|
||||
// committed transcript now contains what was streaming).
|
||||
export function applyOverlay(overlay: LiveOverlay, event: TurnStreamEvent): LiveOverlay {
|
||||
switch (event.type) {
|
||||
case 'text_delta': {
|
||||
const text = overlay.text + event.delta
|
||||
// Extract complete voice blocks past the scan cursor. Incomplete
|
||||
// blocks (opening tag seen, closing not yet) stay unconsumed until a
|
||||
// later delta completes them.
|
||||
const segments: string[] = []
|
||||
let scanIndex = overlay.voiceScanIndex
|
||||
VOICE_BLOCK.lastIndex = scanIndex
|
||||
for (let m = VOICE_BLOCK.exec(text); m; m = VOICE_BLOCK.exec(text)) {
|
||||
const content = m[1].trim()
|
||||
if (content) segments.push(content)
|
||||
scanIndex = m.index + m[0].length
|
||||
}
|
||||
return {
|
||||
...overlay,
|
||||
text,
|
||||
...(segments.length > 0
|
||||
? { voiceSegments: [...overlay.voiceSegments, ...segments] }
|
||||
: {}),
|
||||
voiceScanIndex: scanIndex,
|
||||
}
|
||||
}
|
||||
case 'reasoning_delta':
|
||||
return { ...overlay, reasoning: overlay.reasoning + event.delta }
|
||||
case 'model_call_completed':
|
||||
return { ...overlay, text: '', reasoning: '', voiceScanIndex: 0 }
|
||||
case 'tool_progress': {
|
||||
const progress = event.progress
|
||||
if (
|
||||
progress &&
|
||||
typeof progress === 'object' &&
|
||||
!Array.isArray(progress) &&
|
||||
(progress as { kind?: unknown }).kind === 'tool-output' &&
|
||||
typeof (progress as { chunk?: unknown }).chunk === 'string'
|
||||
) {
|
||||
const chunk = (progress as { chunk: string }).chunk
|
||||
return {
|
||||
...overlay,
|
||||
toolOutput: {
|
||||
...overlay.toolOutput,
|
||||
[event.toolCallId]: (overlay.toolOutput[event.toolCallId] ?? '') + chunk,
|
||||
},
|
||||
}
|
||||
}
|
||||
return overlay
|
||||
}
|
||||
case 'tool_result': {
|
||||
if (!(event.toolCallId in overlay.toolOutput)) return overlay
|
||||
const toolOutput = { ...overlay.toolOutput }
|
||||
delete toolOutput[event.toolCallId]
|
||||
return { ...overlay, toolOutput }
|
||||
}
|
||||
default:
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UserContent = TurnState['definition']['input']['content']
|
||||
|
||||
function extractText(content: UserContent): string {
|
||||
if (typeof content === 'string') return content
|
||||
return content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function extractAttachments(content: UserContent): MessageAttachment[] | undefined {
|
||||
if (typeof content === 'string') return undefined
|
||||
const attachments = content.flatMap((part) =>
|
||||
part.type === 'attachment'
|
||||
? [
|
||||
{
|
||||
path: part.path,
|
||||
filename: part.filename,
|
||||
mimeType: part.mimeType,
|
||||
...(part.size === undefined ? {} : { size: part.size }),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
return attachments.length > 0 ? attachments : undefined
|
||||
}
|
||||
|
||||
function toolStatus(tc: ToolCallState): ToolCall['status'] {
|
||||
if (tc.result) return 'completed'
|
||||
if (tc.permission && !tc.permission.resolved) return 'pending'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
// One turn's contribution to the conversation: the user input, then per
|
||||
// completed model call its text and tool calls (with live status/results).
|
||||
export function buildTurnConversation(state: TurnState): ConversationItem[] {
|
||||
const items: ConversationItem[] = []
|
||||
const turnId = state.definition.turnId
|
||||
let seq = 0
|
||||
const ts = () => Date.parse(state.definition.ts) + seq++
|
||||
|
||||
const userText = extractText(state.definition.input.content)
|
||||
const attachments = extractAttachments(state.definition.input.content)
|
||||
if (userText || attachments) {
|
||||
items.push({
|
||||
id: `${turnId}:user`,
|
||||
role: 'user',
|
||||
content: userText,
|
||||
timestamp: ts(),
|
||||
...(attachments ? { attachments } : {}),
|
||||
} satisfies ChatMessage)
|
||||
}
|
||||
|
||||
const toolCallsById = new Map(state.toolCalls.map((tc) => [tc.toolCallId, tc]))
|
||||
for (const call of state.modelCalls) {
|
||||
if (call.response === undefined) continue
|
||||
const content = call.response.content
|
||||
// Voice tags are model-facing markup, never shown (parity with the
|
||||
// legacy path's display-time strip).
|
||||
const text = stripVoiceTags(
|
||||
typeof content === 'string'
|
||||
? content
|
||||
: content
|
||||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
)
|
||||
if (text) {
|
||||
items.push({
|
||||
id: `${turnId}:a${call.index}`,
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
timestamp: ts(),
|
||||
} satisfies ChatMessage)
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
if (part.type !== 'tool-call') continue
|
||||
const tc = toolCallsById.get(part.toolCallId)
|
||||
items.push({
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
input: part.arguments as ToolCall['input'],
|
||||
...(tc?.result ? { result: tc.result.result.output as ToolCall['result'] } : {}),
|
||||
status: tc ? toolStatus(tc) : 'running',
|
||||
timestamp: ts(),
|
||||
} satisfies ToolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.terminal?.type === 'turn_failed') {
|
||||
items.push({
|
||||
id: `${turnId}:error`,
|
||||
kind: 'error',
|
||||
message: state.terminal.error,
|
||||
timestamp: ts(),
|
||||
} satisfies ErrorMessage)
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session chat state (superset of the ChatTabViewState fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PermMeta = z.infer<typeof ToolPermissionMetadata>
|
||||
|
||||
export type SessionChatState = {
|
||||
conversation: ConversationItem[]
|
||||
currentAssistantMessage: string
|
||||
// See LiveOverlay.voiceSegments.
|
||||
voiceSegments: string[]
|
||||
pendingAskHumanRequests: Map<string, z.infer<typeof AskHumanRequestEvent>>
|
||||
allPermissionRequests: Map<string, z.infer<typeof ToolPermissionRequestEvent>>
|
||||
permissionResponses: Map<string, PermissionResponse>
|
||||
autoPermissionDecisions: Map<string, z.infer<typeof ToolPermissionAutoDecisionEvent>>
|
||||
// Composer blocked / Stop shown until the latest turn settles (waiting on a
|
||||
// permission or ask-human still counts as processing).
|
||||
isProcessing: boolean
|
||||
// Actively working (non-terminal, nothing waiting on the user) — drives the
|
||||
// "Thinking…" shimmer; deliberately false under permission/ask-human cards.
|
||||
isThinking: boolean
|
||||
}
|
||||
|
||||
function toolCallPartOf(tc: ToolCallState) {
|
||||
return {
|
||||
type: 'tool-call' as const,
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
arguments: tc.input,
|
||||
}
|
||||
}
|
||||
|
||||
// Compose the whole session's conversation: prior (settled) turns plus the
|
||||
// latest turn, with the live overlay stitched onto the latest turn's items.
|
||||
export function buildSessionChatState(
|
||||
turns: TurnState[],
|
||||
overlay: LiveOverlay,
|
||||
): SessionChatState {
|
||||
const conversation: ConversationItem[] = []
|
||||
for (const turn of turns) {
|
||||
conversation.push(...buildTurnConversation(turn))
|
||||
}
|
||||
for (let i = 0; i < conversation.length; i++) {
|
||||
const item = conversation[i]
|
||||
if ('name' in item && overlay.toolOutput[item.id]) {
|
||||
conversation[i] = { ...item, streamingOutput: overlay.toolOutput[item.id] }
|
||||
}
|
||||
}
|
||||
|
||||
const latest = turns[turns.length - 1]
|
||||
const status = latest ? deriveTurnStatus(latest) : undefined
|
||||
const latestTurnId = latest?.definition.turnId ?? ''
|
||||
|
||||
const allPermissionRequests = new Map<string, z.infer<typeof ToolPermissionRequestEvent>>()
|
||||
const permissionResponses = new Map<string, PermissionResponse>()
|
||||
const autoPermissionDecisions = new Map<
|
||||
string,
|
||||
z.infer<typeof ToolPermissionAutoDecisionEvent>
|
||||
>()
|
||||
const pendingAskHumanRequests = new Map<string, z.infer<typeof AskHumanRequestEvent>>()
|
||||
|
||||
if (latest) {
|
||||
for (const tc of outstandingPermissions(latest)) {
|
||||
allPermissionRequests.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'tool-permission-request',
|
||||
subflow: [],
|
||||
toolCall: toolCallPartOf(tc),
|
||||
permission: tc.permission?.required.request as PermMeta,
|
||||
})
|
||||
}
|
||||
for (const tc of latest.toolCalls) {
|
||||
const resolved = tc.permission?.resolved
|
||||
if (!resolved) continue
|
||||
if (resolved.source === 'human') {
|
||||
permissionResponses.set(tc.toolCallId, resolved.decision === 'allow' ? 'approve' : 'deny')
|
||||
} else if (resolved.source === 'classifier') {
|
||||
autoPermissionDecisions.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'tool-permission-auto-decision',
|
||||
subflow: [],
|
||||
toolCallId: tc.toolCallId,
|
||||
toolCall: toolCallPartOf(tc),
|
||||
permission: tc.permission?.required.request as PermMeta,
|
||||
decision: resolved.decision,
|
||||
reason: resolved.reason ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const tc of outstandingAsyncTools(latest)) {
|
||||
if (tc.toolName !== 'ask-human') continue
|
||||
const input = (tc.input ?? {}) as { question?: unknown; options?: unknown }
|
||||
pendingAskHumanRequests.set(tc.toolCallId, {
|
||||
runId: latestTurnId,
|
||||
type: 'ask-human-request',
|
||||
toolCallId: tc.toolCallId,
|
||||
subflow: [],
|
||||
query: typeof input.question === 'string' ? input.question : '',
|
||||
...(Array.isArray(input.options) && input.options.every((o) => typeof o === 'string')
|
||||
? { options: input.options }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const settled = status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
return {
|
||||
conversation,
|
||||
currentAssistantMessage: stripVoiceTags(overlay.text),
|
||||
voiceSegments: overlay.voiceSegments,
|
||||
pendingAskHumanRequests,
|
||||
allPermissionRequests,
|
||||
permissionResponses,
|
||||
autoPermissionDecisions,
|
||||
isProcessing: latest !== undefined && !settled,
|
||||
isThinking: status === 'idle',
|
||||
}
|
||||
}
|
||||
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
1
apps/x/apps/renderer/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest'
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import path from "path"
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
|
|
@ -18,4 +18,10 @@ export default defineConfig({
|
|||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@
|
|||
"deps": "npm run shared && npm run core && npm run preload",
|
||||
"main": "wait-on http://localhost:5173 && cd apps/main && npm run build && npm run start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "npm run shared && npm run test:shared && npm run test:core && npm run test:renderer",
|
||||
"test:shared": "cd packages/shared && npm test",
|
||||
"test:core": "cd packages/core && npm test",
|
||||
"test:renderer": "cd apps/renderer && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
|
|
@ -26,4 +30,4 @@
|
|||
"typescript-eslint": "^8.50.1",
|
||||
"wait-on": "^9.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
561
apps/x/packages/core/docs/session-design.md
Normal file
561
apps/x/packages/core/docs/session-design.md
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
# Session Layer Technical Specification
|
||||
|
||||
Status: design complete for the session layer v1. No implementation exists
|
||||
yet.
|
||||
|
||||
This document specifies the session layer that sits above the turn runtime
|
||||
defined in `turn-runtime-design.md`. That document is assumed context; this
|
||||
one does not restate turn semantics.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
The session layer must:
|
||||
|
||||
1. Own conversations composed of ordered turns.
|
||||
2. Persist each session as one append-only JSONL file with the same
|
||||
validation discipline as turn files.
|
||||
3. Enforce one active turn per session.
|
||||
4. Assemble each turn's context as a reference to the previous turn.
|
||||
5. Maintain an in-memory index for listing, sorting, and filtering sessions,
|
||||
updated write-through and rebuilt by scanning at startup.
|
||||
6. Route external inputs — permission decisions, ask-human answers, async
|
||||
tool results — to the correct turn through dedicated APIs.
|
||||
7. Forward live turn events to the renderer over IPC.
|
||||
8. Provide headless standalone turns outside any session.
|
||||
|
||||
## 2. Non-goals (v1)
|
||||
|
||||
- Queued user messages. The committed future shape is in section 12.1.
|
||||
- Steering / mid-turn message injection (section 12.4).
|
||||
- Session-scoped permission grants ("always allow for this chat"); every
|
||||
applicable tool call prompts in v1 (section 12.2).
|
||||
- Context compaction; a viable mechanism sketch is recorded in section 12.3.
|
||||
V1 behavior on context overflow is the turn-level model failure.
|
||||
- LLM auto-titling (section 12.6).
|
||||
- A persisted index cache; startup always scans (section 12.5).
|
||||
- Cross-process coordination. A single main process is enforced.
|
||||
- Data migration from the current runs system. Old conversations are not
|
||||
converted; the old code path remains readable until it is deleted.
|
||||
- Session list pagination. The index is in-memory and shipped whole.
|
||||
|
||||
## 3. Terminology
|
||||
|
||||
A **session** is a durable, ordered chain of turns plus presentation
|
||||
metadata (title). Conversation content lives exclusively in turn files; the
|
||||
session file stores turn references with denormalized metadata.
|
||||
|
||||
The **index** is an in-memory projection over all session files, used for
|
||||
the session list UI. It is never a source of truth.
|
||||
|
||||
A **standalone turn** is a turn with `sessionId: null`, created outside any
|
||||
session by headless callers. Standalone turns do not appear in the index.
|
||||
|
||||
## 4. Storage design
|
||||
|
||||
### 4.1 File location
|
||||
|
||||
Session files live under:
|
||||
|
||||
```text
|
||||
WorkDir/storage/sessions/YYYY/MM/DD/<sessionId>.jsonl
|
||||
```
|
||||
|
||||
Session IDs come from the existing
|
||||
`IMonotonicallyIncreasingIdGenerator`, and the repository derives the
|
||||
date-partitioned path from the ID exactly as the turn repository does,
|
||||
including format validation and path-traversal rejection.
|
||||
|
||||
### 4.2 File rules
|
||||
|
||||
Identical discipline to turn files:
|
||||
|
||||
- The first line is always `session_created` with `schemaVersion: 1`.
|
||||
- Every event contains `sessionId` and an ISO timestamp `ts`.
|
||||
- Physical line order is authoritative.
|
||||
- Reads validate every line strictly; any malformed line makes the session
|
||||
corrupt; no truncation, repair, or skipping.
|
||||
- Unknown schema versions and unknown event types fail loudly. Future
|
||||
additive event types (queueing, grants, compaction) arrive as a schema
|
||||
version bump; the reducer will accept old and new versions and write the
|
||||
newest.
|
||||
- Appends are awaited but not explicitly `fsync`ed.
|
||||
|
||||
### 4.3 Repository contract
|
||||
|
||||
```ts
|
||||
interface ISessionRepo {
|
||||
create(event: SessionCreated): Promise<void>;
|
||||
read(sessionId: string): Promise<SessionEvent[]>;
|
||||
append(sessionId: string, events: SessionEvent[]): Promise<void>;
|
||||
withLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
|
||||
listSessionIds(): Promise<string[]>;
|
||||
delete(sessionId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
- `create` fails if the file exists.
|
||||
- `listSessionIds` enumerates the partition directories for the startup
|
||||
scan.
|
||||
- `delete` removes the session file only. Turn files referenced by the
|
||||
session are left in place as harmless orphans (see section 9, deletion).
|
||||
- `withLock` is in-process per-session exclusion, mirroring the turn repo.
|
||||
|
||||
## 5. Event schemas
|
||||
|
||||
All session event schemas live in `@x/shared` alongside the turn schemas.
|
||||
|
||||
```ts
|
||||
interface BaseSessionEvent {
|
||||
sessionId: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
interface SessionCreated extends BaseSessionEvent {
|
||||
type: "session_created";
|
||||
schemaVersion: 1;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface SessionTurnAppended extends BaseSessionEvent {
|
||||
type: "turn_appended";
|
||||
turnId: string;
|
||||
sessionSeq: number; // 1-based position of the turn in the session
|
||||
agentId: string;
|
||||
model: ModelDescriptor; // resolved provider/model for the turn
|
||||
}
|
||||
|
||||
interface SessionTitleChanged extends BaseSessionEvent {
|
||||
type: "title_changed";
|
||||
title: string;
|
||||
}
|
||||
|
||||
type SessionEvent =
|
||||
| SessionCreated
|
||||
| SessionTurnAppended
|
||||
| SessionTitleChanged;
|
||||
```
|
||||
|
||||
`turn_appended` deliberately denormalizes `agentId` and `model` from the
|
||||
turn so the index can fold from session files without opening turn files.
|
||||
The turn file remains authoritative for the turn's actual configuration.
|
||||
|
||||
The session file never mirrors turn outcomes. Turn lifecycle facts live only
|
||||
in turn files; deriving "is this session busy/suspended/failed" reads the
|
||||
latest turn (section 8).
|
||||
|
||||
## 6. Session reducer
|
||||
|
||||
`@x/shared` owns one pure reducer shared by core and renderer:
|
||||
|
||||
```ts
|
||||
function reduceSession(events: SessionEvent[]): SessionState;
|
||||
|
||||
interface SessionState {
|
||||
definition: SessionCreated;
|
||||
title?: string;
|
||||
turns: Array<{
|
||||
turnId: string;
|
||||
sessionSeq: number;
|
||||
agentId: string;
|
||||
model: ModelDescriptor;
|
||||
ts: string;
|
||||
}>;
|
||||
latestTurnId?: string;
|
||||
createdAt: string; // definition.ts
|
||||
updatedAt: string; // ts of the last event
|
||||
}
|
||||
```
|
||||
|
||||
Invariants (violations throw, as with the turn reducer):
|
||||
|
||||
- `session_created` is present, first, and unique.
|
||||
- All event `sessionId` values match.
|
||||
- `sessionSeq` is strictly increasing starting at 1, with no gaps.
|
||||
- `turnId` values are unique.
|
||||
- Unsupported schema versions and unknown event types fail loudly.
|
||||
|
||||
## 7. Write ordering and consistency
|
||||
|
||||
Per user message, the session layer performs, in order:
|
||||
|
||||
1. `turnRuntime.createTurn(...)` — the turn file is created.
|
||||
2. `sessionRepo.append(turn_appended)` — the session references the turn.
|
||||
3. `advanceTurn(...)` — execution begins.
|
||||
|
||||
Rules:
|
||||
|
||||
- A crash between steps 1 and 2 leaves an orphan turn file: unreferenced,
|
||||
never advanced, and benign. Turns are only ever found by reference, so an
|
||||
orphan is invisible. V1 does not garbage-collect orphans.
|
||||
- The reverse order is forbidden: a `turn_appended` referencing a turn file
|
||||
that was never created would be a dangling reference, which is corruption.
|
||||
- Step 2 precedes step 3 so that a turn that is executing is always already
|
||||
referenced by its session.
|
||||
- Session-file appends happen under the session lock; turn-file appends are
|
||||
the turn runtime's concern.
|
||||
|
||||
## 8. In-memory index
|
||||
|
||||
### 8.1 Shape
|
||||
|
||||
```ts
|
||||
interface SessionIndexEntry {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
turnCount: number;
|
||||
lastAgentId?: string;
|
||||
lastModel?: ModelDescriptor;
|
||||
latestTurnId?: string;
|
||||
latestTurnStatus:
|
||||
| "none" // session has no turns yet
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "suspended" // durable suspension: pending permissions/async tools
|
||||
| "idle"; // non-terminal, not suspended: interrupted by a crash
|
||||
}
|
||||
```
|
||||
|
||||
`latestTurnStatus` is derived from the latest turn's reduced state, using
|
||||
the same derivation everywhere: terminal event kind if present, else
|
||||
`suspended` if a suspension with outstanding work is the resting state, else
|
||||
`idle`. Whether a turn is *actively processing right now* is not in the
|
||||
index; it is ephemeral bus state (`turn-processing-start/end`), per the turn
|
||||
specification.
|
||||
|
||||
### 8.2 Startup scan
|
||||
|
||||
1. `listSessionIds()`.
|
||||
2. For each session: read and reduce the session file, producing the entry's
|
||||
session-derived fields.
|
||||
3. For each session with turns: read and reduce the latest turn file only,
|
||||
producing `latestTurnStatus`.
|
||||
4. Publish the completed index to the renderer.
|
||||
|
||||
A corrupt session file or corrupt latest-turn file does not abort startup:
|
||||
the entry is surfaced in an errored state (identifiable in the UI, excluded
|
||||
from normal interaction) and the scan continues.
|
||||
|
||||
### 8.3 Maintenance
|
||||
|
||||
- Every session mutation updates the entry in the same code path that
|
||||
appends to the session file (write-through), then publishes a
|
||||
`session-index-changed` event on the application bus.
|
||||
- When an `advanceTurn` outcome settles, the session layer updates
|
||||
`latestTurnStatus` and publishes `session-index-changed`.
|
||||
- There is no filesystem watcher. Out-of-band edits to session or turn files
|
||||
while the app runs are unsupported; offline changes are reconciled by the
|
||||
next startup scan.
|
||||
- The main process enforces single-instance via
|
||||
`app.requestSingleInstanceLock()`; all locking in both layers is
|
||||
in-process.
|
||||
|
||||
## 9. Sessions API
|
||||
|
||||
```ts
|
||||
interface SendMessageConfig {
|
||||
agent: RequestedAgent; // agent id + optional model override
|
||||
autoPermission?: boolean; // default false
|
||||
maxModelCalls?: number; // default per turn spec
|
||||
}
|
||||
|
||||
interface ISessions {
|
||||
createSession(input?: { title?: string }): Promise<string>;
|
||||
listSessions(): SessionIndexEntry[];
|
||||
getSession(sessionId: string): Promise<SessionState>;
|
||||
getTurn(turnId: string): Promise<Turn>; // passthrough to turn runtime
|
||||
|
||||
sendMessage(
|
||||
sessionId: string,
|
||||
input: UserMessage,
|
||||
config: SendMessageConfig,
|
||||
): Promise<{ turnId: string }>;
|
||||
|
||||
respondToPermission(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
decision: "allow" | "deny",
|
||||
metadata?: JsonValue,
|
||||
): Promise<void>;
|
||||
|
||||
respondToAskHuman(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
answer: string,
|
||||
): Promise<void>;
|
||||
|
||||
deliverAsyncToolResult(
|
||||
turnId: string,
|
||||
toolCallId: string,
|
||||
result: ToolResultData,
|
||||
): Promise<void>;
|
||||
|
||||
stopTurn(turnId: string, reason?: string): Promise<void>;
|
||||
resumeTurn(sessionId: string): Promise<void>;
|
||||
|
||||
setTitle(sessionId: string, title: string): Promise<void>;
|
||||
deleteSession(sessionId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 9.1 sendMessage
|
||||
|
||||
Under the per-session lock:
|
||||
|
||||
1. Read and reduce the session.
|
||||
2. If the session has turns, read and reduce the latest turn. If it is
|
||||
non-terminal — running, suspended, or idle — reject with a typed
|
||||
`TurnNotSettledError`. There is no implicit queueing, steering, or
|
||||
cancel-and-replace, and no implicit routing to a pending ask-human.
|
||||
3. Build context: `[]` (inline, empty) for the first turn, else
|
||||
`{ previousTurnId: latestTurnId }`.
|
||||
4. Create the turn: config from `SendMessageConfig` lands on the turn
|
||||
(`humanAvailable: true` always, for session turns). Sessions store no
|
||||
configuration; every turn is self-describing.
|
||||
5. Append `turn_appended` with the next `sessionSeq` and denormalized
|
||||
agent/model.
|
||||
6. If the session has no title, append `title_changed` derived from the
|
||||
truncated first user message.
|
||||
7. Start `advanceTurn` in the background; consume its events (section 10).
|
||||
8. Return `{ turnId }` immediately. The renderer follows progress through
|
||||
events, not the return value.
|
||||
|
||||
Continuation after a failed or exhausted (`code: "model-call-limit"`) turn
|
||||
is just `sendMessage`: failed turns are terminal, and the new turn's context
|
||||
reference includes the failed turn's structurally complete transcript.
|
||||
|
||||
### 9.2 External inputs
|
||||
|
||||
`respondToPermission`, `respondToAskHuman`, and `deliverAsyncToolResult`
|
||||
each translate to one `advanceTurn(turnId, input)` call with the
|
||||
corresponding `TurnExternalInput`. `respondToAskHuman` is the dedicated
|
||||
endpoint for the `ask-human` tool — a thin wrapper over
|
||||
`async_tool_result` — and is deliberately separate from `sendMessage`.
|
||||
Validation (unknown call, already-resolved call, terminal turn) is the turn
|
||||
runtime's job; the session layer passes its errors through.
|
||||
|
||||
### 9.3 stopTurn and resumeTurn
|
||||
|
||||
- `stopTurn` cancels via the turn runtime: aborting the active invocation's
|
||||
signal if one is running, else advancing a suspended turn with a `cancel`
|
||||
input.
|
||||
- `resumeTurn` re-enters the latest turn with no input — the turn spec's
|
||||
recovery entry point — for turns left `idle` by a crash. There is no
|
||||
automatic resume sweep at startup: recovery re-issues interrupted model
|
||||
calls, so resumption must be an explicit user action. Suspended turns need
|
||||
no resumption; they advance when their inputs arrive.
|
||||
|
||||
### 9.4 Deletion
|
||||
|
||||
`deleteSession` removes the session file and the index entry, and publishes
|
||||
`session-index-changed`. Referenced turn files are retained as orphans:
|
||||
turns are only discoverable by reference, so orphaned files are inert.
|
||||
Deleting an entity's file is not a violation of append-only discipline,
|
||||
which governs mutation of live logs, not their removal.
|
||||
|
||||
## 10. Event forwarding and live UI
|
||||
|
||||
1. For every `advanceTurn` it initiates, the session layer consumes
|
||||
`TurnExecution.events` and forwards each `TurnStreamEvent` — tagged with
|
||||
`sessionId` — through the application bus to renderer windows over one
|
||||
IPC channel (`sessions:events`).
|
||||
2. When `outcome` settles, the session layer updates the index entry and
|
||||
publishes `session-index-changed`.
|
||||
3. The renderer follows the turn spec's historical/live pattern: fetch
|
||||
turns via `getTurn`, run the shared `reduceTurn` per turn, compose the
|
||||
session timeline turn-by-turn (each turn renders its input and its own
|
||||
activity; the referenced prefix is never re-rendered from context),
|
||||
append live durable events and re-reduce, and keep text/reasoning deltas
|
||||
in an ephemeral overlay cleared by canonical responses.
|
||||
4. Pending approvals and ask-human prompts render from the suspended turn's
|
||||
reduced state, so they survive restarts without any session-layer
|
||||
bookkeeping.
|
||||
|
||||
## 11. Headless standalone turns
|
||||
|
||||
A helper covers the non-session callers (background tasks, live notes,
|
||||
knowledge pipelines, scheduled agents):
|
||||
|
||||
```ts
|
||||
function runHeadlessTurn(input: {
|
||||
agent: RequestedAgent;
|
||||
context?: ConversationMessage[]; // inline; defaults to []
|
||||
input: UserMessage;
|
||||
maxModelCalls?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<TurnOutcome>;
|
||||
```
|
||||
|
||||
- `sessionId: null`, `autoPermission: true`, `humanAvailable: false`.
|
||||
- Creates the turn, advances to the first settled outcome, and returns it.
|
||||
- Standalone turns never appear in the index; callers keep their own turn
|
||||
IDs if they need history.
|
||||
|
||||
## 12. Deferred designs with committed shapes
|
||||
|
||||
These are not implemented in v1. Their shapes are recorded so v1 decisions
|
||||
stay compatible; each arrives as a session schema version bump.
|
||||
|
||||
### 12.1 Queued messages
|
||||
|
||||
```ts
|
||||
{ type: "message_queued", queueId, message, ts }
|
||||
{ type: "queued_message_replaced", queueId, message, ts } // edit
|
||||
{ type: "queued_message_removed", queueId, ts } // cancel
|
||||
// promotion: turn_appended gains consumedQueueIds?: string[]
|
||||
```
|
||||
|
||||
The reducer derives the pending queue by supersession. Promotion rules
|
||||
(collapse-into-one-turn vs FIFO, behavior after failed turns) are decided
|
||||
together with steering.
|
||||
|
||||
### 12.2 Session permission grants
|
||||
|
||||
```ts
|
||||
{ type: "permission_grant_added", grantId, toolId, ts }
|
||||
{ type: "permission_grant_removed", grantId, ts }
|
||||
```
|
||||
|
||||
The injected `IPermissionChecker` consults a session-keyed grants view
|
||||
before answering `required: true`. V1 grants would be blanket per-toolId;
|
||||
argument-pattern matchers are a separate, security-sensitive project.
|
||||
|
||||
### 12.3 Compaction (mechanism sketch)
|
||||
|
||||
Compaction requires zero turn-schema change. A session-level compaction
|
||||
event records `{ compactionId, summary, firstKeptTurnId }`; the next turn
|
||||
after a compaction uses **inline** context (summary message + kept
|
||||
transcript), which restarts the reference chain and bounds resolution depth
|
||||
by construction. Trigger policy and summarizer design are unspecified.
|
||||
|
||||
### 12.4 Steering
|
||||
|
||||
Rides the queue events with a different promotion rule, and additionally
|
||||
requires a turn-level `steer` external input (a turn schema bump) with an
|
||||
injection boundary after tool-batch completion. Recorded here so the session
|
||||
design does not foreclose it.
|
||||
|
||||
### 12.5 Persisted index cache
|
||||
|
||||
If the startup scan ever gets slow, a single cache file keyed by file
|
||||
mtimes can be added. It is a rebuildable cache, never a source of truth:
|
||||
missing, stale, or invalid means rebuild from session files.
|
||||
|
||||
### 12.6 Auto-titling
|
||||
|
||||
An LLM-generated title replacing the truncated-first-message default,
|
||||
appended as an ordinary `title_changed` event.
|
||||
|
||||
## 13. Required test scenarios
|
||||
|
||||
All tests use the in-memory/mocked turn runtime and repo fakes.
|
||||
|
||||
### 13.1 Reducer
|
||||
|
||||
- Valid event sequences reduce to expected state.
|
||||
- Every invariant violation throws: missing/duplicate `session_created`,
|
||||
mismatched sessionId, non-monotonic or gapped `sessionSeq`, duplicate
|
||||
turnIds, unknown type/version.
|
||||
- Title folding: default, explicit changes, last-wins.
|
||||
|
||||
### 13.2 Repository
|
||||
|
||||
- Date-partitioned paths, ID validation, create-if-absent.
|
||||
- Strict line validation on read; corrupt files rejected whole.
|
||||
- `listSessionIds` enumeration across partitions.
|
||||
- Deletion removes only the session file.
|
||||
|
||||
### 13.3 sendMessage
|
||||
|
||||
- First turn: inline `[]` context, `sessionSeq: 1`, default title appended.
|
||||
- Subsequent turns: context references the latest turn; seq increments.
|
||||
- Rejection with `TurnNotSettledError` when the latest turn is running,
|
||||
suspended, or idle — and success after it settles.
|
||||
- Continuation after failed and model-call-limit turns.
|
||||
- Concurrent sendMessage calls serialize under the session lock; exactly
|
||||
one wins, the other rejects.
|
||||
- Config lands on the turn; the session file stores only denormalized
|
||||
agent/model on `turn_appended`.
|
||||
|
||||
### 13.4 Ordering and crash simulation
|
||||
|
||||
- Simulated crash between `createTurn` and `turn_appended`: orphan turn
|
||||
file, session unchanged, retry produces a fresh turn.
|
||||
- `turn_appended` is present before the first advance begins.
|
||||
|
||||
### 13.5 External inputs
|
||||
|
||||
- Permission decision, ask-human answer, and async result each advance the
|
||||
correct turn with the correct input type.
|
||||
- Turn-runtime rejections (unknown call, terminal turn) pass through.
|
||||
- `sendMessage` never routes to ask-human.
|
||||
|
||||
### 13.6 Index
|
||||
|
||||
- Startup fold matches write-through state for the same history.
|
||||
- Latest-turn status derivation for every status value.
|
||||
- Corrupt session file yields an errored entry without aborting the scan.
|
||||
- Mutations publish `session-index-changed`; deletion removes the entry.
|
||||
|
||||
### 13.7 Event forwarding
|
||||
|
||||
- Forwarded events are tagged with sessionId and arrive in order.
|
||||
- Outcome settlement updates `latestTurnStatus`.
|
||||
|
||||
### 13.8 Headless
|
||||
|
||||
- Standalone turns: `sessionId: null`, auto permission, human unavailable,
|
||||
absent from the index.
|
||||
|
||||
## 14. Suggested module layout
|
||||
|
||||
```text
|
||||
apps/x/packages/shared/src/sessions.ts # event schemas, reducer, index types
|
||||
|
||||
apps/x/packages/core/src/sessions/
|
||||
sessions.ts # ISessions implementation
|
||||
repo.ts # ISessionRepo contract
|
||||
fs-repo.ts # filesystem implementation
|
||||
index.ts # in-memory index + startup scan
|
||||
headless.ts # runHeadlessTurn
|
||||
```
|
||||
|
||||
Awilix registration mirrors the turn runtime: singleton scope, PROXY
|
||||
constructor injection, no container resolution from inside the classes.
|
||||
|
||||
## 15. Integration sequence
|
||||
|
||||
The rollout is staged as commits on one branch (squash-merge acceptable);
|
||||
old and new stacks coexist briefly but never share state, and no data is
|
||||
migrated:
|
||||
|
||||
1. `@x/shared`: turn + session schemas and reducers.
|
||||
2. Turn runtime + fs turn repo (unit tests only, wired to nothing).
|
||||
3. Session layer + index (unit tests only).
|
||||
4. Bridges: agent resolver, context resolver, tool runner, permission
|
||||
checker/classifier — adapted from the `new-runtime` reference
|
||||
implementation where applicable.
|
||||
5. IPC (`sessions:*`) + renderer swap: Copilot chat UI moves to the
|
||||
sessions API.
|
||||
6. Headless callers move to `runHeadlessTurn`.
|
||||
7. Delete the old runs runtime.
|
||||
|
||||
## 16. Implementation acceptance criteria
|
||||
|
||||
The session layer is implementation-complete only when:
|
||||
|
||||
1. Session event schemas and `reduceSession` live in `@x/shared` and are
|
||||
consumed unchanged by core and renderer.
|
||||
2. Session files follow the partitioned append-only JSONL layout with
|
||||
strict validation.
|
||||
3. Turn-file-first write ordering is enforced; orphan turns are benign.
|
||||
4. `sendMessage` rejects non-terminal latest turns with a typed error; no
|
||||
implicit queueing, steering, or ask-human routing exists.
|
||||
5. Ask-human answers flow only through the dedicated endpoint.
|
||||
6. The index is write-through with a startup scan, no watcher, and no
|
||||
persisted cache; single-instance is enforced.
|
||||
7. Deletion removes only the session file.
|
||||
8. Headless callers run standalone turns and appear nowhere in the index.
|
||||
9. All required test scenarios pass with mocked dependencies.
|
||||
1887
apps/x/packages/core/docs/turn-runtime-design.md
Normal file
1887
apps/x/packages/core/docs/turn-runtime-design.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,9 @@
|
|||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -w -p tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"inspect-turn": "node dist/turns/inspect-cli.js",
|
||||
"inspect": "node dist/turns/inspect-cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.39.0",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
25
apps/x/packages/core/src/agents/headless-app.ts
Normal file
25
apps/x/packages/core/src/agents/headless-app.ts
Normal 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";
|
||||
300
apps/x/packages/core/src/agents/headless.test.ts
Normal file
300
apps/x/packages/core/src/agents/headless.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
182
apps/x/packages/core/src/agents/headless.ts
Normal file
182
apps/x/packages/core/src/agents/headless.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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,87 @@ 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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}: 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 (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>;
|
||||
}
|
||||
|
|
@ -1423,70 +1504,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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'meeting_prep' | 'knowledge_sync' | 'code_session';
|
||||
|
||||
export interface UseCaseContext {
|
||||
useCase: UseCase;
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -24,6 +26,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 +69,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(),
|
||||
|
|
@ -62,6 +103,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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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']) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
230
apps/x/packages/core/src/knowledge/meeting_prep.ts
Normal file
230
apps/x/packages/core/src/knowledge/meeting_prep.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
307
apps/x/packages/core/src/knowledge/meeting_prep_brief.ts
Normal file
307
apps/x/packages/core/src/knowledge/meeting_prep_brief.ts
Normal 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('## Who’s 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;
|
||||
}
|
||||
158
apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts
Normal file
158
apps/x/packages/core/src/knowledge/meeting_prep_scheduler.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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']) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
11
apps/x/packages/core/src/models/default-model-resolver.ts
Normal file
11
apps/x/packages/core/src/models/default-model-resolver.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
79
apps/x/packages/core/src/sessions/api.ts
Normal file
79
apps/x/packages/core/src/sessions/api.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
28
apps/x/packages/core/src/sessions/bus.ts
Normal file
28
apps/x/packages/core/src/sessions/bus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
108
apps/x/packages/core/src/sessions/fs-repo.test.ts
Normal file
108
apps/x/packages/core/src/sessions/fs-repo.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
149
apps/x/packages/core/src/sessions/fs-repo.ts
Normal file
149
apps/x/packages/core/src/sessions/fs-repo.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/x/packages/core/src/sessions/headless.ts
Normal file
41
apps/x/packages/core/src/sessions/headless.ts
Normal 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 };
|
||||
}
|
||||
83
apps/x/packages/core/src/sessions/in-memory-session-repo.ts
Normal file
83
apps/x/packages/core/src/sessions/in-memory-session-repo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
apps/x/packages/core/src/sessions/index.ts
Normal file
8
apps/x/packages/core/src/sessions/index.ts
Normal 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";
|
||||
20
apps/x/packages/core/src/sessions/repo.ts
Normal file
20
apps/x/packages/core/src/sessions/repo.ts
Normal 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>;
|
||||
}
|
||||
25
apps/x/packages/core/src/sessions/session-index.ts
Normal file
25
apps/x/packages/core/src/sessions/session-index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
844
apps/x/packages/core/src/sessions/sessions.test.ts
Normal file
844
apps/x/packages/core/src/sessions/sessions.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
418
apps/x/packages/core/src/sessions/sessions.ts
Normal file
418
apps/x/packages/core/src/sessions/sessions.ts
Normal 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;
|
||||
}
|
||||
14
apps/x/packages/core/src/turns/agent-resolver.ts
Normal file
14
apps/x/packages/core/src/turns/agent-resolver.ts
Normal 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>>;
|
||||
}
|
||||
112
apps/x/packages/core/src/turns/api.ts
Normal file
112
apps/x/packages/core/src/turns/api.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
5
apps/x/packages/core/src/turns/bridges/index.ts
Normal file
5
apps/x/packages/core/src/turns/bridges/index.ts
Normal 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";
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
198
apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts
Normal file
198
apps/x/packages/core/src/turns/bridges/real-agent-resolver.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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(),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
261
apps/x/packages/core/src/turns/bridges/real-model-registry.ts
Normal file
261
apps/x/packages/core/src/turns/bridges/real-model-registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
141
apps/x/packages/core/src/turns/bridges/real-tool-registry.ts
Normal file
141
apps/x/packages/core/src/turns/bridges/real-tool-registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
apps/x/packages/core/src/turns/bus.ts
Normal file
39
apps/x/packages/core/src/turns/bus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/x/packages/core/src/turns/clock.ts
Normal file
11
apps/x/packages/core/src/turns/clock.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
68
apps/x/packages/core/src/turns/compose-model-request.ts
Normal file
68
apps/x/packages/core/src/turns/compose-model-request.ts
Normal 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);
|
||||
}
|
||||
330
apps/x/packages/core/src/turns/context-resolver.test.ts
Normal file
330
apps/x/packages/core/src/turns/context-resolver.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
88
apps/x/packages/core/src/turns/context-resolver.ts
Normal file
88
apps/x/packages/core/src/turns/context-resolver.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
203
apps/x/packages/core/src/turns/fs-repo.test.ts
Normal file
203
apps/x/packages/core/src/turns/fs-repo.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
128
apps/x/packages/core/src/turns/fs-repo.ts
Normal file
128
apps/x/packages/core/src/turns/fs-repo.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
apps/x/packages/core/src/turns/in-memory-turn-repo.ts
Normal file
62
apps/x/packages/core/src/turns/in-memory-turn-repo.ts
Normal 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))),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
apps/x/packages/core/src/turns/index.ts
Normal file
16
apps/x/packages/core/src/turns/index.ts
Normal 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";
|
||||
193
apps/x/packages/core/src/turns/inspect-cli.ts
Normal file
193
apps/x/packages/core/src/turns/inspect-cli.ts
Normal 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);
|
||||
});
|
||||
21
apps/x/packages/core/src/turns/keyed-mutex.ts
Normal file
21
apps/x/packages/core/src/turns/keyed-mutex.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
apps/x/packages/core/src/turns/model-registry.ts
Normal file
54
apps/x/packages/core/src/turns/model-registry.ts
Normal 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>;
|
||||
}
|
||||
61
apps/x/packages/core/src/turns/permission.ts
Normal file
61
apps/x/packages/core/src/turns/permission.ts
Normal 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[]>;
|
||||
}
|
||||
15
apps/x/packages/core/src/turns/repo.ts
Normal file
15
apps/x/packages/core/src/turns/repo.ts
Normal 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>;
|
||||
}
|
||||
1759
apps/x/packages/core/src/turns/runtime.test.ts
Normal file
1759
apps/x/packages/core/src/turns/runtime.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1216
apps/x/packages/core/src/turns/runtime.ts
Normal file
1216
apps/x/packages/core/src/turns/runtime.ts
Normal file
File diff suppressed because it is too large
Load diff
89
apps/x/packages/core/src/turns/stream.test.ts
Normal file
89
apps/x/packages/core/src/turns/stream.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
94
apps/x/packages/core/src/turns/stream.ts
Normal file
94
apps/x/packages/core/src/turns/stream.ts
Normal 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 };
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
37
apps/x/packages/core/src/turns/tool-registry.ts
Normal file
37
apps/x/packages/core/src/turns/tool-registry.ts
Normal 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>;
|
||||
}
|
||||
19
apps/x/packages/core/src/turns/usage-reporter.ts
Normal file
19
apps/x/packages/core/src/turns/usage-reporter.ts
Normal 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 {}
|
||||
}
|
||||
|
|
@ -1,14 +1,19 @@
|
|||
{
|
||||
"name": "@x/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc",
|
||||
"dev": "tsc -w"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1"
|
||||
}
|
||||
}
|
||||
"name": "@x/shared",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -w",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import {
|
|||
BackgroundTaskSummarySchema,
|
||||
TriggersSchema,
|
||||
} from './background-task.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { UserMessage, UserMessageContent } from './message.js';
|
||||
import { RequestedAgent, type TurnEvent } from './turns.js';
|
||||
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
|
|
@ -375,6 +377,90 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: z.null(),
|
||||
},
|
||||
// ── New runtime: sessions + turns (session-design.md) ────────────────────
|
||||
// Turn-mutating calls return quickly; the renderer follows progress through
|
||||
// the sessions:events feed and the shared reduceTurn reducer.
|
||||
'sessions:create': {
|
||||
req: z.object({ title: z.string().optional() }),
|
||||
res: z.object({ sessionId: z.string() }),
|
||||
},
|
||||
'sessions:list': {
|
||||
req: z.object({}),
|
||||
res: z.object({ sessions: z.array(z.custom<SessionIndexEntry>()) }),
|
||||
},
|
||||
'sessions:get': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.custom<SessionState>(),
|
||||
},
|
||||
'sessions:getTurn': {
|
||||
// Events are strictly validated at the repository read; typed via
|
||||
// z.custom to avoid re-validating potentially large logs per IPC hop.
|
||||
req: z.object({ turnId: z.string() }),
|
||||
res: z.custom<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>(),
|
||||
},
|
||||
'sessions:sendMessage': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
input: UserMessage,
|
||||
config: z.object({
|
||||
agent: RequestedAgent,
|
||||
autoPermission: z.boolean().optional(),
|
||||
maxModelCalls: z.number().int().positive().optional(),
|
||||
}),
|
||||
}),
|
||||
res: z.object({ turnId: z.string() }),
|
||||
},
|
||||
'sessions:respondToPermission': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
toolCallId: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
metadata: z.json().optional(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:respondToAskHuman': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
toolCallId: z.string(),
|
||||
answer: z.string(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:stopTurn': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:resumeTurn': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:setTitle': {
|
||||
req: z.object({ sessionId: z.string(), title: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:downloadLog': {
|
||||
// Concatenates the session's turn logs into one JSONL for debugging.
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'sessions:delete': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:events': {
|
||||
// Typed via z.custom so the renderer's `on` handler is typed without
|
||||
// runtime validation (the broadcast path bypasses preload validation,
|
||||
// like runs:events).
|
||||
req: z.custom<SessionBusEvent>(),
|
||||
res: z.null(),
|
||||
},
|
||||
'services:events': {
|
||||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
|
|
@ -1248,6 +1334,47 @@ const ipcSchemas = {
|
|||
notes: z.string(),
|
||||
}),
|
||||
},
|
||||
// Resolve a meeting's attendees against the knowledge base — returns each
|
||||
// attendee's existing person note (or null). Deterministic, no LLM; powers
|
||||
// the ambient "Next up" prep card.
|
||||
'meeting-prep:resolve': {
|
||||
req: z.object({
|
||||
attendees: z.array(z.object({
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
self: z.boolean().optional(),
|
||||
})),
|
||||
// When provided, the response includes any pre-generated prep note for
|
||||
// this calendar event (matched by the eventId stamped in frontmatter).
|
||||
eventId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
attendees: z.array(z.object({
|
||||
label: z.string(),
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
note: z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
markdown: z.string(),
|
||||
}).nullable(),
|
||||
})),
|
||||
organizations: z.array(z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
markdown: z.string(),
|
||||
})),
|
||||
// The pre-generated prep note (brief + path), if one exists for eventId.
|
||||
prepNote: z.object({
|
||||
path: z.string(),
|
||||
brief: z.string(),
|
||||
}).nullable(),
|
||||
matchedCount: z.number().int().nonnegative(),
|
||||
unmatchedCount: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'export:note': {
|
||||
req: z.object({
|
||||
|
|
|
|||
201
apps/x/packages/shared/src/sessions.test.ts
Normal file
201
apps/x/packages/shared/src/sessions.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SessionCorruptionError,
|
||||
SessionCreated,
|
||||
SessionEvent,
|
||||
SessionTurnAppended,
|
||||
reduceSession,
|
||||
sessionIndexEntry,
|
||||
} from "./sessions.js";
|
||||
|
||||
type SEvent = z.infer<typeof SessionEvent>;
|
||||
|
||||
const SESSION_ID = "2026-07-02T09-00-00Z-0000042-000";
|
||||
const MODEL = { provider: "openai", model: "gpt-test" };
|
||||
|
||||
function sessionCreated(
|
||||
overrides: Partial<z.infer<typeof SessionCreated>> = {},
|
||||
): z.infer<typeof SessionCreated> {
|
||||
return {
|
||||
type: "session_created",
|
||||
schemaVersion: 1,
|
||||
sessionId: SESSION_ID,
|
||||
ts: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turnAppended(
|
||||
sessionSeq: number,
|
||||
turnId: string,
|
||||
overrides: Partial<z.infer<typeof SessionTurnAppended>> = {},
|
||||
): z.infer<typeof SessionTurnAppended> {
|
||||
return {
|
||||
type: "turn_appended",
|
||||
sessionId: SESSION_ID,
|
||||
ts: `2026-07-02T09:0${sessionSeq}:00Z`,
|
||||
turnId,
|
||||
sessionSeq,
|
||||
agentId: "copilot",
|
||||
model: MODEL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function titleChanged(title: string, ts = "2026-07-02T09:05:00Z") {
|
||||
return {
|
||||
type: "title_changed" as const,
|
||||
sessionId: SESSION_ID,
|
||||
ts,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function expectCorruption(events: SEvent[], match: string | RegExp): void {
|
||||
expect(() => reduceSession(events)).toThrowError(SessionCorruptionError);
|
||||
expect(() => reduceSession(events)).toThrowError(match);
|
||||
}
|
||||
|
||||
describe("schemas", () => {
|
||||
it("session events round-trip through the SessionEvent schema", () => {
|
||||
const events: SEvent[] = [
|
||||
sessionCreated({ title: "Hello" }),
|
||||
turnAppended(1, "turn-1"),
|
||||
titleChanged("Renamed"),
|
||||
];
|
||||
for (const event of events) {
|
||||
expect(SessionEvent.parse(event)).toEqual(event);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-positive sessionSeq at the schema level", () => {
|
||||
expect(() => SessionEvent.parse(turnAppended(0, "turn-0"))).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceSession", () => {
|
||||
it("reduces a created-only log", () => {
|
||||
const state = reduceSession([sessionCreated()]);
|
||||
expect(state.definition.sessionId).toBe(SESSION_ID);
|
||||
expect(state.turns).toEqual([]);
|
||||
expect(state.latestTurnId).toBeUndefined();
|
||||
expect(state.title).toBeUndefined();
|
||||
expect(state.createdAt).toBe("2026-07-02T09:00:00Z");
|
||||
expect(state.updatedAt).toBe("2026-07-02T09:00:00Z");
|
||||
});
|
||||
|
||||
it("folds turns in order with denormalized metadata", () => {
|
||||
const state = reduceSession([
|
||||
sessionCreated(),
|
||||
turnAppended(1, "turn-1"),
|
||||
turnAppended(2, "turn-2", { agentId: "researcher", model: { provider: "anthropic", model: "claude-x" } }),
|
||||
]);
|
||||
expect(state.turns).toHaveLength(2);
|
||||
expect(state.turns[0]).toMatchObject({ turnId: "turn-1", sessionSeq: 1, agentId: "copilot" });
|
||||
expect(state.turns[1]).toMatchObject({
|
||||
turnId: "turn-2",
|
||||
sessionSeq: 2,
|
||||
agentId: "researcher",
|
||||
model: { provider: "anthropic", model: "claude-x" },
|
||||
});
|
||||
expect(state.latestTurnId).toBe("turn-2");
|
||||
expect(state.updatedAt).toBe("2026-07-02T09:02:00Z");
|
||||
});
|
||||
|
||||
it("folds titles: creation default then last change wins", () => {
|
||||
const withDefault = reduceSession([sessionCreated({ title: "First message…" })]);
|
||||
expect(withDefault.title).toBe("First message…");
|
||||
|
||||
const renamed = reduceSession([
|
||||
sessionCreated({ title: "First message…" }),
|
||||
titleChanged("Better title"),
|
||||
titleChanged("Best title", "2026-07-02T09:06:00Z"),
|
||||
]);
|
||||
expect(renamed.title).toBe("Best title");
|
||||
expect(renamed.updatedAt).toBe("2026-07-02T09:06:00Z");
|
||||
});
|
||||
|
||||
it("rejects an empty log", () => {
|
||||
expectCorruption([], /empty/);
|
||||
});
|
||||
|
||||
it("rejects a log not starting with session_created", () => {
|
||||
expectCorruption([turnAppended(1, "turn-1")], /first event must be session_created/);
|
||||
});
|
||||
|
||||
it("rejects duplicate session_created", () => {
|
||||
expectCorruption([sessionCreated(), sessionCreated()], /duplicate session_created/);
|
||||
});
|
||||
|
||||
it("rejects mismatched session ids", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), { ...turnAppended(1, "turn-1"), sessionId: "other" }],
|
||||
/does not match session/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
const bad = { ...sessionCreated(), schemaVersion: 2 } as unknown as SEvent;
|
||||
expectCorruption([bad], /unsupported session schema version/);
|
||||
});
|
||||
|
||||
it("rejects unknown event types", () => {
|
||||
const bad = { type: "wat", sessionId: SESSION_ID, ts: "t" } as unknown as SEvent;
|
||||
expectCorruption([sessionCreated(), bad], /unknown session event type/);
|
||||
});
|
||||
|
||||
it("rejects a first turn not at sessionSeq 1", () => {
|
||||
expectCorruption([sessionCreated(), turnAppended(2, "turn-2")], /out of order; expected 1/);
|
||||
});
|
||||
|
||||
it("rejects gapped sessionSeq", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(3, "turn-3")],
|
||||
/out of order; expected 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate sessionSeq", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(1, "turn-1b")],
|
||||
/out of order; expected 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate turn ids", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(2, "turn-1")],
|
||||
/duplicate turnId/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionIndexEntry", () => {
|
||||
it("projects an entry with last-turn metadata and the provided status", () => {
|
||||
const state = reduceSession([
|
||||
sessionCreated({ title: "T" }),
|
||||
turnAppended(1, "turn-1"),
|
||||
turnAppended(2, "turn-2", { agentId: "researcher" }),
|
||||
]);
|
||||
expect(sessionIndexEntry(state, "suspended")).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
title: "T",
|
||||
createdAt: "2026-07-02T09:00:00Z",
|
||||
updatedAt: "2026-07-02T09:02:00Z",
|
||||
turnCount: 2,
|
||||
lastAgentId: "researcher",
|
||||
lastModel: MODEL,
|
||||
latestTurnId: "turn-2",
|
||||
latestTurnStatus: "suspended",
|
||||
});
|
||||
});
|
||||
|
||||
it("projects an empty session with status none", () => {
|
||||
const state = reduceSession([sessionCreated()]);
|
||||
const entry = sessionIndexEntry(state, "none");
|
||||
expect(entry.turnCount).toBe(0);
|
||||
expect(entry.lastAgentId).toBeUndefined();
|
||||
expect(entry.latestTurnStatus).toBe("none");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue