From 5f4113d1cabce3dd600842b1541d642b81d1650b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:41:16 -0600 Subject: [PATCH 01/27] feat(agents-server): add project store with env-paths Co-Authored-By: Claude Opus 4.6 --- packages/agents-server/package.json | 1 + packages/agents-server/src/project-store.ts | 92 +++++++++++++++++++++ pnpm-lock.yaml | 17 ++++ 3 files changed, 110 insertions(+) create mode 100644 packages/agents-server/src/project-store.ts diff --git a/packages/agents-server/package.json b/packages/agents-server/package.json index cd092eb0de..7e28219706 100644 --- a/packages/agents-server/package.json +++ b/packages/agents-server/package.json @@ -54,6 +54,7 @@ "ajv": "^8.18.0", "cron-parser": "^5.5.0", "drizzle-orm": "^0.44.0", + "env-paths": "^4.0.0", "fastq": "^1.20.1", "lmdb": "^3.5.1", "pino": "^10.3.1", diff --git a/packages/agents-server/src/project-store.ts b/packages/agents-server/src/project-store.ts new file mode 100644 index 0000000000..3d4f924e31 --- /dev/null +++ b/packages/agents-server/src/project-store.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import envPaths from 'env-paths' +import { nanoid } from 'nanoid' + +export interface Project { + id: string + name: string + path: string + createdAt: number +} + +const paths = envPaths(`electric-agents`, { suffix: `` }) +const PROJECTS_FILE = path.join(paths.data, `projects.json`) + +async function ensureDir(): Promise { + await fs.mkdir(path.dirname(PROJECTS_FILE), { recursive: true }) +} + +async function readProjects(): Promise> { + try { + const raw = await fs.readFile(PROJECTS_FILE, `utf-8`) + return JSON.parse(raw) as Array + } catch (err: unknown) { + if ( + err instanceof Error && + `code` in err && + (err as NodeJS.ErrnoException).code === `ENOENT` + ) { + return [] + } + throw err + } +} + +async function writeProjects(projects: Array): Promise { + await ensureDir() + await fs.writeFile(PROJECTS_FILE, JSON.stringify(projects, null, 2), `utf-8`) +} + +export async function listProjects(): Promise> { + return readProjects() +} + +export async function createProject( + name: string, + projectPath: string +): Promise { + const projects = await readProjects() + const project: Project = { + id: nanoid(8), + name, + path: projectPath, + createdAt: Date.now(), + } + projects.push(project) + await writeProjects(projects) + return project +} + +export async function updateProject( + id: string, + updates: { name?: string; path?: string } +): Promise { + const projects = await readProjects() + const idx = projects.findIndex((p) => p.id === id) + if (idx === -1) return null + if (updates.name !== undefined) projects[idx].name = updates.name + if (updates.path !== undefined) projects[idx].path = updates.path + await writeProjects(projects) + return projects[idx] +} + +export async function deleteProject(id: string): Promise { + const projects = await readProjects() + const filtered = projects.filter((p) => p.id !== id) + if (filtered.length === projects.length) return false + await writeProjects(filtered) + return true +} + +export async function validatePath( + dirPath: string +): Promise<{ valid: boolean; resolved: string }> { + try { + const resolved = await fs.realpath(dirPath) + const stat = await fs.stat(resolved) + return { valid: stat.isDirectory(), resolved } + } catch { + return { valid: false, resolved: dirPath } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae2a2dcd4a..696d4dbc09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1690,6 +1690,9 @@ importers: drizzle-orm: specifier: ^0.44.0 version: 0.44.3(@electric-sql/pglite@0.4.5)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.4)(better-sqlite3@11.10.0)(gel@2.2.0)(kysely@0.28.7)(pg@8.16.3)(postgres@3.4.7) + env-paths: + specifier: ^4.0.0 + version: 4.0.0 fastq: specifier: ^1.20.1 version: 1.20.1 @@ -12042,6 +12045,10 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + env-paths@4.0.0: + resolution: {integrity: sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==} + engines: {node: '>=20'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -13652,6 +13659,10 @@ packages: resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} engines: {node: '>=0.10.0'} + is-safe-filename@0.1.1: + resolution: {integrity: sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==} + engines: {node: '>=20'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -30792,6 +30803,10 @@ snapshots: env-paths@3.0.0: {} + env-paths@4.0.0: + dependencies: + is-safe-filename: 0.1.1 + environment@1.1.0: {} enzyme-shallow-equal@1.0.7: @@ -33103,6 +33118,8 @@ snapshots: is-regexp@1.0.0: {} + is-safe-filename@0.1.1: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.3: From 0fef98dec1eeedb6c103ae7e9a6f34ea98cc22d1 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:42:19 -0600 Subject: [PATCH 02/27] feat(agents-server): add project CRUD API routes Co-Authored-By: Claude Opus 4.6 --- packages/agents-server/src/project-routes.ts | 146 +++++++++++++++++++ packages/agents-server/src/server.ts | 19 +++ 2 files changed, 165 insertions(+) create mode 100644 packages/agents-server/src/project-routes.ts diff --git a/packages/agents-server/src/project-routes.ts b/packages/agents-server/src/project-routes.ts new file mode 100644 index 0000000000..b175f818bb --- /dev/null +++ b/packages/agents-server/src/project-routes.ts @@ -0,0 +1,146 @@ +import { + sendJson, + sendJsonError, + parseJsonBody, +} from './electric-agents-http.js' +import { + listProjects, + createProject, + updateProject, + deleteProject, + validatePath, +} from './project-store.js' +import type { IncomingMessage, ServerResponse } from 'node:http' + +export class ProjectRoutes { + async handleRequest( + method: string, + path: string, + req: IncomingMessage, + res: ServerResponse + ): Promise { + if (path === `/_electric/projects` && method === `GET`) { + await this.handleList(res) + return true + } + + if (path === `/_electric/projects` && method === `POST`) { + await this.handleCreate(req, res) + return true + } + + if (path === `/_electric/validate-path` && method === `POST`) { + await this.handleValidatePath(req, res) + return true + } + + const match = path.match(/^\/_electric\/projects\/([^/]+)$/) + if (!match) return false + + const id = match[1]! + + if (method === `PATCH`) { + await this.handleUpdate(id, req, res) + return true + } + + if (method === `DELETE`) { + await this.handleDelete(id, res) + return true + } + + return false + } + + private async handleList(res: ServerResponse): Promise { + const projects = await listProjects() + sendJson(res, 200, projects) + } + + private async handleCreate( + req: IncomingMessage, + res: ServerResponse + ): Promise { + const body = await parseJsonBody<{ name?: string; path?: string }>(req, res) + if (!body) return + + if (!body.name || typeof body.name !== `string`) { + sendJsonError(res, 400, `INVALID_REQUEST`, `"name" is required`) + return + } + if (!body.path || typeof body.path !== `string`) { + sendJsonError(res, 400, `INVALID_REQUEST`, `"path" is required`) + return + } + + const validation = await validatePath(body.path) + if (!validation.valid) { + sendJsonError( + res, + 400, + `INVALID_PATH`, + `Path is not a valid directory: ${body.path}` + ) + return + } + + const project = await createProject(body.name, validation.resolved) + sendJson(res, 201, project) + } + + private async handleUpdate( + id: string, + req: IncomingMessage, + res: ServerResponse + ): Promise { + const body = await parseJsonBody<{ name?: string; path?: string }>(req, res) + if (!body) return + + if (body.path !== undefined) { + const validation = await validatePath(body.path) + if (!validation.valid) { + sendJsonError( + res, + 400, + `INVALID_PATH`, + `Path is not a valid directory: ${body.path}` + ) + return + } + body.path = validation.resolved + } + + const project = await updateProject(id, body) + if (!project) { + sendJsonError(res, 404, `NOT_FOUND`, `Project not found`) + return + } + sendJson(res, 200, project) + } + + private async handleDelete(id: string, res: ServerResponse): Promise { + const deleted = await deleteProject(id) + if (!deleted) { + sendJsonError(res, 404, `NOT_FOUND`, `Project not found`) + return + } + res.writeHead(204) + res.end() + } + + private async handleValidatePath( + req: IncomingMessage, + res: ServerResponse + ): Promise { + const body = await parseJsonBody<{ path?: string }>(req, res) + if (!body) return + + if (!body.path || typeof body.path !== `string`) { + sendJsonError(res, 400, `INVALID_REQUEST`, `"path" is required`) + return + } + + const result = await validatePath(body.path) + sendJson(res, 200, result) + } +} diff --git a/packages/agents-server/src/server.ts b/packages/agents-server/src/server.ts index d5ce0e00a1..6e8f1980ff 100644 --- a/packages/agents-server/src/server.ts +++ b/packages/agents-server/src/server.ts @@ -33,6 +33,7 @@ import { SchemaValidator } from './electric-agents-schema-validator.js' import { ElectricAgentsManager } from './electric-agents-manager.js' import { ElectricAgentsRoutes } from './electric-agents-routes.js' import { ElectricAgentsEntityTypeRoutes } from './electric-agents-entity-type-routes.js' +import { ProjectRoutes } from './project-routes.js' import { Scheduler, isPermanentElectricAgentsError } from './scheduler.js' import { StreamClient } from './stream-client.js' import { serverLog } from './log.js' @@ -179,6 +180,7 @@ export class ElectricAgentsServer { private electricAgentsRoutes: ElectricAgentsRoutes | null = null private electricAgentsEntityTypeRoutes: ElectricAgentsEntityTypeRoutes | null = null + private projectRoutes: ProjectRoutes | null = null private registry: PostgresRegistry | null = null private pgDb: DrizzleDB | null = null private pgClient: PgClient | null = null @@ -422,6 +424,7 @@ export class ElectricAgentsServer { ) this.electricAgentsEntityTypeRoutes = new ElectricAgentsEntityTypeRoutes(this.electricAgentsManager) + this.projectRoutes = new ProjectRoutes() if (this.options.mockStreamFn) { this.mockAgentBootstrap = createMockAgentBootstrap({ @@ -484,6 +487,7 @@ export class ElectricAgentsServer { this.electricAgentsManager = null this.electricAgentsRoutes = null this.electricAgentsEntityTypeRoutes = null + this.projectRoutes = null this.registry = null } @@ -711,6 +715,21 @@ export class ElectricAgentsServer { return } + if ( + this.projectRoutes && + method && + (path.startsWith(`/_electric/projects`) || + path === `/_electric/validate-path`) + ) { + const handled = await this.projectRoutes.handleRequest( + method, + path, + req, + res + ) + if (handled) return + } + if ( this.electricAgentsEntityTypeRoutes && method && From 73c70fb59eeaffda77cde24c4dbb1d9b0fb56ffa Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:42:59 -0600 Subject: [PATCH 03/27] feat(agents): add optional workingDirectory spawn arg to horton Co-Authored-By: Claude Opus 4.6 --- packages/agents/src/agents/horton.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 0cabc5bc5d..a07d706764 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -327,10 +327,14 @@ function createAssistantHandler(options: { wake: WakeEvent ): Promise { const readSet = new Set() + const effectiveCwd = + typeof ctx.args.workingDirectory === `string` + ? ctx.args.workingDirectory + : workingDirectory const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args) const tools = [ ...ctx.electricTools, - ...createHortonTools(workingDirectory, ctx, readSet, { + ...createHortonTools(effectiveCwd, ctx, readSet, { docsSearchTool, modelConfig, }), @@ -391,7 +395,7 @@ function createAssistantHandler(options: { } ctx.useAgent({ - systemPrompt: buildHortonSystemPrompt(workingDirectory, { + systemPrompt: buildHortonSystemPrompt(effectiveCwd, { hasDocsSupport: Boolean(docsSupport), hasSkills, docsUrl, @@ -485,6 +489,12 @@ export function registerHorton( .describe( `Reasoning effort for compatible reasoning models. Auto uses a safe provider default.` ), + workingDirectory: z + .string() + .optional() + .describe( + `Working directory for file operations. Defaults to the server's configured cwd.` + ), }) registry.define(`horton`, { From 75eff4168b2ff1fd6c43069719aebf9c44f930e9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:43:54 -0600 Subject: [PATCH 04/27] feat(agents-server-ui): rewrite useProjects to fetch from server API Replaces localStorage-only implementation with server-backed CRUD. Projects are now persisted on disk via the agents-server API. Co-Authored-By: Claude Opus 4.6 --- .../src/hooks/useProjects.tsx | 164 ++++++++++++------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/packages/agents-server-ui/src/hooks/useProjects.tsx b/packages/agents-server-ui/src/hooks/useProjects.tsx index 611663bf78..244d98490b 100644 --- a/packages/agents-server-ui/src/hooks/useProjects.tsx +++ b/packages/agents-server-ui/src/hooks/useProjects.tsx @@ -1,10 +1,17 @@ -import { createContext, useCallback, useContext, useState } from 'react' -import { nanoid } from 'nanoid' +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' +import { useServerConnection } from './useServerConnection' import type { ReactNode } from 'react' export interface Project { id: string name: string + path: string createdAt: number } @@ -12,81 +19,128 @@ interface ProjectsState { projects: Array activeProjectId: string | null setActiveProjectId: (id: string | null) => void - createProject: (name: string) => Project - deleteProject: (id: string) => void - renameProject: (id: string, name: string) => void + createProject: (name: string, path: string) => Promise + deleteProject: (id: string) => Promise + renameProject: (id: string, name: string) => Promise + validatePath: (path: string) => Promise<{ valid: boolean; resolved: string }> + loading: boolean } const ProjectsContext = createContext(null) -const STORAGE_KEY = `electric-agents-projects` const ACTIVE_PROJECT_KEY = `electric-agents-active-project` -function loadProjects(): Array { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? `[]`) - } catch { - return [] - } -} - -function persistProjects(projects: Array): void { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(projects)) - } catch { - // Ignore quota errors - } -} - export function ProjectsProvider({ children, }: { children: ReactNode }): React.ReactElement { - const [projects, setProjects] = useState>(loadProjects) + const { activeServer } = useServerConnection() + const baseUrl = activeServer?.url ?? null + + const [projects, setProjects] = useState>([]) + const [loading, setLoading] = useState(false) const [activeProjectId, setActiveProjectIdRaw] = useState( () => localStorage.getItem(ACTIVE_PROJECT_KEY) ?? null ) const setActiveProjectId = useCallback((id: string | null) => { setActiveProjectIdRaw(id) + if (id) { + localStorage.setItem(ACTIVE_PROJECT_KEY, id) + } else { + localStorage.removeItem(ACTIVE_PROJECT_KEY) + } + }, []) + + const fetchProjects = useCallback(async () => { + if (!baseUrl) return + setLoading(true) try { - if (id) { - localStorage.setItem(ACTIVE_PROJECT_KEY, id) - } else { - localStorage.removeItem(ACTIVE_PROJECT_KEY) + const res = await fetch(`${baseUrl}/_electric/projects`) + if (res.ok) { + const data = (await res.json()) as Array + setProjects(data) } - } catch { - // Ignore + } finally { + setLoading(false) } - }, []) + }, [baseUrl]) - const createProject = useCallback((name: string): Project => { - const project: Project = { id: nanoid(8), name, createdAt: Date.now() } - setProjects((prev) => { - const next = [...prev, project] - persistProjects(next) - return next - }) - return project - }, []) + useEffect(() => { + void fetchProjects() + }, [fetchProjects]) - const deleteProject = useCallback((id: string) => { - setProjects((prev) => { - const next = prev.filter((p) => p.id !== id) - persistProjects(next) - return next - }) - setActiveProjectIdRaw((prev) => (prev === id ? null : prev)) - }, []) + const createProject = useCallback( + async (name: string, projectPath: string): Promise => { + if (!baseUrl) throw new Error(`No server connected`) + const res = await fetch(`${baseUrl}/_electric/projects`, { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ name, path: projectPath }), + }) + if (!res.ok) { + const text = await res.text().catch(() => ``) + let message = `Create failed (${res.status})` + try { + const data = JSON.parse(text) as { error?: { message?: string } } + if (data.error?.message) message = data.error.message + } catch { + if (text) message = text + } + throw new Error(message) + } + const project = (await res.json()) as Project + setProjects((prev) => [...prev, project]) + return project + }, + [baseUrl] + ) - const renameProject = useCallback((id: string, name: string) => { - setProjects((prev) => { - const next = prev.map((p) => (p.id === id ? { ...p, name } : p)) - persistProjects(next) - return next - }) - }, []) + const deleteProject = useCallback( + async (id: string): Promise => { + if (!baseUrl) return + const res = await fetch(`${baseUrl}/_electric/projects/${id}`, { + method: `DELETE`, + }) + if (res.ok || res.status === 204) { + setProjects((prev) => prev.filter((p) => p.id !== id)) + setActiveProjectIdRaw((prev) => (prev === id ? null : prev)) + } + }, + [baseUrl] + ) + + const renameProject = useCallback( + async (id: string, name: string): Promise => { + if (!baseUrl) return + const res = await fetch(`${baseUrl}/_electric/projects/${id}`, { + method: `PATCH`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ name }), + }) + if (res.ok) { + setProjects((prev) => + prev.map((p) => (p.id === id ? { ...p, name } : p)) + ) + } + }, + [baseUrl] + ) + + const validatePathFn = useCallback( + async (dirPath: string): Promise<{ valid: boolean; resolved: string }> => { + if (!baseUrl) return { valid: false, resolved: dirPath } + const res = await fetch(`${baseUrl}/_electric/validate-path`, { + method: `POST`, + headers: { 'content-type': `application/json` }, + body: JSON.stringify({ path: dirPath }), + }) + if (!res.ok) return { valid: false, resolved: dirPath } + return (await res.json()) as { valid: boolean; resolved: string } + }, + [baseUrl] + ) return ( {children} From 3cfd09c1269226485727df22ec6234b494c7dae4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:46:56 -0600 Subject: [PATCH 05/27] feat(agents-server-ui): redesign new session page with hero project picker Replaces the old Select-based project picker with a centered hero layout: "Let's build ^" with a Popover for project selection and inline creation with path validation. Co-Authored-By: Claude Opus 4.6 --- .../src/components/NewSessionPage.module.css | 87 +++- .../src/components/NewSessionPage.tsx | 382 ++++++++++-------- 2 files changed, 299 insertions(+), 170 deletions(-) diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index fa7b5ddc3a..1809819c92 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -22,12 +22,15 @@ display: flex; flex-direction: column; gap: var(--ds-space-5); + align-items: center; } .heading { display: flex; flex-direction: column; - gap: var(--ds-space-1); + align-items: center; + gap: var(--ds-space-2); + padding-top: var(--ds-space-9); } .headingTitle { @@ -273,30 +276,80 @@ color: var(--ds-accent-10); } -/* Project picker -------------------------------------------------- */ +/* Hero project picker ---------------------------------------------- */ -.projectPicker { - display: flex; - flex-direction: column; +.projectTrigger { + all: unset; + display: inline-flex; + align-items: center; gap: var(--ds-space-1); + cursor: pointer; + color: var(--ds-text-3); + font-size: var(--ds-text-2xl); + font-weight: 400; + font-family: var(--ds-font-body); + transition: color 0.12s ease; } -.projectPickerLabel { - text-transform: uppercase; - letter-spacing: 0.06em; - font-size: 10px; +.projectTrigger:hover { + color: var(--ds-text-2); +} +.projectTriggerChevron { + flex-shrink: 0; + transition: transform 0.15s ease; +} +.projectTriggerChevron[data-open='true'] { + transform: rotate(180deg); +} + +.projectPopover { + min-width: 240px; } -.projectPickerRow { +.projectPopoverHeader { + padding: var(--ds-space-2) var(--ds-space-3); + font-size: var(--ds-text-xs); + color: var(--ds-text-3); +} +.projectItem { + all: unset; display: flex; align-items: center; gap: var(--ds-space-2); - flex-wrap: wrap; + width: 100%; + padding: var(--ds-space-2) var(--ds-space-3); + border-radius: 7px; + cursor: pointer; + font-size: var(--ds-text-sm); + color: var(--ds-text-1); + box-sizing: border-box; + transition: background 0.1s ease; +} +.projectItem:hover { + background: var(--ds-gray-a2); +} +.projectItemIcon { + color: var(--ds-text-3); + flex-shrink: 0; +} +.projectItemCheck { + margin-left: auto; + color: var(--ds-text-3); + flex-shrink: 0; } -.projectCreateForm { + +.projectCreateInline { + display: flex; + flex-direction: column; + gap: var(--ds-space-2); + padding: var(--ds-space-2) var(--ds-space-3); +} +.projectCreateRow { display: flex; align-items: center; gap: var(--ds-space-2); } .projectCreateInput { + flex: 1; + min-width: 0; border: 1px solid var(--ds-border-1); border-radius: var(--ds-radius-2); padding: 4px 8px; @@ -310,6 +363,15 @@ .projectCreateInput:focus { border-color: var(--ds-accent-a6); } +.projectPathInput { + composes: projectCreateInput; + font-family: var(--ds-font-mono); + font-size: var(--ds-text-xs); +} +.projectPathError { + font-size: var(--ds-text-xs); + color: var(--ds-red-11); +} .projectCreateBtn { all: unset; cursor: pointer; @@ -317,6 +379,7 @@ color: var(--ds-accent-9); padding: 4px 8px; border-radius: var(--ds-radius-2); + flex-shrink: 0; } .projectCreateBtn:hover { background: var(--ds-accent-a2); diff --git a/packages/agents-server-ui/src/components/NewSessionPage.tsx b/packages/agents-server-ui/src/components/NewSessionPage.tsx index 14cd5f1c10..beec214c42 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.tsx +++ b/packages/agents-server-ui/src/components/NewSessionPage.tsx @@ -1,5 +1,11 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { ArrowUp } from 'lucide-react' +import { + ArrowUp, + Check, + ChevronDown, + FolderOpen, + FolderPlus, +} from 'lucide-react' import { useLiveQuery } from '@tanstack/react-db' import { eq, not } from '@tanstack/db' import { useNavigate } from '@tanstack/react-router' @@ -7,20 +13,13 @@ import { nanoid } from 'nanoid' import { useElectricAgents } from '../lib/ElectricAgentsProvider' import { useServerConnection } from '../hooks/useServerConnection' import { useProjects } from '../hooks/useProjects' -import { Select, Stack, Text } from '../ui' +import { Popover, Select, Stack, Text } from '../ui' import { MainHeader } from './MainHeader' import { SchemaForm, hasSchemaProperties, isObjectSchema } from './SchemaForm' import styles from './NewSessionPage.module.css' import type { ElectricEntityType } from '../lib/ElectricAgentsProvider' +import type { Project } from '../hooks/useProjects' -/** - * The "default agent" — when an entity type with this name is registered - * we surface a chat-input quick-start at the top of the new-session page - * so the most common flow is one keystroke away. - * - * TODO: replace this with a server-side flag (e.g. tags.default) once - * the entity_types schema gets one. - */ const DEFAULT_AGENT_NAME = `horton` interface SchemaProperty { @@ -31,24 +30,25 @@ interface SchemaProperty { description?: string } -/** - * "New session" page shown at `/`. - * - * If a `horton` entity type is available we render a chat-style - * composer at the top of the page so the user can just type and hit - * Enter to start a new conversation. Other agent types are listed - * below as cards. Picking one of those either spawns immediately - * (no schema) or transitions to an inline form. - */ export function NewSessionPage(): React.ReactElement { const navigate = useNavigate() const { entityTypesCollection, spawnEntity } = useElectricAgents() const { activeServer } = useServerConnection() - const { projects, activeProjectId, setActiveProjectId, createProject } = - useProjects() + const { + projects, + activeProjectId, + setActiveProjectId, + createProject, + validatePath, + } = useProjects() const [selected, setSelected] = useState(null) const [error, setError] = useState(null) + const activeProject = useMemo( + () => projects.find((p) => p.id === activeProjectId) ?? null, + [projects, activeProjectId] + ) + const { data: entityTypes = [] } = useLiveQuery( (query) => { if (!entityTypesCollection) return undefined @@ -71,12 +71,6 @@ export function NewSessionPage(): React.ReactElement { const baseUrl = activeServer?.url ?? null - /** - * Spawn an entity, optionally followed by a `/send` of an initial - * user message. We prefer this two-step over `initialMessage` on - * spawn so the message goes through the same path as the regular - * MessageInput (which is the proven path that wakes horton). - */ const doSpawn = useCallback( async ( typeName: string, @@ -89,7 +83,15 @@ export function NewSessionPage(): React.ReactElement { const tags: Record | undefined = activeProjectId ? { project: activeProjectId } : undefined - const tx = spawnEntity({ type: typeName, name, args, tags }) + const spawnArgs = activeProject?.path + ? { ...args, workingDirectory: activeProject.path } + : args + const tx = spawnEntity({ + type: typeName, + name, + args: spawnArgs, + tags, + }) navigate({ to: `/entity/$`, params: { _splat: `${typeName}/${name}` }, @@ -116,7 +118,7 @@ export function NewSessionPage(): React.ReactElement { ) } }, - [navigate, spawnEntity, baseUrl, activeProjectId] + [navigate, spawnEntity, baseUrl, activeProjectId, activeProject] ) const handleSelectType = useCallback( @@ -159,9 +161,10 @@ export function NewSessionPage(): React.ReactElement { spawnReady={Boolean(spawnEntity)} error={error} projects={projects} - activeProjectId={activeProjectId} + activeProject={activeProject} onChangeProject={setActiveProjectId} onCreateProject={createProject} + onValidatePath={validatePath} /> )} @@ -178,9 +181,10 @@ function Picker({ spawnReady, error, projects, - activeProjectId, + activeProject, onChangeProject, onCreateProject, + onValidatePath, }: { defaultAgent: ElectricEntityType | null otherAgents: Array @@ -188,33 +192,31 @@ function Picker({ onStartDefault: (text: string, args: Record) => void spawnReady: boolean error: string | null - projects: Array<{ id: string; name: string }> - activeProjectId: string | null + projects: Array + activeProject: Project | null onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } + onCreateProject: (name: string, path: string) => Promise + onValidatePath: ( + path: string + ) => Promise<{ valid: boolean; resolved: string }> }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 return ( - +
- Start a new session + Let’s build - - {defaultAgent - ? `Type a message to start a new ${defaultAgent.name} chat, or pick another agent below.` - : `Pick the kind of agent you want to spawn.`} - +
- - {error &&
{error}
} {defaultAgent && ( @@ -263,6 +265,178 @@ function Picker({ ) } +function ProjectPicker({ + projects, + activeProject, + onChangeProject, + onCreateProject, + onValidatePath, +}: { + projects: Array + activeProject: Project | null + onChangeProject: (id: string | null) => void + onCreateProject: (name: string, path: string) => Promise + onValidatePath: ( + path: string + ) => Promise<{ valid: boolean; resolved: string }> +}): React.ReactElement { + const [open, setOpen] = useState(false) + const [creating, setCreating] = useState(false) + const [newName, setNewName] = useState(``) + const [newPath, setNewPath] = useState(``) + const [pathError, setPathError] = useState(null) + const [submitting, setSubmitting] = useState(false) + const nameRef = useRef(null) + + const resetForm = useCallback(() => { + setCreating(false) + setNewName(``) + setNewPath(``) + setPathError(null) + }, []) + + const handleCreate = useCallback(async () => { + const trimmedName = newName.trim() + const trimmedPath = newPath.trim() + if (!trimmedName || !trimmedPath) return + + setSubmitting(true) + setPathError(null) + try { + const validation = await onValidatePath(trimmedPath) + if (!validation.valid) { + setPathError(`Not a valid directory`) + setSubmitting(false) + return + } + const project = await onCreateProject(trimmedName, trimmedPath) + onChangeProject(project.id) + resetForm() + setOpen(false) + } catch (err) { + setPathError(err instanceof Error ? err.message : String(err)) + } finally { + setSubmitting(false) + } + }, [ + newName, + newPath, + onValidatePath, + onCreateProject, + onChangeProject, + resetForm, + ]) + + return ( + { + setOpen(nextOpen) + if (!nextOpen) resetForm() + }} + > + + {activeProject?.name ?? `Select a project`} + + + } + /> + +
Select your project
+ + {projects.map((p) => ( + + ))} + + {!creating ? ( + + ) : ( +
{ + e.preventDefault() + void handleCreate() + }} + > + setNewName(e.target.value)} + placeholder="Project name" + className={styles.projectCreateInput} + onKeyDown={(e) => { + if (e.key === `Escape`) resetForm() + }} + /> +
+ { + setNewPath(e.target.value) + setPathError(null) + }} + placeholder="/path/to/project" + className={styles.projectPathInput} + onKeyDown={(e) => { + if (e.key === `Escape`) resetForm() + }} + /> + +
+ {pathError && ( + {pathError} + )} +
+ )} +
+
+ ) +} + function SelectedAgentForm({ entityType, onCancel, @@ -309,12 +483,6 @@ function SelectedAgentForm({ ) } -/** - * Walk the agent's `creation_schema` and pull out the keys we know how - * to render inline as compact controls (enums and booleans). Other - * fields fall through to schema defaults; if they're required without - * a default, the user can switch to the full form via "Other agents". - */ function inlineSchemaProperties( schema: unknown ): Array<{ key: string; prop: SchemaProperty }> { @@ -322,6 +490,7 @@ function inlineSchemaProperties( const out: Array<{ key: string; prop: SchemaProperty }> = [] for (const [key, raw] of Object.entries(schema.properties)) { const prop = raw as SchemaProperty + if (key === `workingDirectory`) continue if (prop.enum && prop.enum.length > 0) { out.push({ key, prop }) } else if (prop.type === `boolean`) { @@ -344,18 +513,13 @@ function DefaultAgentComposer({ const [submitting, setSubmitting] = useState(false) const textareaRef = useRef(null) - // Auto-grow the textarea up to the CSS `max-height` cap as the - // user types (matches the chat composer in `MessageInput.tsx`). - // Reset to `auto` first so `scrollHeight` reports the natural - // content height, then assign that back as the inline height; the - // CSS bounds clamp it. Layout effect ensures the resize lands - // before paint so there's no one-frame flicker. useLayoutEffect(() => { const el = textareaRef.current if (!el) return el.style.height = `auto` el.style.height = `${el.scrollHeight}px` }, [value]) + const inlineProps = useMemo( () => inlineSchemaProperties(agent.creation_schema), [agent.creation_schema] @@ -378,8 +542,6 @@ function DefaultAgentComposer({ const trimmed = value.trim() if (!trimmed || disabled || submitting) return setSubmitting(true) - // Strip undefined/empty values so the server can fall back to schema - // defaults instead of receiving an explicit empty/null. const cleaned: Record = {} for (const [k, v] of Object.entries(args)) { if (v !== undefined && v !== ``) cleaned[k] = v @@ -462,13 +624,6 @@ function DefaultAgentComposer({ ) } -/** - * Tiny dropdown rendered as a borderless pill so it sits cleanly - * in the composer footer without competing visually with the textarea. - * Backed by the Base UI `Select` so we get a custom popover with proper - * keyboard semantics (instead of the OS-native picker, which doesn't - * blend with the rest of the surface). - */ function PillSelect({ label, value, @@ -531,92 +686,3 @@ function PillToggle({ ) } - -function ProjectPicker({ - projects, - activeProjectId, - onChangeProject, - onCreateProject, -}: { - projects: Array<{ id: string; name: string }> - activeProjectId: string | null - onChangeProject: (id: string | null) => void - onCreateProject: (name: string) => { id: string } -}): React.ReactElement { - const [creating, setCreating] = useState(false) - const [newName, setNewName] = useState(``) - const inputRef = useRef(null) - - const handleCreate = useCallback(() => { - const trimmed = newName.trim() - if (!trimmed) return - const project = onCreateProject(trimmed) - onChangeProject(project.id) - setNewName(``) - setCreating(false) - }, [newName, onCreateProject, onChangeProject]) - - return ( -
- - Project - -
- - value={activeProjectId ?? `__none__`} - onValueChange={(v) => { - if (v === `__new__`) { - setCreating(true) - setTimeout(() => inputRef.current?.focus(), 0) - } else { - onChangeProject(v === `__none__` ? null : v) - } - }} - > - - - No project - {projects.map((p) => ( - - {p.name} - - ))} - + New project… - - - - {creating && ( -
{ - e.preventDefault() - handleCreate() - }} - > - setNewName(e.target.value)} - placeholder="Project name" - className={styles.projectCreateInput} - onKeyDown={(e) => { - if (e.key === `Escape`) { - setCreating(false) - setNewName(``) - } - }} - /> - -
- )} -
-
- ) -} From cf714db53e47ad852fdaf2991f766bee18864748 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 16:49:20 -0600 Subject: [PATCH 06/27] feat(agents-server-ui): rotating hero verbs and bigger heading Cycles through verbs like "Let's ship", "Let's create", "Let's explore" every 4 seconds. Bumps heading to size 7. Co-Authored-By: Claude Opus 4.6 --- .../src/components/NewSessionPage.module.css | 1 + .../src/components/NewSessionPage.tsx | 38 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/agents-server-ui/src/components/NewSessionPage.module.css b/packages/agents-server-ui/src/components/NewSessionPage.module.css index 1809819c92..783df07ebf 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.module.css +++ b/packages/agents-server-ui/src/components/NewSessionPage.module.css @@ -36,6 +36,7 @@ .headingTitle { font-weight: 400; margin: 0; + transition: opacity 0.3s ease; } .headingSubtitle { diff --git a/packages/agents-server-ui/src/components/NewSessionPage.tsx b/packages/agents-server-ui/src/components/NewSessionPage.tsx index beec214c42..338096bbb5 100644 --- a/packages/agents-server-ui/src/components/NewSessionPage.tsx +++ b/packages/agents-server-ui/src/components/NewSessionPage.tsx @@ -1,4 +1,11 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { ArrowUp, Check, @@ -22,6 +29,30 @@ import type { Project } from '../hooks/useProjects' const DEFAULT_AGENT_NAME = `horton` +const HERO_VERBS = [ + `Let’s ship`, + `Let’s create`, + `Let’s build`, + `Let’s explore`, + `Let’s debug`, + `Let’s design`, + `Let’s hack on`, + `Let’s improve`, +] + +function useRotatingVerb(): string { + const [index, setIndex] = useState(() => + Math.floor(Math.random() * (HERO_VERBS.length - 1)) + ) + useEffect(() => { + const id = setInterval(() => { + setIndex((prev) => (prev + 1) % HERO_VERBS.length) + }, 4_000) + return () => clearInterval(id) + }, []) + return HERO_VERBS[index] +} + interface SchemaProperty { type?: string enum?: Array @@ -201,12 +232,13 @@ function Picker({ ) => Promise<{ valid: boolean; resolved: string }> }): React.ReactElement { const hasAnyAgent = defaultAgent !== null || otherAgents.length > 0 + const verb = useRotatingVerb() return (
- - Let’s build + + {verb} Date: Mon, 4 May 2026 16:52:55 -0600 Subject: [PATCH 07/27] feat(agents-server-ui): switch body font from Inter to Figtree Adds @fontsource-variable/figtree and imports it in main.tsx so the font actually loads. Updates --ds-font-body token accordingly. Co-Authored-By: Claude Opus 4.6 --- packages/agents-server-ui/package.json | 1 + packages/agents-server-ui/src/main.tsx | 1 + packages/agents-server-ui/src/ui/tokens.css | 2 +- pnpm-lock.yaml | 10 +++++++++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/agents-server-ui/package.json b/packages/agents-server-ui/package.json index 3852a54bb2..76d86d8ea7 100644 --- a/packages/agents-server-ui/package.json +++ b/packages/agents-server-ui/package.json @@ -16,6 +16,7 @@ "@durable-streams/client": "npm:@electric-ax/durable-streams-client-beta@^0.3.1", "@durable-streams/state": "npm:@electric-ax/durable-streams-state-beta@^0.3.1", "@electric-ax/agents-runtime": "workspace:*", + "@fontsource-variable/figtree": "^5.2.10", "@streamdown/math": "^1.0.2", "@tanstack/db": "^0.6.4", "@tanstack/electric-db-collection": "^0.3.2", diff --git a/packages/agents-server-ui/src/main.tsx b/packages/agents-server-ui/src/main.tsx index f6d4f7a390..1c69e42138 100644 --- a/packages/agents-server-ui/src/main.tsx +++ b/packages/agents-server-ui/src/main.tsx @@ -11,6 +11,7 @@ if (import.meta.env.DEV) { scan({ enabled: true }) } +import '@fontsource-variable/figtree' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './ui' diff --git a/packages/agents-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index 7689e332db..e423913c12 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -51,7 +51,7 @@ /* ---- Font families ------------------------------------------------- */ --ds-font-body: - Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', + Figtree, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --ds-font-heading: var(--ds-font-body); --ds-font-mono: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 696d4dbc09..da6ec17fbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1788,6 +1788,9 @@ importers: '@electric-ax/agents-runtime': specifier: workspace:* version: link:../agents-runtime + '@fontsource-variable/figtree': + specifier: ^5.2.10 + version: 5.2.10 '@streamdown/math': specifier: ^1.0.2 version: 1.0.2(react@19.2.0) @@ -5454,6 +5457,9 @@ packages: '@floating-ui/utils@0.2.8': resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@fontsource-variable/figtree@5.2.10': + resolution: {integrity: sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==} + '@fontsource/alegreya-sans@5.1.1': resolution: {integrity: sha512-vQAwr25Pk5N5Y924AxaGpipZQY9IdulIRS4+WXbNiHCVwDS/i6k7c46UdyBlhvnPM33JII7ndv/gJ9BP8i11bA==} @@ -23086,6 +23092,8 @@ snapshots: '@floating-ui/utils@0.2.8': {} + '@fontsource-variable/figtree@5.2.10': {} + '@fontsource/alegreya-sans@5.1.1': {} '@google/genai@1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': @@ -28664,7 +28672,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.3.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.2)(tsx@4.20.3)(yaml@2.8.1)) + vitest: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.0(@noble/hashes@2.0.1))(vite@7.1.7(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.46.2)(tsx@4.20.3)(yaml@2.8.1)) '@vitest/expect@3.2.4': dependencies: From 2ac47118502cc0d1dcf70390298a691a16ecab66 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 17:27:06 -0600 Subject: [PATCH 08/27] =?UTF-8?q?fix:=20review=20cleanup=20=E2=80=94=20off?= =?UTF-8?q?-by-one,=20stale=20project=20ID,=20cwd=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix useRotatingVerb off-by-one (last verb never appeared as initial) - Reconcile stale activeProjectId after fetching projects from server - Validate workingDirectory exists before running horton agent - Expand ~ in paths server-side (validatePath) - Use +
{pathError && ( {pathError} diff --git a/packages/agents-server-ui/src/hooks/useProjects.tsx b/packages/agents-server-ui/src/hooks/useProjects.tsx index 244d98490b..9c272b61a1 100644 --- a/packages/agents-server-ui/src/hooks/useProjects.tsx +++ b/packages/agents-server-ui/src/hooks/useProjects.tsx @@ -30,6 +30,20 @@ const ProjectsContext = createContext(null) const ACTIVE_PROJECT_KEY = `electric-agents-active-project` +async function parseErrorMessage( + res: Response, + fallback: string +): Promise { + const text = await res.text().catch(() => ``) + try { + const data = JSON.parse(text) as { error?: { message?: string } } + if (data.error?.message) return data.error.message + } catch { + if (text) return text + } + return `${fallback} (${res.status})` +} + export function ProjectsProvider({ children, }: { @@ -71,6 +85,16 @@ export function ProjectsProvider({ void fetchProjects() }, [fetchProjects]) + useEffect(() => { + if ( + activeProjectId && + projects.length > 0 && + !projects.some((p) => p.id === activeProjectId) + ) { + setActiveProjectId(null) + } + }, [projects, activeProjectId, setActiveProjectId]) + const createProject = useCallback( async (name: string, projectPath: string): Promise => { if (!baseUrl) throw new Error(`No server connected`) @@ -80,14 +104,7 @@ export function ProjectsProvider({ body: JSON.stringify({ name, path: projectPath }), }) if (!res.ok) { - const text = await res.text().catch(() => ``) - let message = `Create failed (${res.status})` - try { - const data = JSON.parse(text) as { error?: { message?: string } } - if (data.error?.message) message = data.error.message - } catch { - if (text) message = text - } + const message = await parseErrorMessage(res, `Create failed`) throw new Error(message) } const project = (await res.json()) as Project @@ -103,7 +120,7 @@ export function ProjectsProvider({ const res = await fetch(`${baseUrl}/_electric/projects/${id}`, { method: `DELETE`, }) - if (res.ok || res.status === 204) { + if (res.ok) { setProjects((prev) => prev.filter((p) => p.id !== id)) setActiveProjectIdRaw((prev) => (prev === id ? null : prev)) } @@ -128,7 +145,7 @@ export function ProjectsProvider({ [baseUrl] ) - const validatePathFn = useCallback( + const validatePath = useCallback( async (dirPath: string): Promise<{ valid: boolean; resolved: string }> => { if (!baseUrl) return { valid: false, resolved: dirPath } const res = await fetch(`${baseUrl}/_electric/validate-path`, { @@ -151,7 +168,7 @@ export function ProjectsProvider({ createProject, deleteProject, renameProject, - validatePath: validatePathFn, + validatePath, loading, }} > diff --git a/packages/agents-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index e423913c12..8b1c364f05 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -36,7 +36,7 @@ * so existing `size="1"`..`size="6"` migrations are visually 1:1. */ --ds-text-xs: 11px; --ds-text-xs-lh: 1.45; - --ds-text-sm: 13px; + --ds-text-sm: 14px; --ds-text-sm-lh: 1.5; --ds-text-base: 14px; --ds-text-base-lh: 1.55; @@ -51,8 +51,8 @@ /* ---- Font families ------------------------------------------------- */ --ds-font-body: - Figtree, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', - 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + 'Figtree Variable', ui-sans-serif, system-ui, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; --ds-font-heading: var(--ds-font-body); --ds-font-mono: SourceCodePro, ui-monospace, Menlo, Monaco, Consolas, 'Liberation Mono', diff --git a/packages/agents-server/src/project-store.ts b/packages/agents-server/src/project-store.ts index 3d4f924e31..a7d4798ac0 100644 --- a/packages/agents-server/src/project-store.ts +++ b/packages/agents-server/src/project-store.ts @@ -1,4 +1,5 @@ import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' import envPaths from 'env-paths' import { nanoid } from 'nanoid' @@ -21,14 +22,8 @@ async function readProjects(): Promise> { try { const raw = await fs.readFile(PROJECTS_FILE, `utf-8`) return JSON.parse(raw) as Array - } catch (err: unknown) { - if ( - err instanceof Error && - `code` in err && - (err as NodeJS.ErrnoException).code === `ENOENT` - ) { - return [] - } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === `ENOENT`) return [] throw err } } @@ -63,12 +58,12 @@ export async function updateProject( updates: { name?: string; path?: string } ): Promise { const projects = await readProjects() - const idx = projects.findIndex((p) => p.id === id) - if (idx === -1) return null - if (updates.name !== undefined) projects[idx].name = updates.name - if (updates.path !== undefined) projects[idx].path = updates.path + const project = projects.find((p) => p.id === id) + if (!project) return null + if (updates.name !== undefined) project.name = updates.name + if (updates.path !== undefined) project.path = updates.path await writeProjects(projects) - return projects[idx] + return project } export async function deleteProject(id: string): Promise { @@ -79,11 +74,19 @@ export async function deleteProject(id: string): Promise { return true } +function expandHome(p: string): string { + if (p === `~` || p.startsWith(`~/`)) { + return path.join(os.homedir(), p.slice(1)) + } + return p +} + export async function validatePath( dirPath: string ): Promise<{ valid: boolean; resolved: string }> { try { - const resolved = await fs.realpath(dirPath) + const expanded = expandHome(dirPath) + const resolved = await fs.realpath(expanded) const stat = await fs.stat(resolved) return { valid: stat.isDirectory(), resolved } } catch { diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index a07d706764..83d78d3c0e 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs' import Anthropic from '@anthropic-ai/sdk' import { z } from 'zod' import { serverLog } from '../log' @@ -331,6 +332,20 @@ function createAssistantHandler(options: { typeof ctx.args.workingDirectory === `string` ? ctx.args.workingDirectory : workingDirectory + + if ( + typeof ctx.args.workingDirectory === `string` && + (!fs.existsSync(effectiveCwd) || !fs.statSync(effectiveCwd).isDirectory()) + ) { + ctx.useAgent({ + systemPrompt: `Tell the user that the working directory "${effectiveCwd}" does not exist or is not a directory. Ask them to check the project path and try again.`, + ...resolveBuiltinModelConfig(modelCatalog, ctx.args), + tools: [...ctx.electricTools], + }) + await ctx.agent.run() + return + } + const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args) const tools = [ ...ctx.electricTools, From ed4b4ba33e3fb226cf1b4e7f945a027cbf930447 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 17:28:50 -0600 Subject: [PATCH 09/27] chore: add changeset for projects feature Co-Authored-By: Claude Opus 4.6 --- .changeset/add-projects-working-directory.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/add-projects-working-directory.md diff --git a/.changeset/add-projects-working-directory.md b/.changeset/add-projects-working-directory.md new file mode 100644 index 0000000000..ca947e400d --- /dev/null +++ b/.changeset/add-projects-working-directory.md @@ -0,0 +1,7 @@ +--- +'@electric-ax/agents-server': patch +'@electric-ax/agents-server-ui': patch +'@electric-ax/agents': patch +--- + +Add server-side projects (name + directory path) with REST API, popover-based project picker in the new session page, and working directory support in horton. Sessions are tagged by project for sidebar grouping. From 3dfb9e4cd7718cf65abd1e551bdc933a76dcbe90 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 17:36:58 -0600 Subject: [PATCH 10/27] feat(agents-server-ui): add 404 page for missing entities and unknown routes Co-Authored-By: Claude Opus 4.6 --- packages/agents-server-ui/src/router.tsx | 52 ++++++++++++++++++------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index 97b694480d..dd52573fba 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -32,7 +32,7 @@ import { EntityContextDrawer } from './components/EntityContextDrawer' import { MessageInput } from './components/MessageInput' import { StateExplorerPanel } from './components/stateExplorer/StateExplorerPanel' import { NewSessionPage } from './components/NewSessionPage' -import { Stack } from './ui' +import { Link, Stack, Text } from './ui' import styles from './router.module.css' function RootLayout(): React.ReactElement { @@ -158,7 +158,17 @@ function EntityPage(): React.ReactElement { }) }, [entityUrl, forkEntity, forking, navigate]) + const [waitedLong, setWaitedLong] = useState(false) + useEffect(() => { + if (selectedEntity) return + const timer = setTimeout(() => setWaitedLong(true), 2_000) + return () => clearTimeout(timer) + }, [selectedEntity]) + if (!selectedEntity) { + if (entitiesCollection && waitedLong) { + return + } return ( - Loading entity... + Loading entity… ) } @@ -195,7 +205,6 @@ function EntityPage(): React.ReactElement { entityUrl={connectUrl} entity={selectedEntity} entityStopped={entityStopped} - isSpawning={isSpawning} />
{stateExplorerOpen && ( @@ -248,25 +257,16 @@ function GenericEntityBody({ entityUrl, entity, entityStopped, - isSpawning, }: { baseUrl: string entityUrl: string | null entity: ElectricEntity entityStopped: boolean - isSpawning: boolean }): React.ReactElement { const { entries, db, loading, error } = useEntityTimeline( baseUrl || null, entityUrl ) - const navigate = useNavigate() - - useEffect(() => { - if (error && !isSpawning) { - navigate({ to: `/` }) - } - }, [error, navigate, isSpawning]) return ( <> @@ -288,7 +288,33 @@ function GenericEntityBody({ ) } -const rootRoute = createRootRoute({ component: RootLayout }) +function NotFoundPage({ message }: { message?: string }): React.ReactElement { + return ( + + + Not found + + + {message ?? `The page you're looking for doesn't exist.`} + + + Go home + + + ) +} + +const rootRoute = createRootRoute({ + component: RootLayout, + notFoundComponent: () => , +}) const indexRoute = createRoute({ getParentRoute: () => rootRoute, From 2980f183ef3c3c987b90a6ec8bf94d9dc2839f95 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 17:37:09 -0600 Subject: [PATCH 11/27] style(agents-server-ui): warmer light theme and surface polish Warmer page/sidebar backgrounds, softer tool-call cards with subtle shadows and rounder corners, pill-shaped badges, gentler sidebar selection tint, composer elevation, and cleaner spacing/hierarchy. Co-Authored-By: Claude Opus 4.6 --- .../src/components/EntityTimeline.module.css | 6 +++--- .../src/components/MessageInput.module.css | 7 +++++-- .../agents-server-ui/src/components/Sidebar.module.css | 6 ++++-- .../src/components/SidebarFooter.module.css | 2 +- .../agents-server-ui/src/components/SidebarRow.module.css | 2 +- .../agents-server-ui/src/components/UserMessage.module.css | 2 +- .../agents-server-ui/src/components/toolBlock.module.css | 7 ++++--- packages/agents-server-ui/src/ui/Badge.module.css | 2 +- packages/agents-server-ui/src/ui/tokens.css | 4 ++-- 9 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index b39c9de612..294b62f1e9 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -55,7 +55,7 @@ * CSS variables so EntityTimeline + MessageInput can stay perfectly * aligned without duplicating constants. */ .content { - padding: 32px 40px; + padding: 36px 40px; max-width: calc(var(--chat-surface-width) + 80px); margin: 0 auto; overflow-anchor: none; @@ -65,8 +65,8 @@ .statusPill { padding: 4px 14px; - border-radius: 12px; - opacity: 0.5; + border-radius: 9999px; + opacity: 0.45; letter-spacing: 0.02em; } diff --git a/packages/agents-server-ui/src/components/MessageInput.module.css b/packages/agents-server-ui/src/components/MessageInput.module.css index efcc980b16..99891bdd8e 100644 --- a/packages/agents-server-ui/src/components/MessageInput.module.css +++ b/packages/agents-server-ui/src/components/MessageInput.module.css @@ -34,8 +34,11 @@ bottom of a scrolling chat surface and chat content must NOT bleed through it. Solid raised surface is the right semantic. */ background: var(--ds-surface-raised); - border: 1px solid var(--ds-gray-a4); + border: 1px solid var(--ds-gray-a3); border-radius: 12px; + box-shadow: + 0 1px 3px rgba(15, 15, 30, 0.04), + 0 1px 1px rgba(15, 15, 30, 0.02); /* 12px on all sides — same as the user-message bubble (`Stack p={3}` in UserMessage.tsx, where `--ds-space-3 = 12px`). Keeping the two surfaces on the same padding makes the textarea text column @@ -97,7 +100,7 @@ } .sendIcon { - color: var(--ds-gray-8); + color: var(--ds-gray-7); cursor: default; transition: color 0.15s ease; flex-shrink: 0; diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index 79790d8e8c..f33ba75879 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -125,12 +125,13 @@ .projectHeader { all: unset; + box-sizing: border-box; display: flex; align-items: center; gap: 5px; width: 100%; height: var(--ds-row-height-md); - padding: 14px 4px 4px 8px; + padding: 14px 8px 4px 8px; cursor: pointer; color: var(--ds-text-2); font-size: 10px; @@ -164,8 +165,9 @@ .sectionLabel { display: block; text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.07em; font-size: 10px; + font-weight: 500; padding: 14px 4px 4px 8px; color: var(--ds-text-3); } diff --git a/packages/agents-server-ui/src/components/SidebarFooter.module.css b/packages/agents-server-ui/src/components/SidebarFooter.module.css index 3eb15c55df..d77fa1c968 100644 --- a/packages/agents-server-ui/src/components/SidebarFooter.module.css +++ b/packages/agents-server-ui/src/components/SidebarFooter.module.css @@ -7,6 +7,6 @@ align-items: center; gap: 4px; padding: 6px 11px; - border-top: 1px solid var(--ds-divider); + border-top: 1px solid var(--ds-gray-a3); flex-shrink: 0; } diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 163cb67ca8..9838b87a98 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -36,7 +36,7 @@ background: var(--ds-gray-a3); } .selected { - background: var(--ds-accent-a3); + background: var(--ds-accent-a2); } .selected:hover { background: var(--ds-accent-a3); diff --git a/packages/agents-server-ui/src/components/UserMessage.module.css b/packages/agents-server-ui/src/components/UserMessage.module.css index 198a5bb9a5..4a9e86f7ac 100644 --- a/packages/agents-server-ui/src/components/UserMessage.module.css +++ b/packages/agents-server-ui/src/components/UserMessage.module.css @@ -14,7 +14,7 @@ user's "voice" turn. */ .bubble { background: var(--ds-input-bg); - border: 1px solid var(--ds-gray-a4); + border: 1px solid var(--ds-gray-a3); border-radius: 12px; } diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index 1bd85679e4..32ac98881b 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -18,10 +18,11 @@ */ .card { - border: 1px solid var(--ds-gray-a3); - border-radius: var(--ds-radius-3); + border: 1px solid var(--ds-gray-a2); + border-radius: var(--ds-radius-4); overflow: hidden; background: var(--ds-gray-a1); + box-shadow: 0 1px 2px rgba(15, 15, 30, 0.03); } /* The header is metadata about the tool call, not part of the @@ -103,7 +104,7 @@ .body { padding: 10px 12px 12px; - border-top: 1px solid var(--ds-gray-a3); + border-top: 1px solid var(--ds-gray-a2); background: var(--ds-bg); } diff --git a/packages/agents-server-ui/src/ui/Badge.module.css b/packages/agents-server-ui/src/ui/Badge.module.css index 35b332100f..9172a9ce9d 100644 --- a/packages/agents-server-ui/src/ui/Badge.module.css +++ b/packages/agents-server-ui/src/ui/Badge.module.css @@ -2,7 +2,7 @@ display: inline-flex; align-items: center; gap: 4px; - border-radius: var(--ds-radius-2); + border-radius: var(--ds-radius-full); font-family: var(--ds-font-body); font-weight: 500; white-space: nowrap; diff --git a/packages/agents-server-ui/src/ui/tokens.css b/packages/agents-server-ui/src/ui/tokens.css index 8b1c364f05..7fbc20c79f 100644 --- a/packages/agents-server-ui/src/ui/tokens.css +++ b/packages/agents-server-ui/src/ui/tokens.css @@ -74,8 +74,8 @@ * is solid white so inputs read clearly against the off-white page; * in dark mode it stays a translucent grey so inputs blend with * raised surfaces (panels, popovers) wherever they're used. */ - --ds-bg: #f7f7f5; - --ds-bg-subtle: #f0efed; + --ds-bg: #f9f8f6; + --ds-bg-subtle: #efeeeb; --ds-surface: #ffffff; --ds-surface-raised: #ffffff; --ds-input-bg: #ffffff; From d2e388ab11af7bd73b374ad911233bbe053b7b2b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 17:47:38 -0600 Subject: [PATCH 12/27] style(agents-server-ui): tool trace polish, focus fix, wider column - Replace browser-default blue focus outline on tool card toggles with subtle accent bg on focus-visible - Section labels (Command, Output, etc.) use small-caps + 600 weight so they read as proper section headers - Warmer code block background via color-mix - Jump-to-bottom button: smaller, translucent, anchored bottom-right - Spawned/stopped timestamps left-aligned with border-left rail - Sidebar type labels dropped to xs, project headers bolder - Wider transcript column (72ch / 88ch) for code-heavy sessions Co-Authored-By: Claude Opus 4.6 --- .../src/components/EntityTimeline.module.css | 29 +++++++++-------- .../src/components/EntityTimeline.tsx | 4 +-- .../src/components/Sidebar.module.css | 3 +- .../src/components/SidebarRow.module.css | 2 +- .../src/components/ToolCallView.tsx | 24 ++++---------- .../src/components/toolBlock.module.css | 32 +++++++++++++------ .../agents-server-ui/src/router.module.css | 4 +-- 7 files changed, 52 insertions(+), 46 deletions(-) diff --git a/packages/agents-server-ui/src/components/EntityTimeline.module.css b/packages/agents-server-ui/src/components/EntityTimeline.module.css index 294b62f1e9..86d1f9cfba 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.module.css +++ b/packages/agents-server-ui/src/components/EntityTimeline.module.css @@ -64,9 +64,9 @@ } .statusPill { - padding: 4px 14px; - border-radius: 9999px; - opacity: 0.45; + padding: 2px 0 2px 10px; + border-left: 2px solid var(--ds-gray-a3); + opacity: 0.5; letter-spacing: 0.02em; } @@ -89,22 +89,25 @@ .jumpToBottom { position: absolute; bottom: 24px; - left: 50%; - transform: translateX(-50%); + right: 24px; z-index: 10; display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; - border: none; + width: 28px; + height: 28px; + border: 1px solid var(--ds-gray-a3); border-radius: 9999px; - background: var(--ds-gray-12); - color: var(--ds-gray-1); + background: var(--ds-surface); + color: var(--ds-gray-9); cursor: pointer; - box-shadow: var(--ds-shadow-3); - transition: background 120ms ease; + box-shadow: var(--ds-shadow-2); + opacity: 0.85; + transition: + opacity 120ms ease, + background 120ms ease; } .jumpToBottom:hover { - background: var(--ds-gray-11); + opacity: 1; + background: var(--ds-gray-2); } diff --git a/packages/agents-server-ui/src/components/EntityTimeline.tsx b/packages/agents-server-ui/src/components/EntityTimeline.tsx index eaaa31948a..b0a06a424b 100644 --- a/packages/agents-server-ui/src/components/EntityTimeline.tsx +++ b/packages/agents-server-ui/src/components/EntityTimeline.tsx @@ -414,7 +414,7 @@ export function EntityTimeline({ scrollbars="vertical" >
- + {spawnTime ? ( @@ -477,7 +477,7 @@ export function EntityTimeline({ )} {entityStopped && ( - + stopped diff --git a/packages/agents-server-ui/src/components/Sidebar.module.css b/packages/agents-server-ui/src/components/Sidebar.module.css index f33ba75879..11dc11872c 100644 --- a/packages/agents-server-ui/src/components/Sidebar.module.css +++ b/packages/agents-server-ui/src/components/Sidebar.module.css @@ -135,8 +135,9 @@ cursor: pointer; color: var(--ds-text-2); font-size: 10px; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: 0.08em; transition: color 0.1s ease; } .projectHeader:hover { diff --git a/packages/agents-server-ui/src/components/SidebarRow.module.css b/packages/agents-server-ui/src/components/SidebarRow.module.css index 9838b87a98..c78736d575 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.module.css +++ b/packages/agents-server-ui/src/components/SidebarRow.module.css @@ -123,7 +123,7 @@ .type { flex-shrink: 0; padding-right: 5px; - font-size: var(--ds-text-sm); + font-size: var(--ds-text-xs); color: var(--ds-text-3); text-transform: lowercase; line-height: 1; diff --git a/packages/agents-server-ui/src/components/ToolCallView.tsx b/packages/agents-server-ui/src/components/ToolCallView.tsx index ecad059f25..d19b5b1e80 100644 --- a/packages/agents-server-ui/src/components/ToolCallView.tsx +++ b/packages/agents-server-ui/src/components/ToolCallView.tsx @@ -103,16 +103,12 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { const timedOut = r.details.timedOut as boolean | undefined return ( - - Command - + Command
{args.command as string}
{r.text && ( <> - - Output - + Output {exitCode !== undefined && exitCode !== 0 && ( exit {exitCode} @@ -134,9 +130,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { case `read`: return ( - - Content - + Content
             {r.text ? truncate(r.text, 2000) : `(empty)`}
           
@@ -183,9 +177,7 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { {typeof args.content === `string` && ( <> - - Content - + Content
                 {truncate(args.content, 1000)}
               
@@ -202,17 +194,13 @@ function ToolBody({ item }: { item: ToolCallItem }): React.ReactElement { default: return ( - - Input - + Input
             {JSON.stringify(args, null, 2)}
           
{r.text && ( <> - - Output - + Output
{r.text}
)} diff --git a/packages/agents-server-ui/src/components/toolBlock.module.css b/packages/agents-server-ui/src/components/toolBlock.module.css index 32ac98881b..cc864d12d6 100644 --- a/packages/agents-server-ui/src/components/toolBlock.module.css +++ b/packages/agents-server-ui/src/components/toolBlock.module.css @@ -18,11 +18,11 @@ */ .card { - border: 1px solid var(--ds-gray-a2); + border: 1px solid var(--ds-gray-a3); border-radius: var(--ds-radius-4); overflow: hidden; - background: var(--ds-gray-a1); - box-shadow: 0 1px 2px rgba(15, 15, 30, 0.03); + background: var(--ds-surface); + box-shadow: 0 1px 2px rgba(15, 15, 30, 0.04); } /* The header is metadata about the tool call, not part of the @@ -35,11 +35,12 @@ display: flex; align-items: center; gap: 8px; - padding: 6px 10px; + padding: 7px 10px; font-size: 12px; line-height: 1.45; - font-family: var(--ds-font-body); + font-family: var(--ds-font-mono); color: var(--ds-text-1); + background: var(--ds-gray-a1); } /* Strip ` + {projects.map((p) => ( ) ) : null} -
+ } /> ) diff --git a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts index a3c8e673c9..3336b0e5d6 100644 --- a/packages/agents-server-ui/src/hooks/useEntityTimeline.ts +++ b/packages/agents-server-ui/src/hooks/useEntityTimeline.ts @@ -5,7 +5,10 @@ import { createEntityIncludesQuery, normalizeEntityTimelineData, } from '@electric-ax/agents-runtime' -import { connectEntityStream } from '../lib/entity-connection' +import { + closeEntityStream, + connectEntityStream, +} from '../lib/entity-connection' import type { EntityStreamDBWithActions, EntityTimelineData, @@ -15,7 +18,13 @@ import type { export function useEntityTimeline( baseUrl: string | null, - entityUrl: string | null + entityUrl: string | null, + /** + * Pre-loaded db from the route loader. When provided, the hook skips + * its own connectEntityStream call and uses this instance directly. + * The loader is responsible for closing it via closeEntityStream. + */ + preloadedDb?: EntityStreamDBWithActions | null ): { entries: Array entities: Array @@ -23,12 +32,30 @@ export function useEntityTimeline( loading: boolean error: string | null } { - const [db, setDb] = useState(null) - const [loading, setLoading] = useState(false) + const [db, setDb] = useState( + preloadedDb ?? null + ) + const [loading, setLoading] = useState(!preloadedDb) const [error, setError] = useState(null) - const closeRef = useRef<(() => void) | null>(null) + + // Track whether we self-connected (vs. using a preloaded db) so we + // know whether to call closeEntityStream on cleanup. + const selfConnectedRef = useRef(false) + const connectedKeyRef = useRef<{ baseUrl: string; entityUrl: string } | null>( + null + ) useEffect(() => { + // If a preloaded db was passed in, use it directly — no self-connection. + if (preloadedDb != null) { + setDb(preloadedDb) + setLoading(false) + setError(null) + selfConnectedRef.current = false + connectedKeyRef.current = null + return + } + setDb(null) setError(null) @@ -39,35 +66,36 @@ export function useEntityTimeline( let cancelled = false setLoading(true) + selfConnectedRef.current = true + connectedKeyRef.current = { baseUrl, entityUrl } connectEntityStream({ baseUrl, entityUrl }) .then((result) => { - if (cancelled) { - result.close() - return - } - closeRef.current = result.close + if (cancelled) return setDb(result.db) setLoading(false) }) .catch((err) => { - if (!cancelled) { - console.error(`Failed to connect entity stream`, { - baseUrl, - entityUrl, - error: err, - }) - setError(err instanceof Error ? err.message : String(err)) - setLoading(false) - } + if (cancelled) return + console.error(`Failed to connect entity stream`, { + baseUrl, + entityUrl, + error: err, + }) + setError(err instanceof Error ? err.message : String(err)) + setLoading(false) }) return () => { cancelled = true - closeRef.current?.() - closeRef.current = null + // Only close if we opened the connection ourselves. + if (selfConnectedRef.current && connectedKeyRef.current) { + closeEntityStream(connectedKeyRef.current) + selfConnectedRef.current = false + connectedKeyRef.current = null + } } - }, [baseUrl, entityUrl]) + }, [baseUrl, entityUrl, preloadedDb]) const { data: timelineRows = [] } = useLiveQuery( (q) => (db ? createEntityIncludesQuery(db)(q) : undefined), diff --git a/packages/agents-server-ui/src/hooks/useServerConnection.tsx b/packages/agents-server-ui/src/hooks/useServerConnection.tsx index 684b2a4c8b..146677f9a8 100644 --- a/packages/agents-server-ui/src/hooks/useServerConnection.tsx +++ b/packages/agents-server-ui/src/hooks/useServerConnection.tsx @@ -6,6 +6,7 @@ import { useState, } from 'react' import { loadServers, saveServers } from '../lib/server-connection' +import { registerActiveBaseUrl } from '../lib/entity-connection' import type { ReactNode } from 'react' import type { ServerConfig } from '../lib/types' @@ -59,6 +60,12 @@ export function ServerConnectionProvider({ }) }, []) + // Keep the module-level accessor in sync so the route loader + // (outside React context) can call connectEntityStream. + useEffect(() => { + registerActiveBaseUrl(activeServer?.url ?? null) + }, [activeServer]) + useEffect(() => { if (!activeServer) { setConnected(false) diff --git a/packages/agents-server-ui/src/lib/entity-connection.ts b/packages/agents-server-ui/src/lib/entity-connection.ts index ca9becce71..d825d9f0d8 100644 --- a/packages/agents-server-ui/src/lib/entity-connection.ts +++ b/packages/agents-server-ui/src/lib/entity-connection.ts @@ -13,7 +13,87 @@ function getMainStreamPath(entityUrl: string): string { */ export type UICustomState = Record -export async function connectEntityStream(opts: { +// --------------------------------------------------------------------------- +// Module-level active base URL +// Registered by useServerConnection so the route loader (outside React) +// can call connectEntityStream without needing context. +// --------------------------------------------------------------------------- + +let _activeBaseUrl: string | null = null + +export function registerActiveBaseUrl(url: string | null): void { + _activeBaseUrl = url +} + +export function getActiveBaseUrl(): string | null { + return _activeBaseUrl +} + +// --------------------------------------------------------------------------- +// Connection cache +// Keyed by `${baseUrl}${entityUrl}`. The route loader and useEntityTimeline +// share the same promise so preload() only runs once. +// --------------------------------------------------------------------------- + +type CachedConnection = { + promise: Promise<{ db: EntityStreamDBWithActions; close: () => void }> +} + +const connectionCache = new Map() + +function cacheKey(baseUrl: string, entityUrl: string): string { + return `${baseUrl}${entityUrl}` +} + +/** + * Connect to an entity stream, returning a shared promise. + * Multiple callers with the same baseUrl+entityUrl get the same db instance. + * On failure the cache entry is evicted so a subsequent call retries fresh. + */ +export function connectEntityStream(opts: { + baseUrl: string + entityUrl: string + customState?: UICustomState +}): Promise<{ db: EntityStreamDBWithActions; close: () => void }> { + const { baseUrl, entityUrl, customState } = opts + const key = cacheKey(baseUrl, entityUrl) + + const existing = connectionCache.get(key) + if (existing) return existing.promise + + const promise = connectEntityStreamFresh({ baseUrl, entityUrl, customState }) + + const entry: CachedConnection = { promise } + connectionCache.set(key, entry) + + // Evict on error so the next attempt starts fresh. + promise.catch(() => { + if (connectionCache.get(key) === entry) connectionCache.delete(key) + }) + + return promise +} + +/** + * Evict the cached connection for an entity, closing the db. + * Call this when the component displaying the entity unmounts. + */ +export function closeEntityStream(opts: { + baseUrl: string + entityUrl: string +}): void { + const key = cacheKey(opts.baseUrl, opts.entityUrl) + const entry = connectionCache.get(key) + if (!entry) return + connectionCache.delete(key) + entry.promise + .then(({ close }) => close()) + .catch(() => { + /* already evicted on error */ + }) +} + +async function connectEntityStreamFresh(opts: { baseUrl: string entityUrl: string customState?: UICustomState diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index dd52573fba..e8409b871c 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -8,6 +8,8 @@ import { useNavigate, useParams, } from '@tanstack/react-router' +import { connectEntityStream, getActiveBaseUrl } from './lib/entity-connection' +import type { EntityStreamDBWithActions } from '@electric-ax/agents-runtime' import { useLiveQuery } from '@tanstack/react-db' import { eq } from '@tanstack/db' import { useServerConnection } from './hooks/useServerConnection' @@ -104,6 +106,7 @@ function RootShell(): React.ReactElement { function EntityPage(): React.ReactElement { const { _splat } = useParams({ from: `/entity/$` }) + const { db: preloadedDb } = entityRoute.useLoaderData() const entityUrl = `/${_splat}` const { activeServer } = useServerConnection() const { pinnedUrls, togglePin } = usePinnedEntities() @@ -205,6 +208,7 @@ function EntityPage(): React.ReactElement { entityUrl={connectUrl} entity={selectedEntity} entityStopped={entityStopped} + preloadedDb={preloadedDb} />
{stateExplorerOpen && ( @@ -257,15 +261,18 @@ function GenericEntityBody({ entityUrl, entity, entityStopped, + preloadedDb, }: { baseUrl: string entityUrl: string | null entity: ElectricEntity entityStopped: boolean + preloadedDb?: EntityStreamDBWithActions | null }): React.ReactElement { const { entries, db, loading, error } = useEntityTimeline( baseUrl || null, - entityUrl + entityUrl, + preloadedDb ) return ( @@ -325,6 +332,22 @@ const indexRoute = createRoute({ const entityRoute = createRoute({ getParentRoute: () => rootRoute, path: `/entity/$`, + // Kick off (or reuse a cached) stream connection during navigation so the + // db is ready by the time the component renders. defaultPreload:'intent' + // on the router means this also fires on sidebar hover via . + loader: async ({ + params, + }): Promise<{ db: EntityStreamDBWithActions | null }> => { + const baseUrl = getActiveBaseUrl() + if (!baseUrl) return { db: null } + const entityUrl = `/${params._splat}` + try { + const { db } = await connectEntityStream({ baseUrl, entityUrl }) + return { db } + } catch { + return { db: null } + } + }, component: EntityPage, }) @@ -333,6 +356,7 @@ const routeTree = rootRoute.addChildren([indexRoute, entityRoute]) export const router = createRouter({ routeTree, history: createHashHistory(), + defaultPreload: `intent`, }) // eslint-disable-next-line quotes From 2a777656219561e6269306c52cc4bd034aff8488 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 21:14:30 -0600 Subject: [PATCH 23/27] =?UTF-8?q?chore(agents-server-ui):=20remove=20onSel?= =?UTF-8?q?ect=20prop=20from=20sidebar=20=E2=80=94=20navigation=20handled?= =?UTF-8?q?=20by=20Link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agents-server-ui/src/components/Sidebar.tsx | 3 --- .../agents-server-ui/src/components/SidebarRow.tsx | 2 -- .../agents-server-ui/src/components/SidebarTree.tsx | 4 ---- packages/agents-server-ui/src/router.tsx | 11 ----------- 4 files changed, 20 deletions(-) diff --git a/packages/agents-server-ui/src/components/Sidebar.tsx b/packages/agents-server-ui/src/components/Sidebar.tsx index 6aae47ff30..24fdaa49ea 100644 --- a/packages/agents-server-ui/src/components/Sidebar.tsx +++ b/packages/agents-server-ui/src/components/Sidebar.tsx @@ -45,12 +45,10 @@ function useSidebarWidth(): readonly [number, (w: number) => void] { export function Sidebar({ selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, }: { selectedEntityUrl: string | null - onSelectEntity: (url: string) => void pinnedUrls: Array onTogglePin: (url: string) => void }): React.ReactElement { @@ -142,7 +140,6 @@ export function Sidebar({ const treeProps = { childrenByParent, selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, hoverHandle, diff --git a/packages/agents-server-ui/src/components/SidebarRow.tsx b/packages/agents-server-ui/src/components/SidebarRow.tsx index 2f612479ca..3da350a7e0 100644 --- a/packages/agents-server-ui/src/components/SidebarRow.tsx +++ b/packages/agents-server-ui/src/components/SidebarRow.tsx @@ -33,7 +33,6 @@ type HoverCardHandle = ReturnType< type SidebarRowProps = { entity: ElectricEntity selected: boolean - onSelect?: () => void depth?: number /** Number of immediate children. 0 means no expand affordance. */ childCount?: number @@ -79,7 +78,6 @@ type SidebarRowProps = { export const SidebarRow = memo(function SidebarRow({ entity, selected, - onSelect: _onSelect, depth = 0, childCount = 0, expanded = false, diff --git a/packages/agents-server-ui/src/components/SidebarTree.tsx b/packages/agents-server-ui/src/components/SidebarTree.tsx index 606233f2e0..48a94db985 100644 --- a/packages/agents-server-ui/src/components/SidebarTree.tsx +++ b/packages/agents-server-ui/src/components/SidebarTree.tsx @@ -10,7 +10,6 @@ type SidebarTreeProps = { entity: ElectricEntity childrenByParent: Map> selectedEntityUrl: string | null - onSelectEntity: (url: string) => void pinnedUrls: ReadonlyArray onTogglePin: (url: string) => void depth?: number @@ -64,7 +63,6 @@ export const SidebarTree = memo(function SidebarTree({ entity, childrenByParent, selectedEntityUrl, - onSelectEntity, pinnedUrls, onTogglePin, depth = 0, @@ -89,7 +87,6 @@ export const SidebarTree = memo(function SidebarTree({ onSelectEntity(entity.url)} depth={depth} childCount={children.length} expanded={expanded} @@ -106,7 +103,6 @@ export const SidebarTree = memo(function SidebarTree({ entity={child} childrenByParent={childrenByParent} selectedEntityUrl={selectedEntityUrl} - onSelectEntity={onSelectEntity} pinnedUrls={pinnedUrls} onTogglePin={onTogglePin} depth={depth + 1} diff --git a/packages/agents-server-ui/src/router.tsx b/packages/agents-server-ui/src/router.tsx index e8409b871c..252eea25f0 100644 --- a/packages/agents-server-ui/src/router.tsx +++ b/packages/agents-server-ui/src/router.tsx @@ -74,16 +74,6 @@ function RootShell(): React.ReactElement { useHotkey(`mod+n`, openNewSession) useHotkey(`mod+shift+o`, openNewSession) - const navigateToEntity = useCallback( - (entityUrl: string) => { - navigate({ - to: `/entity/$`, - params: { _splat: entityUrl.replace(/^\//, ``) }, - }) - }, - [navigate] - ) - const params = useParams({ strict: false }) const splat = (params as Record)._splat const selectedEntityUrl = splat ? `/${splat}` : null @@ -93,7 +83,6 @@ function RootShell(): React.ReactElement { {!collapsed && ( From 4938cbceb03b5f69ed2730ddc97da45ea2418e68 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 21:18:25 -0600 Subject: [PATCH 24/27] chore: add changeset for agents-runtime tool call fix --- .changeset/agents-runtime-tool-call-fix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/agents-runtime-tool-call-fix.md diff --git a/.changeset/agents-runtime-tool-call-fix.md b/.changeset/agents-runtime-tool-call-fix.md new file mode 100644 index 0000000000..af96006a43 --- /dev/null +++ b/.changeset/agents-runtime-tool-call-fix.md @@ -0,0 +1,5 @@ +--- +'@electric-ax/agents-runtime': patch +--- + +Fix tool call event matching. From c4cbe110b2814b28753980202495085629565b1e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 21:25:27 -0600 Subject: [PATCH 25/27] fix(agents-server-ui): set markdown prose line-height to 1.5 --- packages/agents-server-ui/src/markdown.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents-server-ui/src/markdown.css b/packages/agents-server-ui/src/markdown.css index 7a6e0258a3..ef009af5f3 100644 --- a/packages/agents-server-ui/src/markdown.css +++ b/packages/agents-server-ui/src/markdown.css @@ -64,7 +64,7 @@ .agent-ui-markdown p, .agent-ui-markdown li { font-size: var(--ds-text-sm); - line-height: 1.65; + line-height: 1.5; font-family: var(--ds-font-body); color: var(--ds-text-1); } From 46ea0381d1b5a4067735eea31db421dc948c975e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 22:41:02 -0600 Subject: [PATCH 26/27] fix(agents-runtime): preserve tool_call/tool_result pairing during budget truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Budget truncation was dropping individual messages by token size, which could split tool_call/tool_result pairs — leaving orphaned tool_use or tool_result blocks that violate the Claude API. Now oversized tool_call/tool_result messages get their content replaced with a truncation stub pointing to load_timeline_range, preserving pairing while still saving budget. Also merges consecutive assistant messages (text + tool_call) in toAgentHistory to prevent consecutive assistant blocks that the Claude API rejects. Co-Authored-By: Claude Opus 4.6 --- .../agents-runtime/src/context-assembly.ts | 9 ++ packages/agents-runtime/src/pi-adapter.ts | 59 +++++--- .../agents-runtime/test/pi-adapter.test.ts | 140 ++++++++++++++++++ .../test/use-context-budget.test.ts | 46 ++++++ 4 files changed, 234 insertions(+), 20 deletions(-) diff --git a/packages/agents-runtime/src/context-assembly.ts b/packages/agents-runtime/src/context-assembly.ts index b89b50077e..bcc34b987a 100644 --- a/packages/agents-runtime/src/context-assembly.ts +++ b/packages/agents-runtime/src/context-assembly.ts @@ -324,6 +324,15 @@ export async function assembleContext( const message = volatileMessages[i]! const nextTokens = approxTokens(message.content) if (volatileBudgetUsed + nextTokens > remainingBudget) { + if (message.role === `tool_call` || message.role === `tool_result`) { + const stub = `[content truncated — use load_timeline_range({ from: ${message.at}, to: ${message.at} }) to read]` + const stubTokens = approxTokens(stub) + if (volatileBudgetUsed + stubTokens <= remainingBudget) { + volatileBudgetUsed += stubTokens + accepted.push({ ...message, content: stub }) + continue + } + } droppedOffsets.push(message.at) continue } diff --git a/packages/agents-runtime/src/pi-adapter.ts b/packages/agents-runtime/src/pi-adapter.ts index 6520e2ba18..cec2d2132b 100644 --- a/packages/agents-runtime/src/pi-adapter.ts +++ b/packages/agents-runtime/src/pi-adapter.ts @@ -89,6 +89,11 @@ export function toAgentHistory( const history: Array = [] const toolNamesById = new Map() + const lastAssistant = (): AgentMessage | undefined => { + const last = history[history.length - 1] + return last?.role === `assistant` ? last : undefined + } + for (const message of messages) { switch (message.role) { case `user`: @@ -99,30 +104,44 @@ export function toAgentHistory( } as AgentMessage) break - case `assistant`: - history.push({ - role: `assistant`, - content: [{ type: `text`, text: message.content }], - timestamp: Date.now(), - } as AgentMessage) + case `assistant`: { + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push({ + type: `text`, + text: message.content, + }) + } else { + history.push({ + role: `assistant`, + content: [{ type: `text`, text: message.content }], + timestamp: Date.now(), + } as AgentMessage) + } break + } - case `tool_call`: + case `tool_call`: { toolNamesById.set(message.toolCallId, message.toolName) - history.push({ - role: `assistant`, - content: [ - { - type: `toolCall`, - id: message.toolCallId, - name: message.toolName, - arguments: - (message.toolArgs as Record | undefined) ?? {}, - }, - ], - timestamp: Date.now(), - } as AgentMessage) + const block = { + type: `toolCall`, + id: message.toolCallId, + name: message.toolName, + arguments: + (message.toolArgs as Record | undefined) ?? {}, + } + const prev = lastAssistant() + if (prev) { + ;(prev.content as Array).push(block) + } else { + history.push({ + role: `assistant`, + content: [block], + timestamp: Date.now(), + } as AgentMessage) + } break + } case `tool_result`: history.push({ diff --git a/packages/agents-runtime/test/pi-adapter.test.ts b/packages/agents-runtime/test/pi-adapter.test.ts index 0cf9539b27..18190b8b3e 100644 --- a/packages/agents-runtime/test/pi-adapter.test.ts +++ b/packages/agents-runtime/test/pi-adapter.test.ts @@ -230,4 +230,144 @@ describe(`toAgentHistory`, () => { expect(first?.role).toBe(`user`) expect(second?.role).toBe(`assistant`) }) + + it(`merges assistant text and tool_call into a single assistant message`, () => { + const messages: Array = [ + { role: `user`, content: `Help me` }, + { role: `assistant`, content: `Let me look that up` }, + { + role: `tool_call`, + content: `lookup`, + toolCallId: `tc-0`, + toolName: `lookup`, + toolArgs: { q: `hello` }, + }, + { + role: `tool_result`, + content: `found it`, + toolCallId: `tc-0`, + isError: false, + }, + ] + + const history = toAgentHistory(messages) + + // The assistant text and tool_call should be merged into one assistant + // message, otherwise the Claude API rejects consecutive assistant messages + // and tool_result can't find its matching tool_use in the previous message. + const assistantMessages = history.filter((m) => m.role === `assistant`) + expect(assistantMessages).toHaveLength(1) + + const assistant = assistantMessages[0] as AssistantMessage + expect(assistant.content).toHaveLength(2) + expect(assistant.content[0]).toMatchObject({ + type: `text`, + text: `Let me look that up`, + }) + expect(assistant.content[1]).toMatchObject({ + type: `toolCall`, + id: `tc-0`, + name: `lookup`, + }) + }) + + it(`handles interleaved tool_call/tool_result pairs without consecutive assistants`, () => { + const messages: Array = [ + { role: `user`, content: `Do two things` }, + { role: `assistant`, content: `I will do both` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-0`, + toolName: `tool_a`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `result a`, + toolCallId: `tc-0`, + isError: false, + }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-1`, + toolName: `tool_b`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `result b`, + toolCallId: `tc-1`, + isError: false, + }, + ] + + const history = toAgentHistory(messages) + + // First tool call should be merged with the preceding text + const first = history[1] as AssistantMessage + expect(first.role).toBe(`assistant`) + expect(first.content).toHaveLength(2) + expect(first.content[0]).toMatchObject({ type: `text` }) + expect(first.content[1]).toMatchObject({ type: `toolCall`, id: `tc-0` }) + + // No consecutive assistant messages + for (let i = 1; i < history.length; i++) { + if (history[i].role === `assistant`) { + expect(history[i - 1].role).not.toBe(`assistant`) + } + } + + // Each tool_result should still be present + const toolResults = history.filter((m) => m.role === `toolResult`) + expect(toolResults).toHaveLength(2) + }) + + it(`does not produce consecutive assistant messages across multi-step runs`, () => { + const messages: Array = [ + { role: `user`, content: `Help` }, + // Step 1: text + tool call + { role: `assistant`, content: `Step 1` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-0`, + toolName: `search`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `found`, + toolCallId: `tc-0`, + isError: false, + }, + // Step 2: text + tool call + { role: `assistant`, content: `Step 2` }, + { + role: `tool_call`, + content: `{}`, + toolCallId: `tc-1`, + toolName: `write`, + toolArgs: {}, + }, + { + role: `tool_result`, + content: `done`, + toolCallId: `tc-1`, + isError: false, + }, + // Step 3: final answer + { role: `assistant`, content: `All done` }, + ] + + const history = toAgentHistory(messages) + + // Verify no consecutive assistant messages + for (let i = 1; i < history.length; i++) { + if (history[i].role === `assistant`) { + expect(history[i - 1].role).not.toBe(`assistant`) + } + } + }) }) diff --git a/packages/agents-runtime/test/use-context-budget.test.ts b/packages/agents-runtime/test/use-context-budget.test.ts index 8f968356c8..683132b783 100644 --- a/packages/agents-runtime/test/use-context-budget.test.ts +++ b/packages/agents-runtime/test/use-context-budget.test.ts @@ -62,6 +62,52 @@ describe(`budget enforcement`, () => { ) }) + it(`stubs oversized tool_result content instead of dropping it`, async () => { + const messages = await assembleContext({ + sourceBudget: 100, + sources: { + self: { + content: () => [ + { role: `user` as const, content: `Hi`, at: 1 }, + { role: `assistant` as const, content: `Let me check`, at: 2 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-1`, + toolName: `search`, + toolArgs: { q: `hello` }, + at: 3, + }, + { + role: `tool_result` as const, + content: `x`.repeat(5000), + toolCallId: `tc-1`, + isError: false, + at: 4, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 5, + }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + expect(toolCalls).toHaveLength(1) + expect(toolResults).toHaveLength(1) + expect((toolCalls[0] as any).toolCallId).toBe(`tc-1`) + expect((toolResults[0] as any).toolCallId).toBe(`tc-1`) + expect(toolResults[0]!.content).toMatch(/\[content truncated/) + expect(toolResults[0]!.content).toMatch(/load_timeline_range/) + }) + it(`does not write a stream event on overflow`, async () => { const logger = vi.fn() await assembleContext( From a078c5f75ebe9ed6809d03505028cab61b7e9ffe Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 4 May 2026 23:09:47 -0600 Subject: [PATCH 27/27] fix(agents-runtime): drop orphaned tool_call/tool_result after budget truncation The stub approach alone doesn't handle the case where budget exhaustion drops a tool_call while its tool_result survives (or vice versa). After the budget loop, scan accepted messages and drop any tool_call without a matching tool_result and any tool_result without a matching tool_call. Co-Authored-By: Claude Opus 4.6 --- .../agents-runtime/src/context-assembly.ts | 22 ++++++++ .../test/use-context-budget.test.ts | 50 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/agents-runtime/src/context-assembly.ts b/packages/agents-runtime/src/context-assembly.ts index bcc34b987a..9f63316a63 100644 --- a/packages/agents-runtime/src/context-assembly.ts +++ b/packages/agents-runtime/src/context-assembly.ts @@ -339,6 +339,28 @@ export async function assembleContext( volatileBudgetUsed += nextTokens accepted.push(message) } + + const acceptedCallIds = new Set() + const acceptedResultIds = new Set() + for (const m of accepted) { + const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if (m.role === `tool_call`) acceptedCallIds.add(id) + else if (m.role === `tool_result`) acceptedResultIds.add(id) + } + for (let i = accepted.length - 1; i >= 0; i--) { + const m = accepted[i]! + const id = (m as VolatileMessage & { toolCallId?: string }).toolCallId + if (!id) continue + if ( + (m.role === `tool_call` && !acceptedResultIds.has(id)) || + (m.role === `tool_result` && !acceptedCallIds.has(id)) + ) { + droppedOffsets.push(m.at) + accepted.splice(i, 1) + } + } + accepted.reverse() if (droppedOffsets.length > 0) { diff --git a/packages/agents-runtime/test/use-context-budget.test.ts b/packages/agents-runtime/test/use-context-budget.test.ts index 683132b783..4b18422501 100644 --- a/packages/agents-runtime/test/use-context-budget.test.ts +++ b/packages/agents-runtime/test/use-context-budget.test.ts @@ -108,6 +108,56 @@ describe(`budget enforcement`, () => { expect(toolResults[0]!.content).toMatch(/load_timeline_range/) }) + it(`drops orphaned tool_results when their tool_call is budget-truncated`, async () => { + const messages = await assembleContext({ + sourceBudget: 30, + sources: { + self: { + content: () => [ + { role: `assistant` as const, content: `I will search`, at: 1 }, + { + role: `tool_call` as const, + content: `search`, + toolCallId: `tc-old`, + toolName: `search`, + toolArgs: {}, + at: 2, + }, + { + role: `tool_result` as const, + content: `found`, + toolCallId: `tc-old`, + isError: false, + at: 3, + }, + { + role: `assistant` as const, + content: `Here is the answer`, + at: 4, + }, + { role: `user` as const, content: `Thanks`, at: 5 }, + ], + max: 100_000, + cache: `volatile`, + }, + }, + }) + + const toolCalls = messages.filter((m) => m.role === `tool_call`) + const toolResults = messages.filter((m) => m.role === `tool_result`) + + for (const tr of toolResults) { + const trId = (tr as any).toolCallId + expect(toolCalls.some((tc) => (tc as any).toolCallId === trId)).toBe(true) + } + for (const tc of toolCalls) { + const tcId = (tc as any).toolCallId + expect(toolResults.some((tr) => (tr as any).toolCallId === tcId)).toBe( + true + ) + } + }) + it(`does not write a stream event on overflow`, async () => { const logger = vi.fn() await assembleContext(