From b3e82cde13bf8bf2b79b5c1d29de1a870001864a Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 9 Jul 2026 09:25:33 +0800 Subject: [PATCH 1/4] fix(instance): harden against filesystem-root ("/") sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A legacy session persisted with directory "/" made the app unusable after upgrading: the V3.8 instance-boot guard (assertSafeInstanceRoot) rejects a "/"-rooted directory — correct, since it would make the file-tool permission boundary the whole disk — but the client kept re-requesting that boot, so it surfaced as an endless retry storm reported only as "unexpected server error" when loading conversations. Root cause was pre-existing dirty data, not this release; the guard simply started refusing it. Two fail-closed guards so no user hits this again: - server-sync (client): bootstrapInstance now refuses a filesystem-root directory up front and shows a clear toast, instead of firing the doomed server boot and storming on retry. Adds a dependency-free isFilesystemRootDir (posix "/" and Windows drive/UNC roots) + toast.project.rootRefused strings. - import writer: refuses to import a source session whose OWN cwd is a filesystem root — such a session could never be opened later. Checks the source cwd, NOT the resolved project dir, so a non-git session that legitimately maps to the `global` project (worktree sentinel "/") still imports. Per-session import is isolated, so it skips just that one. Adds a regression test. The user's live dirty row (one session at "/") was cleaned separately from the DB; these guards prevent recurrence. Verified: typecheck 2/2; import + question + task + structured-output 63/63; i18n parity green. Co-Authored-By: Claude Opus 4.8 --- packages/app/src/context/server-sync.tsx | 26 +++++++++++++++++++ packages/app/src/i18n/en.ts | 3 +++ packages/app/src/i18n/zh.ts | 2 ++ .../__tests__/session.integration.test.ts | 26 +++++++++++++++++++ .../src/import/writer/session.ts | 25 ++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 83133d3c..b5ae1dac 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -49,6 +49,18 @@ import type { SessionPlan, SessionPlanStep } from "./global-sync/types" export type { SessionPlan, SessionPlanStep } +// True when `dir` is a filesystem root: posix "/" or a Windows drive/UNC root ("C:\", "C:/", "\\"). +// Rooting an instance here is refused server-side (assertSafeInstanceRoot); we check on the client +// too so we never fire the doomed boot. Kept dependency-free (no node:path in the renderer). +function isFilesystemRootDir(dir: string): boolean { + const trimmed = dir.trim() + if (!trimmed) return false + const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "") + if (normalized === "") return true // was "/" or "\" (all separators) + if (/^[A-Za-z]:$/.test(normalized)) return true // "C:" (drive root after trailing-slash strip) + return false +} + type GlobalStore = { ready: boolean error?: InitError @@ -388,6 +400,20 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { async function bootstrapInstance(directory: string) { const key = directoryKey(directory) if (!key) return + // Fail-closed against a filesystem-root directory. The server refuses to boot an instance + // rooted at "/" (assertSafeInstanceRoot — it would make the file-tool permission boundary the + // whole disk), so a stored session/route pointing at "/" would otherwise trigger an endless + // boot→fail→retry storm surfacing only as "unexpected server error". Mirror the guard here so + // the doomed request is never sent and the user gets a clear reason instead. Legacy "/" data + // (pre-guard) is the only way to reach this now that the boot path rejects it. + if (isFilesystemRootDir(directory)) { + showToast({ + variant: "error", + title: language.t("toast.project.rootRefused.title"), + description: language.t("toast.project.rootRefused.description"), + }) + return + } const pending = booting.get(key) if (pending) return pending diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 02b7b05a..cdc09ded 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -589,6 +589,9 @@ export const dict = { "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", + "toast.project.rootRefused.title": "Can't open the filesystem root", + "toast.project.rootRefused.description": + "This conversation points at the filesystem root (\"/\"), which can't be opened for safety. It's likely leftover data — remove or re-target it.", "toast.update.title": "Update available", "toast.update.description": "A new version of DeepAgent Code ({{version}}) is now available to install.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 2a2f8dd8..8ce7ee5c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1041,6 +1041,8 @@ export const dict = { "dialog.releaseNotes.action.hideFuture": "不再显示", "dialog.releaseNotes.media.alt": "发布预览", "toast.project.reloadFailed.title": "无法重新加载 {{project}}", + "toast.project.rootRefused.title": "无法打开文件系统根目录", + "toast.project.rootRefused.description": "该对话指向文件系统根目录(“/”),出于安全考虑无法打开。这多半是遗留数据,请删除或改指到具体目录。", "error.server.invalidConfiguration": "配置无效", "common.moreCountSuffix": " (还有 {{count}} 个)", "common.time.justNow": "刚刚", diff --git a/packages/deepagent-code/src/import/writer/__tests__/session.integration.test.ts b/packages/deepagent-code/src/import/writer/__tests__/session.integration.test.ts index 31e01262..a3fe5c29 100644 --- a/packages/deepagent-code/src/import/writer/__tests__/session.integration.test.ts +++ b/packages/deepagent-code/src/import/writer/__tests__/session.integration.test.ts @@ -111,4 +111,30 @@ describe("import writer/session (integration)", () => { }), ) }) + + it("refuses a session whose cwd resolves to the filesystem root", async () => { + // A "/"-rooted session can never be opened later (the instance boot guard rejects it), so the + // importer must fail this session rather than persist a permanently-unopenable conversation. + const dbPath = join(tmpdir(), `imp-root-${Date.now()}.sqlite`) + const rootSession: SourceSession = { ...sample(), sourceId: "integ-root", cwd: "/" } + let threw = false + try { + await withRuntime(dbPath, importSession(rootSession)) + } catch (err) { + threw = true + expect(String(err)).toContain("filesystem root") + } + expect(threw).toBe(true) + + // Nothing was persisted for the refused session. + await withRuntime( + dbPath, + Effect.gen(function* () { + const { db } = yield* Database.Service + const target = SessionSchema.ID.make(sessionID("codex", "integ-root")) + const rows = yield* db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, target)).all().pipe(Effect.orDie) + expect(rows.length).toBe(0) + }), + ) + }) }) diff --git a/packages/deepagent-code/src/import/writer/session.ts b/packages/deepagent-code/src/import/writer/session.ts index 166324c1..efa27883 100644 --- a/packages/deepagent-code/src/import/writer/session.ts +++ b/packages/deepagent-code/src/import/writer/session.ts @@ -30,6 +30,16 @@ import type { SessionImportResult } from "../types" * Because steps 2+3 run for the same stable aggregate id every time, re-running * an import fully replaces that session's history without duplicates. */ +// True when `dir` is a filesystem root ("/" or a Windows drive/UNC root). Mirrors +// project/instance-context.isFilesystemRoot without pulling node:path into this module's +// dependency surface; the check is the same (strip trailing separators → empty or bare drive). +function isFilesystemRootPath(dir: string): boolean { + const trimmed = dir.trim() + if (!trimmed) return false + const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "") + return normalized === "" || /^[A-Za-z]:$/.test(normalized) +} + export const importSession = Effect.fn("Import.session")(function* (session: SourceSession) { const { db } = yield* Database.Service const events = yield* EventV2.Service @@ -37,6 +47,21 @@ export const importSession = Effect.fn("Import.session")(function* (session: Sou const aggregateID = sessionID(session.source, session.sourceId) + // Fail-closed: never import a session whose OWN cwd is a filesystem root ("/"). The session's + // directory becomes its route/instance directory, and the boot guard (assertSafeInstanceRoot) + // rejects a root-rooted directory — so such a session could never be opened and would surface as + // a permanent "unexpected server error". Note we check the SOURCE cwd, not the resolved project + // directory: a non-git session legitimately maps to the `global` project whose worktree sentinel + // is "/" (handled safely by containsPath), and that must still import. Per-session imports are + // isolated (Effect.either upstream), so failing here skips just this one and records a warning. + if (isFilesystemRootPath(session.cwd)) { + return yield* Effect.fail( + new Error( + `refusing to import session ${session.sourceId}: its cwd is the filesystem root (${JSON.stringify(session.cwd)})`, + ), + ) + } + // 1. resolve the REAL project id (matches native deepagent-code projects) const resolved = yield* projectService.resolve(AbsolutePath.make(session.cwd)) const projectID = resolved.id From f2e81f2c943cf3e5241a8744a58acca64b75bb30 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 9 Jul 2026 13:35:38 +0800 Subject: [PATCH 2/4] feat(session): live turn timer, subagent tokens, real context accounting, sidebar rename Reply timeline: - Move elapsed time + token count to a live top-left per-turn badge (ticks from thinking-start until completion), change the reply footer from elapsed duration to the reply's date+time. - Roll subagent (child-session) token usage into the parent turn total, deduped by child sessionId; show each subagent's usage on the task box. Context accounting: - Top-right gauge now reports REAL retained context (input+cache.read+ cache.write), excluding generated output/reasoning tokens. - Add a cumulative conversation-token total (all turns + descendant subagent sessions) to the usage tooltip and context tab; hide the unaligned cost. Sidebar: - Double-click a session row to rename in place (optimistic update + rollback). - Right-click context menu (Rename/Archive/Delete) aligned with Codex's thread actions; Delete shows a confirm dialog, Archive runs directly. i18n: add duration.hoursMinutes, tool.agent.tokens, conversation-token keys across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/session-context-usage.tsx | 21 +- .../session/session-context-metrics.test.ts | 59 +++- .../session/session-context-metrics.ts | 52 +++- .../session/session-context-tab.tsx | 18 +- packages/app/src/i18n/ar.ts | 2 + packages/app/src/i18n/br.ts | 2 + packages/app/src/i18n/bs.ts | 2 + packages/app/src/i18n/da.ts | 2 + packages/app/src/i18n/de.ts | 2 + packages/app/src/i18n/en.ts | 4 +- packages/app/src/i18n/es.ts | 2 + packages/app/src/i18n/fr.ts | 2 + packages/app/src/i18n/ja.ts | 2 + packages/app/src/i18n/ko.ts | 2 + packages/app/src/i18n/no.ts | 2 + packages/app/src/i18n/pl.ts | 2 + packages/app/src/i18n/ru.ts | 2 + packages/app/src/i18n/th.ts | 2 + packages/app/src/i18n/tr.ts | 2 + packages/app/src/i18n/uk.ts | 2 + packages/app/src/i18n/zh.ts | 4 +- packages/app/src/i18n/zht.ts | 2 + .../pages/layout/sidebar-delete-session.tsx | 104 +++++++ .../app/src/pages/layout/sidebar-items.tsx | 254 ++++++++++++++---- .../pages/session/message-timeline.data.ts | 17 ++ .../src/pages/session/message-timeline.tsx | 83 ++++++ .../app/src/utils/format-duration.test.ts | 34 +++ packages/app/src/utils/format-duration.ts | 29 ++ packages/app/src/utils/session-time.test.ts | 46 ++++ packages/app/src/utils/session-time.ts | 31 +++ packages/ui/src/components/basic-tool.css | 6 + packages/ui/src/components/message-part.tsx | 53 ++-- packages/ui/src/i18n/ar.ts | 2 + packages/ui/src/i18n/br.ts | 2 + packages/ui/src/i18n/bs.ts | 2 + packages/ui/src/i18n/da.ts | 2 + packages/ui/src/i18n/de.ts | 2 + packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/es.ts | 2 + packages/ui/src/i18n/fr.ts | 2 + packages/ui/src/i18n/ja.ts | 2 + packages/ui/src/i18n/ko.ts | 2 + packages/ui/src/i18n/no.ts | 2 + packages/ui/src/i18n/pl.ts | 2 + packages/ui/src/i18n/ru.ts | 2 + packages/ui/src/i18n/th.ts | 2 + packages/ui/src/i18n/tr.ts | 2 + packages/ui/src/i18n/uk.ts | 2 + packages/ui/src/i18n/zh.ts | 2 + packages/ui/src/i18n/zht.ts | 2 + 50 files changed, 778 insertions(+), 105 deletions(-) create mode 100644 packages/app/src/pages/layout/sidebar-delete-session.tsx create mode 100644 packages/app/src/utils/format-duration.test.ts create mode 100644 packages/app/src/utils/format-duration.ts create mode 100644 packages/app/src/utils/session-time.test.ts create mode 100644 packages/app/src/utils/session-time.ts diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 5a1c6029..d492c128 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -8,7 +8,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" -import { getSessionContextMetrics } from "@/components/session/session-context-metrics" +import { getSessionContextMetrics, getConversationTokens } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" @@ -44,19 +44,12 @@ export function SessionContextUsage(props: SessionContextUsageProps) { }) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const context = createMemo(() => metrics().context) - const cost = createMemo(() => { - return usd().format(metrics().totalCost) - }) + // Cumulative tokens across the whole conversation (all turns + subagent child sessions). Distinct + // from `context()` which is the current retained-window occupancy. Cost is intentionally not shown + // yet — the billing figure is not aligned with our agent system, so it is hidden until it is. + const conversationTokens = createMemo(() => getConversationTokens(sync.data.session ?? [], params.id)) const openContext = () => { if (!params.id) return @@ -95,8 +88,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) { )}
- {cost()} - {language.t("context.usage.cost")} + {conversationTokens().toLocaleString(language.intl())} + {language.t("context.usage.totalTokens")}
) diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 7d9acace..9aedd3da 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { Message } from "@deepagent-code/sdk/v2/client" -import { getSessionContextMetrics } from "./session-context-metrics" +import type { Message, Session } from "@deepagent-code/sdk/v2/client" +import { getConversationTokens, getSessionContextMetrics, getSubagentTokens } from "./session-context-metrics" const assistant = ( id: string, @@ -61,8 +61,9 @@ describe("getSessionContextMetrics", () => { expect(metrics.totalCost).toBe(1.75) expect(metrics.context?.message.id).toBe("a2") - expect(metrics.context?.total).toBe(500) - expect(metrics.context?.usage).toBe(50) + // Retained context = input + cache.read + cache.write (300 + 25 + 25); output/reasoning excluded. + expect(metrics.context?.total).toBe(350) + expect(metrics.context?.usage).toBe(35) expect(metrics.context?.providerLabel).toBe("OpenAI") expect(metrics.context?.modelLabel).toBe("GPT-4.1") }) @@ -99,3 +100,53 @@ describe("getSessionContextMetrics", () => { expect(metrics.context).toBeUndefined() }) }) + +const session = ( + id: string, + parentID: string | undefined, + tokens: { input: number; output: number; reasoning: number; read: number; write: number }, +) => + ({ + id, + parentID, + tokens: { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cache: { read: tokens.read, write: tokens.write }, + }, + }) as unknown as Session + +describe("getSubagentTokens", () => { + test("sums all token buckets of a session's persisted total", () => { + const s = session("s1", undefined, { input: 100, output: 40, reasoning: 10, read: 5, write: 5 }) + expect(getSubagentTokens(s)).toBe(160) + }) + + test("undefined session -> 0", () => { + expect(getSubagentTokens(undefined)).toBe(0) + }) +}) + +describe("getConversationTokens", () => { + test("sums root + all descendant subagent sessions", () => { + const sessions = [ + session("root", undefined, { input: 100, output: 0, reasoning: 0, read: 0, write: 0 }), + session("child1", "root", { input: 50, output: 0, reasoning: 0, read: 0, write: 0 }), + session("grandchild", "child1", { input: 20, output: 0, reasoning: 0, read: 0, write: 0 }), + session("child2", "root", { input: 30, output: 0, reasoning: 0, read: 0, write: 0 }), + session("unrelated", undefined, { input: 999, output: 0, reasoning: 0, read: 0, write: 0 }), + ] + // root(100) + child1(50) + grandchild(20) + child2(30) = 200; unrelated excluded. + expect(getConversationTokens(sessions, "root")).toBe(200) + }) + + test("no root id -> 0", () => { + expect(getConversationTokens([], undefined)).toBe(0) + }) + + test("root with no children -> just its own total", () => { + const sessions = [session("root", undefined, { input: 10, output: 5, reasoning: 0, read: 0, write: 0 })] + expect(getConversationTokens(sessions, "root")).toBe(15) + }) +}) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index cb23bffd..d0299f6f 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message } from "@deepagent-code/sdk/v2/client" +import type { AssistantMessage, Message, Session } from "@deepagent-code/sdk/v2/client" type Provider = { id: string @@ -34,8 +34,12 @@ type Metrics = { context: Context | undefined } +// Real retained-context occupancy: the tokens the model's window actually holds going into the next +// turn = non-cached input + cache read + cache write. Output and reasoning are GENERATED, not retained +// (reasoning is explicitly never carried forward), so they must not count toward context usage. This +// mirrors the backend overflow/compaction budget in overflow.ts. const tokenTotal = (msg: AssistantMessage) => { - return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write + return msg.tokens.input + msg.tokens.cache.read + msg.tokens.cache.write } const lastAssistantWithTokens = (messages: Message[]) => { @@ -80,3 +84,47 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } + +// All tokens a session's persisted running total accounts for (input + output + reasoning + cache). +// This is the CONSUMED total, distinct from retained-context occupancy (tokenTotal above). +const sessionTokensUsed = (session: Session): number => { + const t = session.tokens + if (!t) return 0 + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write +} + +// Cumulative token usage for the whole conversation: the root session's persisted total plus every +// descendant subagent (child) session's total. Walks the parent-child tree (subagents can nest), so a +// multi-agent run reports the true aggregate. Deduped by id via the visited set. This is the number +// the top-right "conversation total tokens" surfaces — NOT the retained-context gauge. +export function getConversationTokens(sessions: Session[] = [], rootSessionID?: string): number { + if (!rootSessionID) return 0 + const childrenByParent = new Map() + const byId = new Map() + for (const s of sessions) { + byId.set(s.id, s) + if (!s.parentID) continue + const list = childrenByParent.get(s.parentID) + if (list) list.push(s) + else childrenByParent.set(s.parentID, [s]) + } + + let total = 0 + const visited = new Set() + const stack = [rootSessionID] + while (stack.length > 0) { + const id = stack.pop()! + if (visited.has(id)) continue + visited.add(id) + const session = byId.get(id) + if (session) total += sessionTokensUsed(session) + for (const child of childrenByParent.get(id) ?? []) stack.push(child.id) + } + return total +} + +// Tokens USED by a single subagent (child) session, from its persisted running total. Used to show a +// per-box figure on the right of the task tool-call and to roll subagent usage into the parent turn. +export function getSubagentTokens(session: Session | undefined): number { + return session ? sessionTokensUsed(session) : 0 +} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index e1fe176c..1494a63e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -14,7 +14,7 @@ import type { Message, Part, UserMessage } from "@deepagent-code/sdk/v2/client" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContextMetrics } from "./session-context-metrics" +import { getSessionContextMetrics, getConversationTokens } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -124,21 +124,13 @@ export function SessionContextTab() { { equals: same }, ) - const usd = createMemo( - () => - new Intl.NumberFormat(language.intl(), { - style: "currency", - currency: "USD", - }), - ) - const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const ctx = createMemo(() => metrics().context) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) - const cost = createMemo(() => { - return usd().format(metrics().totalCost) - }) + // Cumulative tokens across the whole conversation (all turns + subagent child sessions). Cost is + // hidden until the billing figure is aligned with our agent system. + const conversationTokens = createMemo(() => getConversationTokens(sync.data.session ?? [], params.id)) const counts = createMemo(() => { const all = messages() @@ -213,7 +205,7 @@ export function SessionContextTab() { }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, - { label: "context.stats.totalCost", value: cost }, + { label: "context.stats.conversationTokens", value: () => formatter().number(conversationTokens()) }, { label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, ] satisfies { label: string; value: () => JSX.Element }[] diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 1acf716d..807a7b6b 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -336,11 +336,13 @@ export const dict = { "context.stats.userMessages": "رسائل المستخدم", "context.stats.assistantMessages": "رسائل المساعد", "context.stats.totalCost": "التكلفة الإجمالية", + "context.stats.conversationTokens": "رموز المحادثة", "context.stats.sessionCreated": "تم إنشاء الجلسة", "context.stats.lastActivity": "آخر نشاط", "context.usage.tokens": "رموز", "context.usage.usage": "استخدام", "context.usage.cost": "تكلفة", + "context.usage.totalTokens": "رموز المحادثة", "context.usage.clickToView": "انقر لعرض السياق", "context.usage.view": "عرض استخدام السياق", "language.en": "English", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 83b330fc..db2de284 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -337,11 +337,13 @@ export const dict = { "context.stats.userMessages": "Mensagens de Usuário", "context.stats.assistantMessages": "Mensagens do Assistente", "context.stats.totalCost": "Custo Total", + "context.stats.conversationTokens": "Tokens da conversa", "context.stats.sessionCreated": "Sessão Criada", "context.stats.lastActivity": "Última Atividade", "context.usage.tokens": "Tokens", "context.usage.usage": "Uso", "context.usage.cost": "Custo", + "context.usage.totalTokens": "Tokens da conversa", "context.usage.clickToView": "Clique para ver o contexto", "context.usage.view": "Ver uso do contexto", "language.en": "English", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 06994c6e..7190b688 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -369,12 +369,14 @@ export const dict = { "context.stats.userMessages": "Korisničke poruke", "context.stats.assistantMessages": "Poruke asistenta", "context.stats.totalCost": "Ukupni trošak", + "context.stats.conversationTokens": "Tokeni razgovora", "context.stats.sessionCreated": "Sesija kreirana", "context.stats.lastActivity": "Posljednja aktivnost", "context.usage.tokens": "Tokeni", "context.usage.usage": "Korištenje", "context.usage.cost": "Trošak", + "context.usage.totalTokens": "Tokeni razgovora", "context.usage.clickToView": "Klikni da vidiš kontekst", "context.usage.view": "Prikaži korištenje konteksta", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 404f6209..fe57ab9f 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -367,12 +367,14 @@ export const dict = { "context.stats.userMessages": "Brugerbeskeder", "context.stats.assistantMessages": "Assistentbeskeder", "context.stats.totalCost": "Samlede omkostninger", + "context.stats.conversationTokens": "Samtale-tokens", "context.stats.sessionCreated": "Session oprettet", "context.stats.lastActivity": "Seneste aktivitet", "context.usage.tokens": "Tokens", "context.usage.usage": "Forbrug", "context.usage.cost": "Omkostning", + "context.usage.totalTokens": "Samtale-tokens", "context.usage.clickToView": "Klik for at se kontekst", "context.usage.view": "Se kontekstforbrug", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 3dc2a534..ae2da2b4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -344,11 +344,13 @@ export const dict = { "context.stats.userMessages": "Benutzernachrichten", "context.stats.assistantMessages": "Assistentennachrichten", "context.stats.totalCost": "Gesamtkosten", + "context.stats.conversationTokens": "Konversations-Tokens", "context.stats.sessionCreated": "Sitzung erstellt", "context.stats.lastActivity": "Letzte Aktivität", "context.usage.tokens": "Token", "context.usage.usage": "Nutzung", "context.usage.cost": "Kosten", + "context.usage.totalTokens": "Konversations-Tokens", "context.usage.clickToView": "Klicken, um Kontext anzuzeigen", "context.usage.view": "Kontextnutzung anzeigen", "language.en": "English", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index cdc09ded..fba60503 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -514,7 +514,7 @@ export const dict = { "context.stats.provider": "Provider", "context.stats.model": "Model", "context.stats.limit": "Context Limit", - "context.stats.totalTokens": "Total Tokens", + "context.stats.totalTokens": "Context Tokens", "context.stats.usage": "Usage", "context.stats.inputTokens": "Input Tokens", "context.stats.outputTokens": "Output Tokens", @@ -523,12 +523,14 @@ export const dict = { "context.stats.userMessages": "User Messages", "context.stats.assistantMessages": "Assistant Messages", "context.stats.totalCost": "Total Cost", + "context.stats.conversationTokens": "Conversation Tokens", "context.stats.sessionCreated": "Session Created", "context.stats.lastActivity": "Last Activity", "context.usage.tokens": "Tokens", "context.usage.usage": "Usage", "context.usage.cost": "Cost", + "context.usage.totalTokens": "Conversation Tokens", "context.usage.clickToView": "Click to view context", "context.usage.view": "View context usage", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 20c9d71c..0d7123d7 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -369,12 +369,14 @@ export const dict = { "context.stats.userMessages": "Mensajes de Usuario", "context.stats.assistantMessages": "Mensajes de Asistente", "context.stats.totalCost": "Costo Total", + "context.stats.conversationTokens": "Tokens de conversación", "context.stats.sessionCreated": "Sesión Creada", "context.stats.lastActivity": "Última Actividad", "context.usage.tokens": "Tokens", "context.usage.usage": "Uso", "context.usage.cost": "Costo", + "context.usage.totalTokens": "Tokens de conversación", "context.usage.clickToView": "Haz clic para ver contexto", "context.usage.view": "Ver uso del contexto", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4fa45f73..bea1dcc1 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -339,11 +339,13 @@ export const dict = { "context.stats.userMessages": "Messages utilisateur", "context.stats.assistantMessages": "Messages assistant", "context.stats.totalCost": "Coût total", + "context.stats.conversationTokens": "Tokens de conversation", "context.stats.sessionCreated": "Session créée", "context.stats.lastActivity": "Dernière activité", "context.usage.tokens": "Jetons", "context.usage.usage": "Utilisation", "context.usage.cost": "Coût", + "context.usage.totalTokens": "Tokens de conversation", "context.usage.clickToView": "Cliquez pour voir le contexte", "context.usage.view": "Voir l'utilisation du contexte", "language.en": "English", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 24b6a134..3fbb6ed1 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -337,11 +337,13 @@ export const dict = { "context.stats.userMessages": "ユーザーメッセージ", "context.stats.assistantMessages": "アシスタントメッセージ", "context.stats.totalCost": "総コスト", + "context.stats.conversationTokens": "会話の総トークン", "context.stats.sessionCreated": "セッション作成日時", "context.stats.lastActivity": "最終アクティビティ", "context.usage.tokens": "トークン", "context.usage.usage": "使用量", "context.usage.cost": "コスト", + "context.usage.totalTokens": "会話の総トークン", "context.usage.clickToView": "クリックしてコンテキストを表示", "context.usage.view": "コンテキスト使用量を表示", "language.en": "English", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 4ba90185..0f1d882f 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -336,11 +336,13 @@ export const dict = { "context.stats.userMessages": "사용자 메시지", "context.stats.assistantMessages": "어시스턴트 메시지", "context.stats.totalCost": "총 비용", + "context.stats.conversationTokens": "대화 총 토큰", "context.stats.sessionCreated": "세션 생성됨", "context.stats.lastActivity": "최근 활동", "context.usage.tokens": "토큰", "context.usage.usage": "사용량", "context.usage.cost": "비용", + "context.usage.totalTokens": "대화 총 토큰", "context.usage.clickToView": "컨텍스트를 보려면 클릭", "context.usage.view": "컨텍스트 사용량 보기", "language.en": "English", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 1d2964aa..68ec2d5c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -370,12 +370,14 @@ export const dict = { "context.stats.userMessages": "Brukermeldinger", "context.stats.assistantMessages": "Assistentmeldinger", "context.stats.totalCost": "Total kostnad", + "context.stats.conversationTokens": "Samtale-tokens", "context.stats.sessionCreated": "Sesjon opprettet", "context.stats.lastActivity": "Siste aktivitet", "context.usage.tokens": "Tokens", "context.usage.usage": "Forbruk", "context.usage.cost": "Kostnad", + "context.usage.totalTokens": "Samtale-tokens", "context.usage.clickToView": "Klikk for å se kontekst", "context.usage.view": "Se kontekstforbruk", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 6bcde90b..2db7354f 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -337,11 +337,13 @@ export const dict = { "context.stats.userMessages": "Wiadomości użytkownika", "context.stats.assistantMessages": "Wiadomości asystenta", "context.stats.totalCost": "Całkowity koszt", + "context.stats.conversationTokens": "Tokeny rozmowy", "context.stats.sessionCreated": "Utworzono sesję", "context.stats.lastActivity": "Ostatnia aktywność", "context.usage.tokens": "Tokeny", "context.usage.usage": "Użycie", "context.usage.cost": "Koszt", + "context.usage.totalTokens": "Tokeny rozmowy", "context.usage.clickToView": "Kliknij, aby zobaczyć kontekst", "context.usage.view": "Pokaż użycie kontekstu", "language.en": "English", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0fa459df..6bd99bf4 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -369,12 +369,14 @@ export const dict = { "context.stats.userMessages": "Сообщения пользователя", "context.stats.assistantMessages": "Сообщения ассистента", "context.stats.totalCost": "Общая стоимость", + "context.stats.conversationTokens": "Токены беседы", "context.stats.sessionCreated": "Сессия создана", "context.stats.lastActivity": "Последняя активность", "context.usage.tokens": "Токены", "context.usage.usage": "Использование", "context.usage.cost": "Стоимость", + "context.usage.totalTokens": "Токены беседы", "context.usage.clickToView": "Нажмите для просмотра контекста", "context.usage.view": "Показать использование контекста", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 58a0217a..6a08684d 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -367,12 +367,14 @@ export const dict = { "context.stats.userMessages": "ข้อความผู้ใช้", "context.stats.assistantMessages": "ข้อความผู้ช่วย", "context.stats.totalCost": "ต้นทุนทั้งหมด", + "context.stats.conversationTokens": "โทเคนบทสนทนา", "context.stats.sessionCreated": "สร้างเซสชันเมื่อ", "context.stats.lastActivity": "กิจกรรมล่าสุด", "context.usage.tokens": "โทเค็น", "context.usage.usage": "การใช้งาน", "context.usage.cost": "ต้นทุน", + "context.usage.totalTokens": "โทเคนบทสนทนา", "context.usage.clickToView": "คลิกเพื่อดูบริบท", "context.usage.view": "ดูการใช้บริบท", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a29c1a08..24ffe8c6 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -372,12 +372,14 @@ export const dict = { "context.stats.userMessages": "Kullanıcı Mesajları", "context.stats.assistantMessages": "Asistan Mesajları", "context.stats.totalCost": "Toplam Maliyet", + "context.stats.conversationTokens": "Konuşma tokenları", "context.stats.sessionCreated": "Oturum Oluşturulma", "context.stats.lastActivity": "Son Etkinlik", "context.usage.tokens": "Tokenler", "context.usage.usage": "Kullanım", "context.usage.cost": "Maliyet", + "context.usage.totalTokens": "Konuşma tokenları", "context.usage.clickToView": "Bağlamı görüntüle", "context.usage.view": "Bağlam kullanımını görüntüle", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c1573940..6835497b 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -387,12 +387,14 @@ export const dict = { "context.stats.userMessages": "Повідомлення користувача", "context.stats.assistantMessages": "Повідомлення асистента", "context.stats.totalCost": "Загальна вартість", + "context.stats.conversationTokens": "Токени розмови", "context.stats.sessionCreated": "Сесію створено", "context.stats.lastActivity": "Остання активність", "context.usage.tokens": "Токени", "context.usage.usage": "Використання", "context.usage.cost": "Вартість", + "context.usage.totalTokens": "Токени розмови", "context.usage.clickToView": "Натисніть, щоб переглянути контекст", "context.usage.view": "Переглянути використання контексту", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 8ce7ee5c..29b74060 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -463,7 +463,7 @@ export const dict = { "context.stats.provider": "提供商", "context.stats.model": "模型", "context.stats.limit": "上下文限制", - "context.stats.totalTokens": "总 token", + "context.stats.totalTokens": "上下文 token", "context.stats.usage": "使用率", "context.stats.inputTokens": "输入 token", "context.stats.outputTokens": "输出 token", @@ -472,11 +472,13 @@ export const dict = { "context.stats.userMessages": "用户消息", "context.stats.assistantMessages": "助手消息", "context.stats.totalCost": "总成本", + "context.stats.conversationTokens": "对话总 token", "context.stats.sessionCreated": "创建时间", "context.stats.lastActivity": "最后活动", "context.usage.tokens": "Token", "context.usage.usage": "使用率", "context.usage.cost": "成本", + "context.usage.totalTokens": "对话总 token", "context.usage.clickToView": "点击查看上下文", "context.usage.view": "查看上下文用量", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 4e9e4878..6a87085d 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -374,12 +374,14 @@ export const dict = { "context.stats.userMessages": "使用者訊息", "context.stats.assistantMessages": "助手訊息", "context.stats.totalCost": "總成本", + "context.stats.conversationTokens": "對話總 token", "context.stats.sessionCreated": "建立時間", "context.stats.lastActivity": "最後活動", "context.usage.tokens": "Token", "context.usage.usage": "使用量", "context.usage.cost": "成本", + "context.usage.totalTokens": "對話總 token", "context.usage.clickToView": "點擊查看上下文", "context.usage.view": "檢視上下文用量", diff --git a/packages/app/src/pages/layout/sidebar-delete-session.tsx b/packages/app/src/pages/layout/sidebar-delete-session.tsx new file mode 100644 index 00000000..9b661d17 --- /dev/null +++ b/packages/app/src/pages/layout/sidebar-delete-session.tsx @@ -0,0 +1,104 @@ +import type { Session } from "@deepagent-code/sdk/v2/client" +import { createMemo } from "solid-js" +import { produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@deepagent-code/ui/button" +import { Dialog } from "@deepagent-code/ui/dialog" +import { useDialog } from "@deepagent-code/ui/context/dialog" +import { base64Encode } from "@deepagent-code/core/util/encode" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useLanguage } from "@/context/language" +import { sessionTitle } from "@/utils/session-title" +import { showToast } from "@/utils/toast" + +// Sidebar-level delete confirmation. Unlike the timeline's DialogDeleteSession (bound to the open +// session via useSDK/useSync), this operates on ANY session by directory + id using serverSDK and the +// per-directory serverSync child store — the sidebar lists sessions across directories. +export function DialogDeleteSession(props: { session: Session }): ReturnType { + const params = useParams() + const navigate = useNavigate() + const dialog = useDialog() + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + + const name = createMemo(() => sessionTitle(props.session.title) ?? language.t("command.session.new")) + + const handleDelete = async () => { + const session = props.session + const [store, setStore] = serverSync.child(session.directory, { bootstrap: false }) + + // Compute the neighbour to navigate to if we're deleting the session currently open. + const roots = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) + const index = roots.findIndex((s) => s.id === session.id) + const nextSession = index === -1 ? undefined : (roots[index + 1] ?? roots[index - 1]) + + const ok = await serverSDK.client.session + .delete({ directory: session.directory, sessionID: session.id }) + .then(() => true) + .catch((err: unknown) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: err instanceof Error ? err.message : String(err), + }) + return false + }) + if (!ok) { + dialog.close() + return + } + + // Optimistically drop the deleted session and its whole descendant subtree from the store. + const removed = new Set([session.id]) + const byParent = new Map() + for (const item of store.session ?? []) { + if (!item.parentID) continue + const existing = byParent.get(item.parentID) + if (existing) existing.push(item.id) + else byParent.set(item.parentID, [item.id]) + } + const stack = [session.id] + while (stack.length) { + const id = stack.pop()! + for (const child of byParent.get(id) ?? []) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + setStore( + produce((draft) => { + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + // If the active route points at a removed session, navigate to a neighbour (or the new-session view). + if (params.id && removed.has(params.id)) { + const dir = base64Encode(session.directory) + navigate(nextSession ? `/${dir}/session/${nextSession.id}` : `/${dir}/session`) + } + + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index eb10171c..827c8c13 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -2,18 +2,27 @@ import type { Session } from "@deepagent-code/sdk/v2/client" import { Avatar } from "@deepagent-code/ui/avatar" import { Icon } from "@deepagent-code/ui/icon" import { IconButton } from "@deepagent-code/ui/icon-button" +import { InlineInput } from "@deepagent-code/ui/inline-input" +import { ContextMenu } from "@deepagent-code/ui/context-menu" import { Spinner } from "@deepagent-code/ui/spinner" import { Tooltip } from "@deepagent-code/ui/tooltip" +import { useDialog } from "@deepagent-code/ui/context/dialog" import { getFilename } from "@deepagent-code/core/util/path" -import { A, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" +import { Binary } from "@deepagent-code/core/util/binary" +import { A } from "@solidjs/router" +import { type Accessor, createMemo, createSignal, For, type JSX, Match, Show, Switch } from "solid-js" +import { produce } from "solid-js/store" import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" +import { formatSessionTime } from "@/utils/session-time" +import { showToast } from "@/utils/toast" +import { DialogDeleteSession } from "./sidebar-delete-session" import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { directChildSessions, @@ -105,8 +114,29 @@ const SessionRow = (props: { sidebarOpened: Accessor warmPress: () => void warmFocus: () => void + editing: Accessor + onStartRename: () => void + onSaveRename: (next: string) => void + onCancelRename: () => void }): JSX.Element => { + const language = useLanguage() const title = () => sessionTitle(props.session.title) + const time = createMemo(() => { + const t = props.session.time + const at = t.updated ?? t.created + if (typeof at !== "number") return "" + return formatSessionTime(at, language.intl()) + }) + + const [draft, setDraft] = createSignal("") + const beginRename = (event: MouseEvent) => { + // Double-click the title to rename in place; suppress the link navigation the dblclick would fire. + event.preventDefault() + event.stopPropagation() + setDraft(title() ?? "") + props.onStartRename() + } + const commit = () => props.onSaveRename(draft().trim()) return ( { + onClick={(event) => { + if (props.editing()) { + event.preventDefault() + return + } if (props.sidebarOpened()) return props.clearHoverProjectSoon() }} @@ -140,21 +174,103 @@ const SessionRow = (props: { - {title()} + + {title()} + + } + > + { + requestAnimationFrame(() => { + if (!el.isConnected) return + el.focus() + el.select() + }) + }} + value={draft()} + class="text-14-regular text-text-strong min-w-0 flex-1 rounded-[6px] pl-1 -ml-1" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setDraft(event.currentTarget.value)} + onClick={(event) => event.preventDefault()} + onPointerDown={(event) => event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + commit() + return + } + if (event.key === "Escape") { + event.preventDefault() + props.onCancelRename() + } + }} + onBlur={commit} + /> + + + + ) } export const SessionItem = (props: SessionItemProps): JSX.Element => { - const params = useParams() const layout = useLayout() const language = useLanguage() const notification = useNotification() const permission = usePermission() const serverSync = useServerSync() + const serverSDK = useServerSDK() + const dialog = useDialog() const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) - const [sessionStore] = serverSync.child(props.session.directory) + const [sessionStore, setSessionStore] = serverSync.child(props.session.directory) + + const [editing, setEditing] = createSignal(false) + // The context menu defers opening the rename editor until it has closed (via onCloseAutoFocus), so + // focus lands on the input rather than being stolen back by the menu teardown. + let pendingRename = false + + const startRename = () => setEditing(true) + const cancelRename = () => setEditing(false) + const saveRename = (next: string) => { + setEditing(false) + const current = sessionTitle(props.session.title) ?? "" + if (!next || next === current) return + // Optimistic: patch the per-directory store immediately, then persist. Roll back on failure. + const previous = props.session.title + setSessionStore( + produce((draft) => { + const match = Binary.search(draft.session, props.session.id, (s) => s.id) + if (match.found) draft.session[match.index].title = next + }), + ) + void serverSDK.client.session + .update({ directory: props.session.directory, sessionID: props.session.id, title: next }) + .catch((err: unknown) => { + setSessionStore( + produce((draft) => { + const match = Binary.search(draft.session, props.session.id, (s) => s.id) + if (match.found) draft.session[match.index].title = previous + }), + ) + showToast({ + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }) + } + const deleteSession = () => dialog.show(() => ) const hasPermissions = createMemo(() => { return !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.session.id, (item) => { return !permission.autoResponds(item, props.session.directory) @@ -212,62 +328,98 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { sidebarOpened={layout.sidebar.opened} warmPress={() => warm(2, "high")} warmFocus={() => warm(2, "high")} + editing={editing} + onStartRename={startRename} + onSaveRename={saveRename} + onCancelRename={cancelRename} /> ) return ( <> -
{ + // When the context menu closes and a rename was requested from the menu item, open the + // inline editor after the menu's focus-return animation finishes (onCloseAutoFocus fires). + if (!open && pendingRename) { + pendingRename = false + // Small rAF so the focus truly leaves the closed menu before we set editing state. + requestAnimationFrame(startRename) + } + }} > -
-
- - {item} + +
+
+ + {item} + + } + > + {item} + +
+ + +
+ + { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.session) + }} + /> - } - > - {item} +
- - -
+ + + { + // Defer rename to onCloseAutoFocus so focus isn't stolen back by menu teardown. + pendingRename = true }} > - - { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.session) - }} - /> - -
-
-
-
+ {language.t("common.rename")} + + void props.archiveSession(props.session)}> + {language.t("common.archive")} + + + + {language.t("common.delete")} + + + + 0}>
diff --git a/packages/app/src/pages/session/message-timeline.data.ts b/packages/app/src/pages/session/message-timeline.data.ts index 16ec5b1b..13fc176c 100644 --- a/packages/app/src/pages/session/message-timeline.data.ts +++ b/packages/app/src/pages/session/message-timeline.data.ts @@ -25,6 +25,11 @@ export type TimelineRowMap = { userMessageID: string label: "compaction" | "interrupted" } + // Per-turn meta line shown above the assistant reply: a live-ticking elapsed timer + token count + // while the agent works, frozen once the turn completes. + TurnMeta: { + userMessageID: string + } AssistantPart: { userMessageID: string group: PartGroup @@ -55,6 +60,9 @@ export namespace TimelineRow { userMessageID: string label: "compaction" | "interrupted" }> {} + export class TurnMeta extends Data.TaggedClass("TurnMeta")<{ + userMessageID: string + }> {} export class AssistantPart extends Data.TaggedClass("AssistantPart")<{ userMessageID: string group: PartGroup @@ -82,6 +90,7 @@ export namespace TimelineRow { | CommentStrip | UserMessage | TurnDivider + | TurnMeta | AssistantPart | Thinking | DiffSummary @@ -99,6 +108,8 @@ export namespace TimelineRow { return `user-message:${row.userMessageID}` case "TurnDivider": return `turn-divider:${row.userMessageID}:${row.label}` + case "TurnMeta": + return `turn-meta:${row.userMessageID}` case "AssistantPart": return `assistant-part:${row.userMessageID}:${row.group.key}` case "Thinking": @@ -187,6 +198,12 @@ export namespace Timeline { ) } + // Meta line (live elapsed + tokens) once per turn, above the assistant reply. Present whenever the + // turn has produced (or is producing) an assistant response. + if (assistantMessages.length > 0 || (isActive && status !== "idle")) { + rows.push(new TimelineRow.TurnMeta({ userMessageID: userMessage.id })) + } + let assistantGroupIndex = 0 assistantItems.forEach((item) => { if (item.type === "interrupted") { diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index c910fc2e..8e701d2b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -56,6 +56,7 @@ import { normalize } from "@deepagent-code/ui/session-diff" import { useFileComponent } from "@deepagent-code/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" +import { getSubagentTokens } from "@/components/session/session-context-metrics" import { useDialog } from "@deepagent-code/ui/context/dialog" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLanguage } from "@/context/language" @@ -68,6 +69,7 @@ import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" import { messageAgentColor } from "@/utils/agent" import { sessionTitle } from "@/utils/session-title" +import { formatDuration } from "@/utils/format-duration" import { makeTimer } from "@solid-primitives/timer" import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" import { TurnRail } from "./turn-rail" @@ -314,6 +316,7 @@ export function MessageTimeline(props: { return sync.data.message[id] ?? emptyMessages }) const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) + const sessionByID = createMemo(() => new Map((sync.data.session ?? []).map((session) => [session.id, session] as const))) const assistantMessagesByParent = createMemo(() => { const result = new Map() for (const message of sessionMessages()) { @@ -340,6 +343,16 @@ export function MessageTimeline(props: { const working = createMemo(() => sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) + // Live wall-clock tick (1s) that only runs while the session is working, so the top-left turn timer + // advances in real time from thinking-start until the reply completes, then stops (no idle churn). + const [nowMs, setNowMs] = createSignal(Date.now()) + createEffect(() => { + if (!working()) return + setNowMs(Date.now()) + const dispose = makeTimer(() => setNowMs(Date.now()), 1000, setInterval) + onCleanup(dispose) + }) + const [timeoutDone, setTimeoutDone] = createSignal(true) const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => { @@ -1017,6 +1030,41 @@ export function MessageTimeline(props: { return end - message.time.created } + // Elapsed time for a turn: live (now - user.time.created, driven by nowMs) while the turn is + // working, otherwise the frozen completed duration. + const turnElapsedMs = (userMessageID: string) => { + if (workingTurn(userMessageID)) { + const message = messageByID().get(userMessageID) + if (!message || message.role !== "user") return + return Math.max(0, nowMs() - message.time.created) + } + return turnDurationMs(userMessageID) + } + + // Total tokens USED by a turn (input + output + reasoning + cache), summed across the turn's + // assistant messages. Distinct from the top-right retained-context number. Advances as each + // tool-loop step finishes (providers report usage per step, not per token). + const turnTokens = (userMessageID: string) => { + let total = 0 + const childIds = new Set() + for (const message of assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages) { + const t = message.tokens + total += t.input + t.output + t.reasoning + t.cache.read + t.cache.write + // Roll in subagent usage: each `task` tool-call in this turn spawns a child session whose + // persisted total we add. Dedupe by child sessionId so a resumed/multi-call child isn't + // double-counted. + for (const part of getMsgParts(message.id)) { + if (part.type !== "tool" || part.tool !== "task") continue + const state = (part as ToolPart).state + const meta = "metadata" in state ? (state.metadata as { sessionId?: unknown } | undefined) : undefined + const childId = typeof meta?.sessionId === "string" ? meta.sessionId : undefined + if (childId) childIds.add(childId) + } + } + for (const childId of childIds) total += getSubagentTokens(sessionByID().get(childId)) + return total + } + const assistantCopyPartID = (userMessageID: string) => { if (workingTurn(userMessageID)) return null const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages @@ -1244,6 +1292,41 @@ export function MessageTimeline(props: { ) } + case "TurnMeta": { + const turnMetaRow = row as Accessor> + const id = () => turnMetaRow().userMessageID + const live = () => workingTurn(id()) + const elapsed = () => { + const ms = turnElapsedMs(id()) + if (typeof ms !== "number") return "" + return formatDuration(ms, language.t, (n) => n.toLocaleString(language.intl())) + } + const tokens = () => turnTokens(id()) + return ( + +
+ 0}> +
+ + + + + {elapsed()} + + 0}> + + + + + {tokens().toLocaleString(language.intl())} {language.t("context.usage.tokens")} + + +
+
+
+
+ ) + } case "AssistantPart": { const assistantPartRow = row as Accessor> return ( diff --git a/packages/app/src/utils/format-duration.test.ts b/packages/app/src/utils/format-duration.test.ts new file mode 100644 index 00000000..bd774090 --- /dev/null +++ b/packages/app/src/utils/format-duration.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test" +import { formatDuration } from "./format-duration" + +// Minimal English-ish translator that mirrors the ui i18n templates. +const t = (key: string, params?: Record) => { + const p = params ?? {} + if (key === "ui.message.duration.seconds") return `${p.count}s` + if (key === "ui.message.duration.minutesSeconds") return `${p.minutes}m ${p.seconds}s` + if (key === "ui.message.duration.hoursMinutes") return `${p.hours}h ${p.minutes}m` + return key +} +const fmt = (n: number) => String(n) + +describe("formatDuration", () => { + test("under a minute -> seconds", () => { + expect(formatDuration(4_200, t as never, fmt)).toBe("4s") + expect(formatDuration(59_000, t as never, fmt)).toBe("59s") + }) + + test("under an hour -> minutes + seconds", () => { + expect(formatDuration(60_000, t as never, fmt)).toBe("1m 0s") + expect(formatDuration(90_000, t as never, fmt)).toBe("1m 30s") + }) + + test("an hour or more -> hours + minutes", () => { + expect(formatDuration(3_600_000, t as never, fmt)).toBe("1h 0m") + expect(formatDuration(3_930_000, t as never, fmt)).toBe("1h 5m") + }) + + test("negative / NaN -> empty string", () => { + expect(formatDuration(-1, t as never, fmt)).toBe("") + expect(formatDuration(Number.NaN, t as never, fmt)).toBe("") + }) +}) diff --git a/packages/app/src/utils/format-duration.ts b/packages/app/src/utils/format-duration.ts new file mode 100644 index 00000000..95ae9204 --- /dev/null +++ b/packages/app/src/utils/format-duration.ts @@ -0,0 +1,29 @@ +type DurationKey = + | "ui.message.duration.seconds" + | "ui.message.duration.minutesSeconds" + | "ui.message.duration.hoursMinutes" + +type Translate = (key: DurationKey, params?: Record) => string + +// Format an elapsed duration (milliseconds) into a compact, locale-formatted string. Mirrors codex's +// `fmt_elapsed_compact` bucketing (s / m s / h m) so the live top-left timer and any completed +// duration read the same way. `format` handles locale-aware number grouping. +export function formatDuration(ms: number, t: Translate, format: (n: number) => string): string { + if (!(ms >= 0)) return "" + const total = Math.round(ms / 1000) + if (total < 60) return t("ui.message.duration.seconds", { count: format(total) }) + if (total < 3600) { + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return t("ui.message.duration.minutesSeconds", { + minutes: format(minutes), + seconds: format(seconds), + }) + } + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + return t("ui.message.duration.hoursMinutes", { + hours: format(hours), + minutes: format(minutes), + }) +} diff --git a/packages/app/src/utils/session-time.test.ts b/packages/app/src/utils/session-time.test.ts new file mode 100644 index 00000000..fe2315e1 --- /dev/null +++ b/packages/app/src/utils/session-time.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { formatSessionTime } from "./session-time" + +// Fixed "now": Wednesday, 2026-07-08 15:30 local. +const now = new Date(2026, 6, 8, 15, 30, 0) + +describe("formatSessionTime", () => { + test("same day -> clock time (HH:MM)", () => { + const at = new Date(2026, 6, 8, 9, 5, 0) + expect(formatSessionTime(at, "en-US", now)).toBe("09:05") + }) + + test("earlier today late morning -> clock time", () => { + const at = new Date(2026, 6, 8, 0, 1, 0) + expect(formatSessionTime(at, "en-US", now)).toBe("00:01") + }) + + test("yesterday -> weekday", () => { + const at = new Date(2026, 6, 7, 23, 0, 0) // Tuesday + expect(formatSessionTime(at, "en-US", now)).toBe("Tue") + }) + + test("6 days ago -> weekday", () => { + const at = new Date(2026, 6, 2, 10, 0, 0) // Thursday + expect(formatSessionTime(at, "en-US", now)).toBe("Thu") + }) + + test("exactly 7 calendar days ago -> month-day", () => { + const at = new Date(2026, 6, 1, 10, 0, 0) // last Wednesday + expect(formatSessionTime(at, "en-US", now)).toBe("07/01") + }) + + test("8 days ago -> month-day", () => { + const at = new Date(2026, 5, 30, 10, 0, 0) + expect(formatSessionTime(at, "en-US", now)).toBe("06/30") + }) + + test("accepts epoch millis", () => { + const at = new Date(2026, 6, 8, 14, 0, 0).getTime() + expect(formatSessionTime(at, "en-US", now)).toBe("14:00") + }) + + test("invalid input -> empty string", () => { + expect(formatSessionTime(Number.NaN, "en-US", now)).toBe("") + }) +}) diff --git a/packages/app/src/utils/session-time.ts b/packages/app/src/utils/session-time.ts new file mode 100644 index 00000000..53ee6b98 --- /dev/null +++ b/packages/app/src/utils/session-time.ts @@ -0,0 +1,31 @@ +// Contextual timestamp for the sidebar session rows. +// - same calendar day -> HH:MM (just the clock time) +// - within the last 6 days -> weekday (e.g. "周三" / "Wed") +// - 7+ calendar days ago -> MM-DD (fall back to a date) +// The boundary is measured in CALENDAR days (not raw 24h spans) so "last Thursday" still reads as a +// weekday while the same weekday a week earlier reads as a date — matching the user's spec. + +// Whole-day difference between two dates, ignoring the time-of-day component. +function calendarDayDiff(now: Date, then: Date): number { + const a = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) + const b = Date.UTC(then.getFullYear(), then.getMonth(), then.getDate()) + return Math.round((a - b) / 86_400_000) +} + +export function formatSessionTime(input: number | string | Date, locale: string, now: Date = new Date()): string { + const date = input instanceof Date ? input : new Date(input) + if (Number.isNaN(date.getTime())) return "" + + const diffDays = calendarDayDiff(now, date) + + // Future or today -> clock time. + if (diffDays <= 0) { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit", hour12: false }).format(date) + } + // Yesterday .. 6 days ago -> weekday. + if (diffDays < 7) { + return new Intl.DateTimeFormat(locale, { weekday: "short" }).format(date) + } + // A week or more ago -> month-day. + return new Intl.DateTimeFormat(locale, { month: "2-digit", day: "2-digit" }).format(date) +} diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index c2e50c4b..58f70e19 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -208,6 +208,12 @@ } } + [data-component="task-tool-tokens"] { + flex-shrink: 0; + margin-left: auto; + white-space: nowrap; + } + [data-component="task-tool-action"] { display: inline-flex; align-items: center; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 8c47dbc4..27fdf313 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1472,7 +1472,6 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() - const numfmt = createMemo(() => new Intl.NumberFormat(i18n.locale())) const part = () => props.part as TextPart const interrupted = createMemo( () => @@ -1486,25 +1485,15 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { return match?.models?.[message.modelID]?.name ?? message.modelID }) - const duration = createMemo(() => { + // Footer timestamp: the wall-clock date + time the reply completed (falls back to when it was + // created while still streaming). Follows the active locale / system clock. This replaces the old + // "elapsed duration" footer \u2014 the live elapsed timer now lives at the top-left of the turn. + const completedAt = createMemo(() => { if (props.message.role !== "assistant") return "" const message = props.message as AssistantMessage - const completed = message.time.completed - const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - message.time.created - : -1 - if (!(ms >= 0)) return "" - const total = Math.round(ms / 1000) - if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) }) - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return i18n.t("ui.message.duration.minutesSeconds", { - minutes: numfmt().format(minutes), - seconds: numfmt().format(seconds), - }) + const at = message.time.completed ?? message.time.created + if (typeof at !== "number") return "" + return new Intl.DateTimeFormat(i18n.locale(), { dateStyle: "short", timeStyle: "short" }).format(at) }) const meta = createMemo(() => { @@ -1513,7 +1502,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const items = [ agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), - duration(), + completedAt(), interrupted() ? i18n.t("ui.message.interrupted") : "", ] return items.filter((x) => !!x).join(" \u00B7 ") @@ -1836,6 +1825,27 @@ ToolRegistry.register({ }) const running = createMemo(() => props.status === "pending" || props.status === "running") + // Subagent token usage: the child session's persisted running total (input+output+reasoning+cache), + // shown on the right of the task box. Reads the child Session row already present in the store. + const childSession = createMemo(() => { + const id = childSessionId() + if (!id) return + return data.store.session?.find((s) => s.id === id) + }) + const childTokens = createMemo(() => { + const t = childSession()?.tokens + if (!t) return 0 + return t.input + t.output + t.reasoning + t.cache.read + t.cache.write + }) + const childTokensLabel = createMemo(() => { + const value = childTokens() + if (value <= 0) return "" + const formatted = new Intl.NumberFormat(i18n.locale(), { notation: "compact", maximumFractionDigits: 1 }).format( + value, + ) + return i18n.t("ui.tool.agent.tokens", { count: formatted }) + }) + const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) @@ -1874,6 +1884,11 @@ ToolRegistry.register({
+ + + {childTokensLabel()} + +
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index de15a353..bc04d45a 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -111,6 +111,7 @@ export const dict = { "ui.tool.questions": "أسئلة", "ui.tool.agent": "وكيل {{type}}", "ui.tool.agent.default": "وكيل", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "ملف", "ui.common.file.other": "ملفات", @@ -165,4 +166,5 @@ export const dict = { "ui.toolErrorCard.copyError": "نسخ الخطأ", "ui.message.duration.seconds": "{{count}}ث", "ui.message.duration.minutesSeconds": "{{minutes}}د {{seconds}}ث", + "ui.message.duration.hoursMinutes": "{{hours}}س {{minutes}}د", } diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index a929b4d7..c84a1fcb 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -111,6 +111,7 @@ export const dict = { "ui.tool.questions": "Perguntas", "ui.tool.agent": "Agente {{type}}", "ui.tool.agent.default": "Agente", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "arquivo", "ui.common.file.other": "arquivos", @@ -165,4 +166,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Copiar erro", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", } diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index cfe6eb9d..0a214b39 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -115,6 +115,7 @@ export const dict = { "ui.tool.questions": "Pitanja", "ui.tool.agent": "{{type}} agent", "ui.tool.agent.default": "agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "datoteka", "ui.common.file.other": "datoteke", @@ -169,4 +170,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Kopiraj grešku", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", } satisfies Partial> diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 6451e266..da1a305a 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -110,6 +110,7 @@ export const dict = { "ui.tool.questions": "Spørgsmål", "ui.tool.agent": "{{type}} Agent", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "fil", "ui.common.file.other": "filer", @@ -164,4 +165,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Kopier fejl", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}t {{minutes}}m", } diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index b60af112..15f6792a 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -116,6 +116,7 @@ export const dict = { "ui.tool.questions": "Fragen", "ui.tool.agent": "{{type}} Agent", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "Datei", "ui.common.file.other": "Dateien", @@ -170,4 +171,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Fehler kopieren", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", } satisfies Partial> diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index b7dcc6ae..1fe020eb 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -124,6 +124,7 @@ export const dict: Record = { "ui.tool.questions": "Questions", "ui.tool.agent": "{{type}} Agent", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.tool.skill": "Skill", "ui.basicTool.called": "Called `{{tool}}`", @@ -158,6 +159,7 @@ export const dict: Record = { "ui.message.copied": "Copied", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", "ui.message.interrupted": "Interrupted", "ui.message.queued": "Queued", "ui.message.attachment.alt": "attachment", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 0922cae1..a19cc380 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -111,6 +111,7 @@ export const dict = { "ui.tool.questions": "Preguntas", "ui.tool.agent": "Agente {{type}}", "ui.tool.agent.default": "Agente", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "archivo", "ui.common.file.other": "archivos", @@ -165,4 +166,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Copiar error", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", } diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 36301f2a..f57f63c7 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -111,6 +111,7 @@ export const dict = { "ui.tool.questions": "Questions", "ui.tool.agent": "Agent {{type}}", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "fichier", "ui.common.file.other": "fichiers", @@ -165,4 +166,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Copier l'erreur", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}h {{minutes}}m", } diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 7825c324..5aba73be 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -110,6 +110,7 @@ export const dict = { "ui.tool.questions": "質問", "ui.tool.agent": "{{type}}エージェント", "ui.tool.agent.default": "エージェント", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "ファイル", "ui.common.file.other": "ファイル", @@ -164,4 +165,5 @@ export const dict = { "ui.toolErrorCard.copyError": "エラーをコピー", "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", + "ui.message.duration.hoursMinutes": "{{hours}}時間 {{minutes}}分", } diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index b147c9c8..2a6f3a05 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -111,6 +111,7 @@ export const dict = { "ui.tool.questions": "질문", "ui.tool.agent": "{{type}} 에이전트", "ui.tool.agent.default": "에이전트", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "파일", "ui.common.file.other": "파일", @@ -165,4 +166,5 @@ export const dict = { "ui.toolErrorCard.copyError": "오류 복사", "ui.message.duration.seconds": "{{count}}초", "ui.message.duration.minutesSeconds": "{{minutes}}분 {{seconds}}초", + "ui.message.duration.hoursMinutes": "{{hours}}시간 {{minutes}}분", } diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 1c8f848f..fd5c808d 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -114,6 +114,7 @@ export const dict: Record = { "ui.tool.questions": "Spørsmål", "ui.tool.agent": "{{type}}-agent", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "fil", "ui.common.file.other": "filer", @@ -168,4 +169,5 @@ export const dict: Record = { "ui.toolErrorCard.copyError": "Kopier feil", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}t {{minutes}}m", } diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 9edf5deb..bff87ed0 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -110,6 +110,7 @@ export const dict = { "ui.tool.questions": "Pytania", "ui.tool.agent": "Agent {{type}}", "ui.tool.agent.default": "Agent", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "plik", "ui.common.file.other": "pliki", @@ -164,4 +165,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Kopiuj błąd", "ui.message.duration.seconds": "{{count}}s", "ui.message.duration.minutesSeconds": "{{minutes}}m {{seconds}}s", + "ui.message.duration.hoursMinutes": "{{hours}}g {{minutes}}m", } diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 704bd44f..f01e2e75 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -110,6 +110,7 @@ export const dict = { "ui.tool.questions": "Вопросы", "ui.tool.agent": "Агент {{type}}", "ui.tool.agent.default": "Агент", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "файл", "ui.common.file.other": "файлов", @@ -164,4 +165,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Скопировать ошибку", "ui.message.duration.seconds": "{{count}}с", "ui.message.duration.minutesSeconds": "{{minutes}}м {{seconds}}с", + "ui.message.duration.hoursMinutes": "{{hours}}ч {{minutes}}м", } diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 588a49eb..6ff719d6 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -112,6 +112,7 @@ export const dict = { "ui.tool.questions": "คำถาม", "ui.tool.agent": "เอเจนต์ {{type}}", "ui.tool.agent.default": "เอเจนต์", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "ไฟล์", "ui.common.file.other": "ไฟล์", @@ -166,4 +167,5 @@ export const dict = { "ui.toolErrorCard.copyError": "คัดลอกข้อผิดพลาด", "ui.message.duration.seconds": "{{count}}วิ", "ui.message.duration.minutesSeconds": "{{minutes}}นาที {{seconds}}วิ", + "ui.message.duration.hoursMinutes": "{{hours}}ชม {{minutes}}นาที", } diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 3f9caed4..dcade07f 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -117,6 +117,7 @@ export const dict = { "ui.tool.questions": "Sorular", "ui.tool.agent": "{{type}} Ajan", "ui.tool.agent.default": "Ajan", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "dosya", "ui.common.file.other": "dosya", @@ -171,4 +172,5 @@ export const dict = { "ui.toolErrorCard.copyError": "Hatayı kopyala", "ui.message.duration.seconds": "{{count}}sn", "ui.message.duration.minutesSeconds": "{{minutes}}dk {{seconds}}sn", + "ui.message.duration.hoursMinutes": "{{hours}}sa {{minutes}}dk", } satisfies Partial> diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 8bfb3e44..ff3e84e2 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -115,6 +115,7 @@ export const dict: Record = { "ui.tool.questions": "Питання", "ui.tool.agent": "Агент {{type}}", "ui.tool.agent.default": "Агент", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.tool.skill": "Навичка", "ui.basicTool.called": "Викликано `{{tool}}`", @@ -149,6 +150,7 @@ export const dict: Record = { "ui.message.copied": "Скопійовано", "ui.message.duration.seconds": "{{count}}с", "ui.message.duration.minutesSeconds": "{{minutes}}хв {{seconds}}с", + "ui.message.duration.hoursMinutes": "{{hours}}год {{minutes}}хв", "ui.message.interrupted": "Перервано", "ui.message.queued": "У черзі", "ui.message.attachment.alt": "вкладення", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 2fb7ca1e..b0ad85ad 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -114,6 +114,7 @@ export const dict = { "ui.tool.questions": "问题", "ui.tool.agent": "{{type}} 智能体", "ui.tool.agent.default": "智能体", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "个文件", "ui.common.file.other": "个文件", @@ -168,4 +169,5 @@ export const dict = { "ui.toolErrorCard.copyError": "复制错误", "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", + "ui.message.duration.hoursMinutes": "{{hours}}时 {{minutes}}分", } satisfies Partial> diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 9c91dc69..4c53fcb5 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -114,6 +114,7 @@ export const dict = { "ui.tool.questions": "問題", "ui.tool.agent": "{{type}} 代理程式", "ui.tool.agent.default": "代理程式", + "ui.tool.agent.tokens": "{{count}} tokens", "ui.common.file.one": "個檔案", "ui.common.file.other": "個檔案", @@ -168,4 +169,5 @@ export const dict = { "ui.toolErrorCard.copyError": "複製錯誤", "ui.message.duration.seconds": "{{count}}秒", "ui.message.duration.minutesSeconds": "{{minutes}}分 {{seconds}}秒", + "ui.message.duration.hoursMinutes": "{{hours}}時 {{minutes}}分", } satisfies Partial> From f5633d47ca65303b871c6d1c90591f292240ad84 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 9 Jul 2026 14:26:45 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(memory):=20environment-fact=20fast=20p?= =?UTF-8?q?ath=20=E2=80=94=20write-cheap,=20ask-at-use=20(V3.8.1=20=C2=A7G?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Learned facts default to project scope and global promotion needs manual review, so cross-project operational facts (test servers, containers, endpoints) never surface in a new project. This adds a governed fast path for verifiable, non-directive, desensitized environment facts: they auto-admit to a user-global `provisional` status without gate-7 review, and each project decides at first use whether to adopt them. Core: - document-store: new `environment_fact` DocType + `provisional` DocStatus (non-knowledge; excluded from retrieve() so it is never silently injected) - environment-fact: pure desensitize (fail-closed on residual secrets), fast-path routing, per-(project×fact) adoption decision, and a connection-failure -> stale matcher (§G.6) - environment-fact-adoption: per-project use-gate service (adopt / reject / modify with global-correction vs project-override), file-backed sidecar - durable-knowledge-store: provisional write + env-fact list/stale helpers - agent-gateway: connection-failure hook degrades adopted facts on a connect error observed during a run Server: HTTP endpoints GET /deepagent/env-facts, POST /env-facts/{decide,modify} App: use-gate section in the review dialog — adopt/reject cards plus an inline edit form (host/port/container/purpose/notes; global vs project scope). Credentials are never editable or displayed (secret_ref pointers only). Pure client API split into dialog-review.api.ts so the route contract test loads server-side. Boundaries held: credentials never enter memory (desensitize fail-closed), strategy/methodology still go through human review, cross-org never projects. Tests: 24 core (desensitize/routing/adoption/staleness) + 8 app route contract. Full deepagent suite 477/477, all packages typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../review/dialog-review-contract.test.ts | 51 ++- .../components/review/dialog-review.api.ts | 104 ++++++ .../src/components/review/dialog-review.tsx | 345 +++++++++++++++--- packages/app/src/i18n/en.ts | 22 ++ packages/app/src/pages/layout.tsx | 2 +- packages/core/src/agent-gateway.ts | 46 +++ packages/core/src/deepagent/document-store.ts | 17 +- .../src/deepagent/durable-knowledge-store.ts | Bin 14619 -> 17806 bytes .../deepagent/environment-fact-adoption.ts | 188 ++++++++++ .../core/src/deepagent/environment-fact.ts | 180 +++++++++ .../core/src/deepagent/knowledge-source.ts | 13 + .../environment-fact-adoption.test.ts | 128 +++++++ .../test/deepagent/environment-fact.test.ts | 152 ++++++++ .../instance/httpapi/groups/deepagent.ts | 81 ++++ .../instance/httpapi/handlers/deepagent.ts | 53 +++ 15 files changed, 1336 insertions(+), 46 deletions(-) create mode 100644 packages/app/src/components/review/dialog-review.api.ts create mode 100644 packages/core/src/deepagent/environment-fact-adoption.ts create mode 100644 packages/core/src/deepagent/environment-fact.ts create mode 100644 packages/core/test/deepagent/environment-fact-adoption.test.ts create mode 100644 packages/core/test/deepagent/environment-fact.test.ts diff --git a/packages/app/src/components/review/dialog-review-contract.test.ts b/packages/app/src/components/review/dialog-review-contract.test.ts index 7b4af285..6030a426 100644 --- a/packages/app/src/components/review/dialog-review-contract.test.ts +++ b/packages/app/src/components/review/dialog-review-contract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { listPending, setStatus } from "./dialog-review" +import { listPending, setStatus, listEnvFacts, decideEnvFact, modifyEnvFact } from "./dialog-review.api" // P1-C route contract: the V3.1 self-learning Review dialog talks to the raw-request escape-hatch // routes (NOT the generated SDK). These assertions lock the exact method/url/body so a backend @@ -70,4 +70,53 @@ describe("DeepAgent review dialog route contract", () => { }, ]) }) + + // V3.8.1 §G use-gate route contract. + test("listEnvFacts GETs /deepagent/env-facts and unwraps adopted/pending", async () => { + const calls: Recorded[] = [] + const data = { + adopted: [], + pending: [{ fact_id: "f1", version: 1, description: "milvus", body: null, degraded: false }], + } + const result = await listEnvFacts(client(calls, data)) + expect(calls).toEqual([{ method: "GET", url: "/deepagent/env-facts" }]) + expect(result).toEqual(data) + }) + + test("listEnvFacts tolerates missing fields", async () => { + const calls: Recorded[] = [] + expect(await listEnvFacts(client(calls, {}))).toEqual({ adopted: [], pending: [] }) + }) + + test("decideEnvFact POSTs /deepagent/env-facts/decide with { factId, decision }", async () => { + const calls: Recorded[] = [] + await decideEnvFact(client(calls, { ok: true }), "f1", "adopt") + expect(calls).toEqual([ + { + method: "POST", + url: "/deepagent/env-facts/decide", + body: { factId: "f1", decision: "adopt" }, + headers: { "Content-Type": "application/json" }, + }, + ]) + }) + + test("modifyEnvFact POSTs /deepagent/env-facts/modify with the full edit payload", async () => { + const calls: Recorded[] = [] + const input = { + factId: "f1", + description: "milvus test", + body: { host: "10.0.0.5", port: 19530, last_confirmed_at: "2026-07-09T00:00:00Z" }, + mode: "global" as const, + } + await modifyEnvFact(client(calls, { ok: true, factId: "f1" }), input) + expect(calls).toEqual([ + { + method: "POST", + url: "/deepagent/env-facts/modify", + body: input, + headers: { "Content-Type": "application/json" }, + }, + ]) + }) }) diff --git a/packages/app/src/components/review/dialog-review.api.ts b/packages/app/src/components/review/dialog-review.api.ts new file mode 100644 index 00000000..3dc44596 --- /dev/null +++ b/packages/app/src/components/review/dialog-review.api.ts @@ -0,0 +1,104 @@ +// Pure HTTP client functions + types for the DeepAgent Review dialog. Split out from the .tsx so it +// carries NO UI imports (Kobalte/solid-web run client-only code at module eval, which crashes a +// server-side unit test). The route contract test imports THIS module; the component re-exports these +// for back-compat. Keep this file free of any solid-js/UI imports. + +export type KnowledgeItem = { + id: string + type: "knowledge" | "strategy" | "methodology" | "memory" | "skill" | "failure_dossier" + summary: string + evidence_strength: "strong" | "medium" | "weak" | "none" + evidence_refs: string[] + approval_status: "pending" | "approved" | "rejected" +} + +type RawSdkClient = { + client: { + request(options: { + method: string + url: string + body?: unknown + headers?: Record + }): Promise<{ data?: TData }> + } +} + +export type ReviewClient = RawSdkClient + +export const listPending = async (client: ReviewClient): Promise => { + const response = await client.client.request<{ items: KnowledgeItem[] }>({ + method: "GET", + url: "/deepagent/knowledge/pending", + }) + return response.data?.items ?? [] +} + +export const setStatus = async ( + client: ReviewClient, + action: "approve" | "reject-ids", + ids: string[], +): Promise => { + await client.client.request<{ updated: string[] }>({ + method: "POST", + url: `/deepagent/knowledge/${action}`, + body: { ids }, + headers: { "Content-Type": "application/json" }, + }) +} + +// V3.8.1 §G environment-fact use-gate. Provisional user-global environment facts surface here so the +// user decides, per project, whether to adopt them (§G.5). Credentials never appear — only secret_ref +// pointers. `degraded` marks a fact whose last connection attempt failed (§G.6). +export type EnvFactBody = { + host?: string + port?: number + container?: string + purpose?: string + secret_refs?: string[] + last_confirmed_at: string + notes?: string +} +export type EnvFactItem = { + fact_id: string + version: number + description: string + body: EnvFactBody | null + degraded: boolean +} +export type EnvFactList = { adopted: EnvFactItem[]; pending: EnvFactItem[] } + +export const listEnvFacts = async (client: ReviewClient): Promise => { + const response = await client.client.request({ method: "GET", url: "/deepagent/env-facts" }) + return { adopted: response.data?.adopted ?? [], pending: response.data?.pending ?? [] } +} + +export const decideEnvFact = async ( + client: ReviewClient, + factId: string, + decision: "adopt" | "reject", +): Promise => { + await client.client.request<{ ok: boolean }>({ + method: "POST", + url: "/deepagent/env-facts/decide", + body: { factId, decision }, + headers: { "Content-Type": "application/json" }, + }) +} + +// §G.5 modify: edit a fact then adopt it. mode=global corrects the shared fact for every project; +// mode=project writes a project-local override, leaving the global fact untouched for others. +export type EnvFactModifyInput = { + factId: string + description: string + body: EnvFactBody + domain?: string | null + mode: "global" | "project" +} +export const modifyEnvFact = async (client: ReviewClient, input: EnvFactModifyInput): Promise => { + await client.client.request<{ ok: boolean; factId: string }>({ + method: "POST", + url: "/deepagent/env-facts/modify", + body: input, + headers: { "Content-Type": "application/json" }, + }) +} diff --git a/packages/app/src/components/review/dialog-review.tsx b/packages/app/src/components/review/dialog-review.tsx index 739c79c8..c3dc90fc 100644 --- a/packages/app/src/components/review/dialog-review.tsx +++ b/packages/app/src/components/review/dialog-review.tsx @@ -1,53 +1,37 @@ import { Component, createMemo, createResource, createSignal, For, Show } from "solid-js" import { Dialog } from "@deepagent-code/ui/v2/dialog-v2" import { Button } from "@deepagent-code/ui/button" +import { TextField } from "@deepagent-code/ui/text-field" import { Icon } from "@deepagent-code/ui/icon" import { useLanguage } from "@/context/language" import { showToast } from "@/utils/toast" +import { + listPending, + setStatus, + listEnvFacts, + decideEnvFact, + modifyEnvFact, + type KnowledgeItem, + type ReviewClient, + type EnvFactItem, +} from "./dialog-review.api" import "../settings-v2/settings-v2.css" -type KnowledgeItem = { - id: string - type: "knowledge" | "strategy" | "methodology" | "memory" | "skill" | "failure_dossier" - summary: string - evidence_strength: "strong" | "medium" | "weak" | "none" - evidence_refs: string[] - approval_status: "pending" | "approved" | "rejected" -} - -type RawSdkClient = { - client: { - request(options: { - method: string - url: string - body?: unknown - headers?: Record - }): Promise<{ data?: TData }> - } -} - -type ReviewClient = RawSdkClient - -export const listPending = async (client: ReviewClient): Promise => { - const response = await client.client.request<{ items: KnowledgeItem[] }>({ - method: "GET", - url: "/deepagent/knowledge/pending", - }) - return response.data?.items ?? [] -} - -export const setStatus = async ( - client: ReviewClient, - action: "approve" | "reject-ids", - ids: string[], -): Promise => { - await client.client.request<{ updated: string[] }>({ - method: "POST", - url: `/deepagent/knowledge/${action}`, - body: { ids }, - headers: { "Content-Type": "application/json" }, - }) -} +// Re-export the pure client API (defined in dialog-review.api.ts, kept UI-import-free so the route +// contract test can load it server-side) for back-compat with existing importers. +export { + listPending, + setStatus, + listEnvFacts, + decideEnvFact, + modifyEnvFact, + type KnowledgeItem, + type ReviewClient, + type EnvFactBody, + type EnvFactItem, + type EnvFactList, + type EnvFactModifyInput, +} from "./dialog-review.api" export const DialogReview: Component<{ client: ReviewClient }> = (props) => { const language = useLanguage() @@ -55,6 +39,101 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { const [busy, setBusy] = createSignal(false) const [showApproved, setShowApproved] = createSignal(false) const [items, { refetch }] = createResource(async () => listPending(props.client)) + const [envFacts, { refetch: refetchEnv }] = createResource(async () => listEnvFacts(props.client)) + const [envBusy, setEnvBusy] = createSignal(null) + + const decideEnv = async (factId: string, decision: "adopt" | "reject") => { + if (envBusy()) return + setEnvBusy(factId) + try { + await decideEnvFact(props.client, factId, decision) + await refetchEnv() + } catch (error) { + showToast({ + variant: "error", + title: language.t("review.envFacts.title"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setEnvBusy(null) + } + } + + // §G.5 inline edit form. `editing` holds the fact_id whose card is expanded into an edit form; the + // draft mirrors the editable fields (credentials are never editable here — only secret_ref + // pointers, which stay untouched). Submitting posts modify() with the chosen mode then re-adopts. + type EnvDraft = { + description: string + host: string + port: string + container: string + purpose: string + notes: string + mode: "global" | "project" + } + const [editing, setEditing] = createSignal(null) + const [draft, setDraft] = createSignal(null) + + const openEdit = (fact: EnvFactItem) => { + const b = fact.body + setDraft({ + description: fact.description, + host: b?.host ?? "", + port: b?.port !== undefined ? String(b.port) : "", + container: b?.container ?? "", + purpose: b?.purpose ?? "", + notes: b?.notes ?? "", + mode: "global", + }) + setEditing(fact.fact_id) + } + const cancelEdit = () => { + setEditing(null) + setDraft(null) + } + const patchDraft = (patch: Partial) => setDraft((d) => (d ? { ...d, ...patch } : d)) + + const submitEdit = async (fact: EnvFactItem) => { + const d = draft() + if (!d || envBusy()) return + const portNum = d.port.trim() === "" ? undefined : Number(d.port) + if (portNum !== undefined && (!Number.isFinite(portNum) || portNum < 0)) { + showToast({ + variant: "error", + title: language.t("review.envFacts.title"), + description: language.t("review.envFacts.portInvalid"), + }) + return + } + setEnvBusy(fact.fact_id) + try { + await modifyEnvFact(props.client, { + factId: fact.fact_id, + description: d.description.trim() || fact.description, + body: { + ...(d.host.trim() ? { host: d.host.trim() } : {}), + ...(portNum !== undefined ? { port: portNum } : {}), + ...(d.container.trim() ? { container: d.container.trim() } : {}), + ...(d.purpose.trim() ? { purpose: d.purpose.trim() } : {}), + ...(d.notes.trim() ? { notes: d.notes.trim() } : {}), + // Preserve the credential pointers and confirmation stamp from the original fact. + ...(fact.body?.secret_refs ? { secret_refs: fact.body.secret_refs } : {}), + last_confirmed_at: fact.body?.last_confirmed_at ?? new Date().toISOString(), + }, + mode: d.mode, + }) + cancelEdit() + await refetchEnv() + } catch (error) { + showToast({ + variant: "error", + title: language.t("review.envFacts.title"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setEnvBusy(null) + } + } // Rejected (and superseded, which the backend already excludes) are noise — // hide them. Approved collapses into a single expandable row so the list is @@ -124,12 +203,194 @@ export const DialogReview: Component<{ client: ReviewClient }> = (props) => { ) } + // §G.5 use-gate card: a provisional environment fact awaiting this project's decision. Endpoint / + // container / purpose are shown plainly (no credentials — those are secret_ref pointers), plus the + // last-confirmed timestamp so the user can judge staleness, and a degraded warning (§G.6). + const EnvFactCard = (fact: EnvFactItem, kind: "pending" | "adopted") => { + const endpoint = () => { + const b = fact.body + if (!b) return "" + const hostPort = b.host ? (b.port ? `${b.host}:${b.port}` : b.host) : "" + return [hostPort, b.container ? `(${b.container})` : ""].filter(Boolean).join(" ") + } + const busy = createMemo(() => envBusy() === fact.fact_id) + const isEditing = createMemo(() => editing() === fact.fact_id) + return ( +
+
+ +
+ {fact.description} + + {endpoint()} + + {" · "} + {fact.body!.purpose} + + + {" · "} + {language.t("review.envFacts.lastConfirmed", { value: fact.body!.last_confirmed_at })} + + + + + {language.t("review.envFacts.degraded")} + + +
+
+ + + + + +
+
+ + {/* §G.5 inline edit form: edit non-credential fields, choose global-correction vs + project-override, then save (which also adopts the edited fact for this project). */} + + {(() => { + const d = draft()! + return ( +
+ patchDraft({ description: v })} + /> +
+
+ patchDraft({ host: v })} + /> +
+
+ patchDraft({ port: v })} + /> +
+
+ patchDraft({ container: v })} + /> + patchDraft({ purpose: v })} + /> + patchDraft({ notes: v })} + /> +
+ {language.t("review.envFacts.field.scope")} +
+ + +
+ + {language.t(d.mode === "global" ? "review.envFacts.scope.globalHint" : "review.envFacts.scope.projectHint")} + +
+
+ + +
+
+ ) + })()} +
+
+ ) + } + return (

{language.t("review.description")}

+ {/* §G use-gate: provisional environment facts awaiting this project's decision. Only shown + when there is something pending or already adopted, so it stays out of the way. */} + 0 || (envFacts()?.adopted.length ?? 0) > 0}> +
+ {language.t("review.envFacts.title")} + {language.t("review.envFacts.description")} +
+
+ {(fact) => EnvFactCard(fact, "pending")} + {(fact) => EnvFactCard(fact, "adopted")} +
+
+
{ + const failureText = collectValidationFailureText(roundState) + if (!failureText) return + const baseDir = current.baseDir ?? resolveDeepAgentCodeHome() + const home = new DeepAgentWorkspace.DeepAgentCodeHome(baseDir) + const project = home.ensureProject(projectID, workspacePath) + const adoption = new DeepAgentEnvironmentFactAdoption.EnvironmentFactAdoption(baseDir, project, workspacePath) + const endpoints = adoption.adoptedEndpoints() + if (endpoints.length === 0) return + const stale = DeepAgentEnvironmentFact.matchStaleFacts(failureText, endpoints) + if (stale.length === 0) return + const userGlobal = DeepAgentKnowledgeSource.userGlobalStoreFor() + for (const id of stale) userGlobal.markEnvironmentFactStale(id) + DeepAgentKnowledgeSource.invalidateCache() +} + +// Concatenate the output of every FAILED validation this run recorded — that's where a tool's +// "ECONNREFUSED ... 10.0.0.4:19530" surfaces. Returns "" when nothing failed. +const collectValidationFailureText = (roundState: DeepAgentRoundState): string => { + const parts: string[] = [] + for (const c of roundState.candidates ?? []) + for (const v of c.validations ?? []) if (!v.passed && v.output) parts.push(v.output) + return parts.join("\n") } const blockProviderExecutedTool = (run: RunRecord, event: LLMEventType) => diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts index eb7589b9..cf3e6683 100644 --- a/packages/core/src/deepagent/document-store.ts +++ b/packages/core/src/deepagent/document-store.ts @@ -66,8 +66,21 @@ export type DocType = // NEW member: no existing type models a durable cross-session handoff (context_snapshot is // within-session). Persisted project-scoped ("durable:project:") reusing existing storage. | "bridge" - -export type DocStatus = "draft" | "candidate" | "active" | "superseded" | "rejected" | "quarantined" + // environment_fact (v3.8.1 §G): a verifiable, non-directive operational fact about a shared runtime + // environment — a test/staging server, a container, an endpoint (host/port/container/purpose). NOT + // knowledge-class: excluded from KNOWLEDGE_TYPES + KNOWLEDGE_DOC_TYPES so it never requires + // confidence and never passes the retrieve() whitelist (it must NOT be silently injected — it is + // adopted per-project through the use-gate). Credentials are NEVER stored in the body; only a + // secret_ref pointer. This is the ONLY doc type on the "write-cheap, ask-at-use" fast path (§G.2): + // it auto-admits to user-global `provisional` without gate-7 human review, then each project decides + // at first use whether to adopt it. strategy/methodology/anti_pattern deliberately do NOT get this + // path (they steer the agent; cross-project auto-share would mislead — §G.2). + | "environment_fact" + +// provisional (v3.8.1 §G.3): a user-global environment_fact that was auto-admitted WITHOUT human +// review. It is never returned by retrieve() (whose status whitelist is "active" only), so it is never +// silently injected; it becomes usable to a project only after that project adopts it at the use-gate. +export type DocStatus = "draft" | "candidate" | "active" | "superseded" | "rejected" | "quarantined" | "provisional" export type LinkRel = | "supports" | "blocks" diff --git a/packages/core/src/deepagent/durable-knowledge-store.ts b/packages/core/src/deepagent/durable-knowledge-store.ts index 306056792a582dd95488f264248bbefb89560ba3..081fcc5563ddd0f09a54ffa07bec69fa2fb130e8 100644 GIT binary patch delta 2587 zcmb7GO^;MX6b%bw93(L&>H=oG(-3(b`a_~1X~F`OuLOla0}Ep4n)hyZ7xTL6Syiux z5lFLeXJU-coqOFH{ReJc_#fQ3@egQRcy7I(na6+&XED=LANS+jb58Yl?yD~k{MhX* zy4^0_d9Qb-canbj;WDwAYiG@jP3VqQ8fdJ1phATy;pG4SSt088>8{g(Nf}bPW0h@p zGxlt}4tnu9XU(MdV^SZS(vxYRV5#)%x|7^)sGLTHvvcjWH7Xy`-K)bZ*KZH&6w`8s z_>SkAweet>B^Z6-oY za0?UDLc)E4E=ZxtlCQMOSDb81=r0Z)tG++@PW98uvFh)Y)y0$cfIhNm%o{0xHRD-f zg?MBQB?yo@T?ipMpx{htgBy;agfv&N9D&EH8ArNMSqKw_yj0{WWqj}fZIB(umQZ}5 zb7`Y2$Sv%a?=|OC>j=Zh(BYi3ev^-gtUBbaC{mkl9Y0Q{&MKrsH9L7+l%i({Ea;Sy znRd=P5AZlUp9X|Ke%7N5fEf@^dhXCh87K#xAeQi~ZabJemM|>b9JPDZ+Uk+&O#h8V zt6@H|{#GZJK;%=D4}fpqJb&TZ#g0s_VGCzT?P_4AHU+eReYKH;Z`-vj092h93qV~0 zVrdz^W&Y41&2$ITq6i*uX2qX?;%CHHS^QD7(EidON`NH{SvTA+SPVs$Z|oJmPe?wg{%Ies{#ZY>2-a~=PA88j;PQZ8Fmmx?#K&F(j21l+ z**#gQn~KghCcz3OpGXG+xfqaLwlet#0X0R3HoP%K0!4X>BN%tfbFwVIib6O zbRs^>5r}9&jfqpyka1DO;)Bj}@>9EIXiLKp6>TYJBI)EQI@OoogVWK$UVvh<(=#mO zR&2ks1PBWP^M67J8OQU_;v?Bk4``;`rf7UWS>+D2k#KL)L#e=7-VYEez990@NMAJ# zbht?CwI>qw~%2)y>os66o>3lqT1k z%N9HXimV;&uqiJQ7g{y&N?E??gPO} zBVQNL06g3QcKHJF++;v%k(5kspN_zboU32gk5orj->MFEUar1A`qBwKreqzL=<#FV mnD#_gw0`c~3zStIINDijHHOp@Ny+0V6%*j+pIe>3Ui}xC3WC=F delta 13 UcmeC{W}IELL04z<0*iM%04OvCwg3PC diff --git a/packages/core/src/deepagent/environment-fact-adoption.ts b/packages/core/src/deepagent/environment-fact-adoption.ts new file mode 100644 index 00000000..f692a1e7 --- /dev/null +++ b/packages/core/src/deepagent/environment-fact-adoption.ts @@ -0,0 +1,188 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import path from "node:path" +import type { ProjectPaths } from "./workspace" +import type { AdoptionRecord } from "./environment-fact" +import { useGateAction, type EnvironmentFactBody } from "./environment-fact" +import { + openUserGlobalStore, + openProjectStore, + type DurableKnowledgeStore, +} from "./durable-knowledge-store" +import type { DocRef } from "./document-store" + +// V3.8.1 §G.5 use-gate persistence. A project's stance toward each user-global provisional +// environment fact (adopted / rejected, with optional pinned version or project-local override) is +// stored as one small JSON per fact under /docs/env-adoption — mirroring the memory-inbox +// sidecar pattern (background-learning.ts) so it is rebuildable from files and needs no schema +// migration. The provisional facts themselves live once in the user-global store; this sidecar only +// records THIS project's decision, so the same fact can be adopted by one project and rejected by +// another without cross-talk (§G.8 isolation). + +export const ENV_ADOPTION_SCHEMA_VERSION = "deepagent-code.env_adoption.v1" + +const adoptionDir = (paths: ProjectPaths): string => path.join(paths.docsDir, "env-adoption") + +const safeFileID = (id: string): string => id.replace(/[^a-zA-Z0-9._-]/g, "_") + +const writeJson = (file: string, value: unknown): void => { + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, JSON.stringify(value, null, 2)) +} + +// A provisional fact as the use-gate should present it to a human (§G.5): the summary + parsed body +// (host/port/container/purpose/last_confirmed_at) + whether it is degraded (connect-failed). +export type UseGateFact = { + readonly fact_id: string + readonly version: number + readonly description: string + readonly body: EnvironmentFactBody | null + readonly degraded: boolean // quarantined => last connection attempt failed (§G.6) +} + +const parseBody = (raw: string): EnvironmentFactBody | null => { + try { + return JSON.parse(raw) as EnvironmentFactBody + } catch { + return null + } +} + +const toUseGateFact = (ref: DocRef, store: DurableKnowledgeStore): UseGateFact | null => { + const doc = store.documentStore.get(ref.id) + if (!doc) return null + return { + fact_id: doc.id, + version: doc.version, + description: doc.description, + body: parseBody(doc.body), + degraded: doc.status === "quarantined", + } +} + +// The use-gate service for one project. `baseDir` is the injected storage home; `paths` locates the +// project's sidecar dir; `workspacePath` roots the project store (for project-local overrides). +export class EnvironmentFactAdoption { + private readonly userGlobal: DurableKnowledgeStore + private readonly project: DurableKnowledgeStore + + constructor( + private readonly baseDir: string, + private readonly paths: ProjectPaths, + private readonly workspacePath: string, + ) { + this.userGlobal = openUserGlobalStore(baseDir) + this.project = openProjectStore(baseDir, workspacePath) + } + + private records(): AdoptionRecord[] { + const dir = adoptionDir(this.paths) + if (!existsSync(dir)) return [] + return readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .map((f) => JSON.parse(readFileSync(path.join(dir, f), "utf8")) as { record: AdoptionRecord }) + .map((x) => x.record) + } + + private writeRecord(record: AdoptionRecord): void { + writeJson(path.join(adoptionDir(this.paths), `${safeFileID(record.fact_id)}.json`), { + schema_version: ENV_ADOPTION_SCHEMA_VERSION, + record, + }) + } + + // Partition the user-global provisional facts (plus already-quarantined ones, still shown with a + // warning) into what this project should do at the use-gate (§G.5): + // adopted -> inject silently pending -> ASK the human (rejected/skip -> omitted) + resolve(): { readonly adopted: readonly UseGateFact[]; readonly pending: readonly UseGateFact[] } { + const records = this.records() + const candidates = [ + ...this.userGlobal.listProvisionalEnvironmentFacts(), + ...this.userGlobal.listByStatusForType("quarantined", "environment_fact"), + ...this.userGlobal.listByStatusForType("active", "environment_fact"), + ] + const seen = new Set() + const adopted: UseGateFact[] = [] + const pending: UseGateFact[] = [] + for (const ref of candidates) { + if (seen.has(ref.id)) continue + seen.add(ref.id) + const gate = useGateAction(ref.id, records) + if (gate.action === "skip") continue + // A project-local override replaces the global fact for this project. + const sourceStore = gate.overrideDocId ? this.project : this.userGlobal + const sourceId = gate.overrideDocId ?? ref.id + const sourceRef = sourceStore.documentStore.get(sourceId) + if (!sourceRef) continue + const fact = toUseGateFact({ ...ref, id: sourceId }, sourceStore) + if (!fact) continue + ;(gate.action === "use" ? adopted : pending).push(fact) + } + return { adopted, pending } + } + + // §G.5 "adopt": this project will silently use the fact (pinned to the current version), never ask + // again. `now` is injected (no Date.now in the pure layer) for deterministic tests. + adopt(factId: string, now: string): void { + const doc = this.userGlobal.documentStore.get(factId) + this.writeRecord({ + fact_id: factId, + stance: "adopted", + decided_at: now, + ...(doc ? { adopted_version: doc.version } : {}), + }) + } + + // §G.5 "reject": never ask again in THIS project (other projects unaffected — the sidecar is + // per-project). Does not touch the global doc. + reject(factId: string, now: string): void { + this.writeRecord({ fact_id: factId, stance: "rejected", decided_at: now }) + } + + // §G.5 "modify": the user edited the fact. Two modes: + // global correction (default) -> update the user-global doc in place (version+1) AND adopt it. + // project override -> write a project-scoped provisional fact and adopt THAT, leaving + // the global doc untouched (other projects keep the original). + modify(input: { + readonly factId: string + readonly description: string + readonly body: EnvironmentFactBody + readonly domain?: string | null + readonly mode: "global" | "project" + readonly now: string + }): { readonly updatedId: string } { + const bodyJson = JSON.stringify(input.body) + if (input.mode === "project") { + const local = this.project.stageProvisionalEnvironmentFact({ + description: input.description, + body: bodyJson, + domain: input.domain ?? null, + provenance: { source: "human" }, + }) + this.writeRecord({ + fact_id: input.factId, + stance: "adopted", + decided_at: input.now, + override_doc_id: local.id, + }) + return { updatedId: local.id } + } + const updated = this.userGlobal.stageProvisionalEnvironmentFact({ + description: input.description, + body: bodyJson, + domain: input.domain ?? null, + provenance: { source: "human" }, + }) + this.writeRecord({ fact_id: updated.id, stance: "adopted", decided_at: input.now, adopted_version: updated.version }) + return { updatedId: updated.id } + } + + // The endpoints this project has adopted — fed to matchStaleFacts (§G.6) so a connection failure + // observed during this project's run can degrade the right global fact. + adoptedEndpoints(): readonly { fact_id: string; host?: string; port?: number }[] { + return this.resolve().adopted.map((f) => ({ + fact_id: f.fact_id, + ...(f.body?.host ? { host: f.body.host } : {}), + ...(f.body?.port !== undefined ? { port: f.body.port } : {}), + })) + } +} diff --git a/packages/core/src/deepagent/environment-fact.ts b/packages/core/src/deepagent/environment-fact.ts new file mode 100644 index 00000000..7fbee332 --- /dev/null +++ b/packages/core/src/deepagent/environment-fact.ts @@ -0,0 +1,180 @@ +// V3.8.1 §G environment-fact fast path. The principle (§G.2): for a fact that is VERIFIABLE, +// NON-DIRECTIVE and DESENSITIZED, move the human checkpoint from the WRITE gate to the USE gate — +// write it cheaply to user-global `provisional` without gate-7 review, then let each project decide +// at first use whether to adopt it. This module is the pure decision + transform layer; the durable +// write/read wiring lives in durable-knowledge-store.ts and the use-gate in the retriever/handlers. +// +// Hard boundary (§G.8): credentials NEVER enter any memory. `desensitize` strips them and, if any +// residual sensitivity remains after stripping, the fact FAILS CLOSED back to human review — the +// fast path is only ever taken for a provably credential-free fact. +// +// Scope: environment_fact is the ONLY type on this path. strategy/methodology/anti_pattern still go +// through the existing gate 5/6/7 human review (they steer the agent; cross-project auto-share would +// mislead), so this module deliberately has no entry point for them. + +// Reuse the SAME sensitivity detectors as memory-governance gate 1 (single source of truth): keyword +// signals plus literal credential-VALUE patterns. Kept here as local copies intentionally decoupled +// would drift — instead we import the canonical ones. +import { looksSensitive } from "./memory-governance" + +// Credential VALUE patterns we can mechanically STRIP (a superset-safe subset of the gate-1 value +// patterns — only the ones that identify a concrete secret token to excise, plus the userinfo of a +// connection URL). After stripping we re-run looksSensitive; anything it still flags fails closed. +const STRIP_PATTERNS: readonly RegExp[] = [ + /AKIA[0-9A-Z]{16}/g, // AWS access key id + /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, // GitHub token + /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, // Slack token + /\bsk-[A-Za-z0-9]{20,}\b/g, // OpenAI-style key + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, // JWT + /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g, // PEM block +] + +// scheme://user:pass@host -> scheme://host, capturing the userinfo so we can mint a secret_ref. +const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s:@]+@/gi + +const slug = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40) || "cred" + +export type DesensitizeResult = + | { + // Cleanly desensitized: safe to take the fast path. + readonly ok: true + readonly sanitized: string + readonly secretRefs: readonly string[] // vault pointers minted for stripped credentials + } + | { + // Residual sensitivity after stripping — FAIL CLOSED, route to human review (§G.4 step 2). + readonly ok: false + readonly reason: "residual_sensitive" + } + +// Strip every mechanically-excisable credential from `raw`, minting a `secret:` pointer for +// each. Then re-scan the result: if ANY sensitivity signal remains (a keyword like "password" with a +// value we couldn't structurally strip, an unrecognized token shape), fail closed. A clean result +// carries the sanitized text and the set of secret refs to persist in place of the values. +export const desensitize = (raw: string, label = "env"): DesensitizeResult => { + const secretRefs: string[] = [] + let out = raw + + out = out.replace(URL_USERINFO, (_m, scheme: string) => { + const ref = `secret:${slug(label)}-url-cred` + if (!secretRefs.includes(ref)) secretRefs.push(ref) + return scheme + }) + + for (const pattern of STRIP_PATTERNS) { + out = out.replace(pattern, () => { + const ref = `secret:${slug(label)}-${secretRefs.length + 1}` + secretRefs.push(ref) + return ref + }) + } + + // Fail closed on any residual signal (keyword patterns like "password: hunter2" that carry a value + // we can't structurally identify, or a stripped-but-still-flagged remnant). + if (looksSensitive(out)) return { ok: false, reason: "residual_sensitive" } + + return { ok: true, sanitized: out, secretRefs } +} + +// Structured body of an environment_fact. Credentials are represented ONLY by secret_ref pointers; +// the concrete values live in a vault out of band and are never persisted here (§G.8). +export type EnvironmentFactBody = { + readonly host?: string + readonly port?: number + readonly container?: string + readonly purpose?: string + readonly secret_refs?: readonly string[] + readonly last_confirmed_at: string // ISO — shown at the use-gate so a human can judge staleness (§G.6) + readonly notes?: string +} + +// A candidate environment fact as declared explicitly (§G.7 decision 1: explicit-only in V3.8.1; no +// learning-extractor auto-classification yet). +export type EnvironmentFactCandidate = { + readonly description: string // one-line summary shown at the use-gate + readonly body: EnvironmentFactBody + readonly domain?: string | null +} + +export type FastPathDecision = + | { readonly kind: "fast_path"; readonly sanitizedBody: EnvironmentFactBody; readonly secretRefs: readonly string[] } + | { readonly kind: "review"; readonly reason: "residual_sensitive" } + +// The write-side routing (§G.4): desensitize the free-text-bearing fields; on a clean result the fact +// takes the fast path (auto-admit to user-global provisional); on residual sensitivity it fails +// closed to human review. Pure — the caller performs the actual durable write. +export const decideFastPath = (candidate: EnvironmentFactCandidate): FastPathDecision => { + // Only free-text-bearing fields can carry a leaked credential; host/port/container are structured. + const scanTarget = [candidate.description, candidate.body.notes ?? "", candidate.body.purpose ?? ""].join("\n") + const label = candidate.body.container ?? candidate.body.host ?? "env" + const result = desensitize(scanTarget, label) + if (!result.ok) return { kind: "review", reason: result.reason } + + // Merge any minted refs with explicitly-declared ones (dedup). + const secretRefs = [...new Set([...(candidate.body.secret_refs ?? []), ...result.secretRefs])] + const sanitizedBody: EnvironmentFactBody = { + ...candidate.body, + ...(secretRefs.length > 0 ? { secret_refs: secretRefs } : {}), + } + return { kind: "fast_path", sanitizedBody, secretRefs } +} + +// --- Use-gate adoption model (§G.5) ------------------------------------------------------------ +// The decision is per (project × fact). A project's stance toward a provisional global fact is one of: +// unseen -> the use-gate must ASK (first encounter) +// adopted -> silently usable in this project (never ask again) +// rejected -> never ask again in this project (does NOT affect other projects) +export type AdoptionStance = "unseen" | "adopted" | "rejected" + +export type AdoptionRecord = { + readonly fact_id: string // the global provisional doc id + readonly stance: Exclude + readonly decided_at: string + readonly adopted_version?: number // the fact version the project pinned (adoption only) + readonly override_doc_id?: string // set when the user chose project-local override on modify (§G.5) +} + +// Given a project's adoption records, decide what the use-gate should do for a provisional fact. +// Pure: the caller supplies the records; this returns the action. Unknown fact -> ask. +export const useGateAction = ( + factId: string, + records: readonly AdoptionRecord[], +): { readonly action: "use" | "ask" | "skip"; readonly overrideDocId?: string } => { + const rec = records.find((r) => r.fact_id === factId) + if (!rec) return { action: "ask" } + if (rec.stance === "rejected") return { action: "skip" } + return rec.override_doc_id ? { action: "use", overrideDocId: rec.override_doc_id } : { action: "use" } +} + +// --- Connection-failure -> stale matcher (§G.6) ------------------------------------------------ +// When an adopted environment fact is used and the connection fails, mark it stale so the next +// project's use-gate shows a "last connect failed" warning. This is the PURE matcher: given a +// failure signal (an error string / attempted endpoint) and the set of facts a project has adopted +// (with their host/port), return the fact ids whose endpoint appears in the failure. The runtime +// hook that observes tool/connection errors calls this, then persists via markEnvironmentFactStale. +export type AdoptedEndpoint = { readonly fact_id: string; readonly host?: string; readonly port?: number } + +// A connection-shaped failure: heuristics kept deliberately conservative (we only ever DEGRADE a +// fact, never delete it, and a project can re-adopt), so a false positive is low-cost. +const CONNECTION_ERROR_SIGNAL = + /\b(ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH|ENOTFOUND|ENETUNREACH|connection refused|connection timed out|could not connect|failed to connect|no route to host)\b/i + +export const matchStaleFacts = (failureText: string, adopted: readonly AdoptedEndpoint[]): readonly string[] => { + if (!CONNECTION_ERROR_SIGNAL.test(failureText)) return [] + const hay = failureText.toLowerCase() + const out: string[] = [] + for (const ep of adopted) { + if (!ep.host) continue + const host = ep.host.toLowerCase() + // Require the host to appear; if a port is known, require it too (host:port or host …:port). + if (!hay.includes(host)) continue + if (ep.port !== undefined && !hay.includes(String(ep.port))) continue + out.push(ep.fact_id) + } + return [...new Set(out)] +} diff --git a/packages/core/src/deepagent/knowledge-source.ts b/packages/core/src/deepagent/knowledge-source.ts index 84a09a8a..1b58b4e0 100644 --- a/packages/core/src/deepagent/knowledge-source.ts +++ b/packages/core/src/deepagent/knowledge-source.ts @@ -7,6 +7,8 @@ import { type ScoredDoc, } from "./durable-knowledge-store" import type { DocType } from "./document-store" +import { DeepAgentCodeHome } from "./workspace" +import { EnvironmentFactAdoption } from "./environment-fact-adoption" // V3.2.1 decision B (docs/34 §8): the read-side adapter between the knowledge retriever and the // durable DocumentStore. Durable knowledge lives in TWO roots under the single injected base @@ -124,6 +126,17 @@ export const queryKnowledge = (query: SourceQuery): readonly ScoredDoc[] => { // Open the user-global store for direct writes (e.g. persistPromoted). Throws if not configured. export const userGlobalStoreFor = (): DurableKnowledgeStore => userGlobalStore() +// --- V3.8.1 §G environment-fact use-gate adapter ---------------------------------------------- +// Build the per-project use-gate service for a workspace, rooted at the same injected baseDir the +// retriever reads. Kept here (the single configured durable adapter) so the HTTP handler never +// self-resolves a home. Throws if not configured (callers guard with isConfigured()). +export const environmentFactAdoptionFor = (workspacePath: string): EnvironmentFactAdoption => { + const base = ensureBase() + const home = new DeepAgentCodeHome(base) + const paths = home.ensureProject(projectIdForWorkspace(workspacePath), workspacePath) + return new EnvironmentFactAdoption(base, paths, workspacePath) +} + // Open the project store for a workspace path. Throws if not configured. export const projectStoreFor = (workspacePath: string): DurableKnowledgeStore => projectStore(workspacePath) diff --git a/packages/core/test/deepagent/environment-fact-adoption.test.ts b/packages/core/test/deepagent/environment-fact-adoption.test.ts new file mode 100644 index 00000000..0163cba2 --- /dev/null +++ b/packages/core/test/deepagent/environment-fact-adoption.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { DeepAgentCodeHome } from "../../src/deepagent/workspace" +import { openUserGlobalStore, projectIdForWorkspace } from "../../src/deepagent/durable-knowledge-store" +import { EnvironmentFactAdoption } from "../../src/deepagent/environment-fact-adoption" +import { matchStaleFacts } from "../../src/deepagent/environment-fact" + +let root: string +let home: DeepAgentCodeHome +const WORKSPACE = "/work/milvus" +const NOW = "2026-07-09T00:00:00Z" + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "envfact-adopt-")) + home = new DeepAgentCodeHome(root) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +const seedProvisional = (description: string, body: object) => + openUserGlobalStore(root).stageProvisionalEnvironmentFact({ + description, + body: JSON.stringify(body), + provenance: { source: "human" }, + }) + +const adoptionFor = (workspacePath = WORKSPACE) => { + const paths = home.ensureProject(projectIdForWorkspace(workspacePath), workspacePath) + return new EnvironmentFactAdoption(root, paths, workspacePath) +} + +describe("V3.8.1 §G.5 use-gate resolve/adopt/reject", () => { + test("a provisional fact starts as pending (ask), not adopted", () => { + seedProvisional("milvus test server", { host: "10.0.0.4", port: 19530, last_confirmed_at: NOW }) + const a = adoptionFor() + const { adopted, pending } = a.resolve() + expect(adopted.length).toBe(0) + expect(pending.length).toBe(1) + expect(pending[0]!.body?.host).toBe("10.0.0.4") + }) + + test("adopt moves the fact to silently-used and never asks again", () => { + const fact = seedProvisional("milvus test server", { host: "10.0.0.4", port: 19530, last_confirmed_at: NOW }) + const a = adoptionFor() + a.adopt(fact.id, NOW) + const { adopted, pending } = a.resolve() + expect(pending.length).toBe(0) + expect(adopted.map((f) => f.fact_id)).toEqual([fact.id]) + }) + + test("reject omits the fact and is isolated to this project", () => { + const fact = seedProvisional("milvus test server", { host: "10.0.0.4", last_confirmed_at: NOW }) + adoptionFor("/work/projA").reject(fact.id, NOW) + // project A: skipped entirely + const a = adoptionFor("/work/projA").resolve() + expect(a.adopted.length + a.pending.length).toBe(0) + // project B: still pending (isolation — other projects unaffected) + const b = adoptionFor("/work/projB").resolve() + expect(b.pending.map((f) => f.fact_id)).toEqual([fact.id]) + }) +}) + +describe("V3.8.1 §G.5 modify", () => { + test("global correction updates the user-global doc and adopts it", () => { + const fact = seedProvisional("milvus test server", { host: "10.0.0.4", port: 19530, last_confirmed_at: NOW }) + const a = adoptionFor() + const { updatedId } = a.modify({ + factId: fact.id, + description: "milvus test server", + body: { host: "10.0.0.5", port: 19530, last_confirmed_at: "2026-07-09T12:00:00Z" }, + mode: "global", + now: NOW, + }) + // the correction is visible to a DIFFERENT project's use-gate (global scope) + const other = adoptionFor("/work/other").resolve() + const seen = other.pending.find((f) => f.fact_id === updatedId) + expect(seen?.body?.host).toBe("10.0.0.5") + }) + + test("project override writes a local fact, leaving global untouched for others", () => { + const fact = seedProvisional("milvus test server", { host: "10.0.0.4", last_confirmed_at: NOW }) + const a = adoptionFor() + const { updatedId } = a.modify({ + factId: fact.id, + description: "milvus test server (local)", + body: { host: "127.0.0.1", last_confirmed_at: NOW }, + mode: "project", + now: NOW, + }) + expect(updatedId).not.toBe(fact.id) + // this project uses the override host + const mine = adoptionFor().resolve() + expect(mine.adopted.find((f) => f.fact_id === updatedId)?.body?.host).toBe("127.0.0.1") + // another project still sees the ORIGINAL global fact as pending + const other = adoptionFor("/work/other").resolve() + expect(other.pending.find((f) => f.fact_id === fact.id)?.body?.host).toBe("10.0.0.4") + }) +}) + +describe("V3.8.1 §G.6 connection-failure staleness", () => { + test("matchStaleFacts flags an adopted endpoint named in a connection error", () => { + const adopted = [{ fact_id: "f1", host: "10.0.0.4", port: 19530 }] + const hit = matchStaleFacts("dial tcp 10.0.0.4:19530: connect: connection refused", adopted) + expect(hit).toEqual(["f1"]) + }) + + test("non-connection errors do not flag anything", () => { + const adopted = [{ fact_id: "f1", host: "10.0.0.4", port: 19530 }] + expect(matchStaleFacts("TypeError: undefined is not a function", adopted)).toEqual([]) + }) + + test("a different host in the error does not flag the fact", () => { + const adopted = [{ fact_id: "f1", host: "10.0.0.4", port: 19530 }] + expect(matchStaleFacts("ECONNREFUSED 10.9.9.9:6379", adopted)).toEqual([]) + }) + + test("marking stale drops the fact from the pending set and flags degraded", () => { + const fact = seedProvisional("flaky milvus", { host: "10.0.0.4", port: 19530, last_confirmed_at: NOW }) + const a = adoptionFor() + a.adopt(fact.id, NOW) + // simulate the runtime hook: mark stale via the store + expect(openUserGlobalStore(root).markEnvironmentFactStale(fact.id)).toBe(true) + const resolved = adoptionFor().resolve() + const shown = [...resolved.adopted, ...resolved.pending].find((f) => f.fact_id === fact.id) + expect(shown?.degraded).toBe(true) + }) +}) diff --git a/packages/core/test/deepagent/environment-fact.test.ts b/packages/core/test/deepagent/environment-fact.test.ts new file mode 100644 index 00000000..81340ba4 --- /dev/null +++ b/packages/core/test/deepagent/environment-fact.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { + desensitize, + decideFastPath, + useGateAction, + type AdoptionRecord, + type EnvironmentFactCandidate, +} from "../../src/deepagent/environment-fact" +import { openUserGlobalStore } from "../../src/deepagent/durable-knowledge-store" + +const base = () => mkdtempSync(path.join(tmpdir(), "envfact-")) + +describe("V3.8.1 §G environment-fact desensitization (fail-closed)", () => { + test("strips a connection-URL credential and mints a secret ref", () => { + const r = desensitize("connect via postgres://admin:hunter2@db.test:5432/milvus", "milvus") + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.sanitized).not.toContain("hunter2") + expect(r.sanitized).toContain("postgres://db.test:5432") + expect(r.secretRefs.length).toBeGreaterThan(0) + } + }) + + test("a URL whose only signal is stripped userinfo passes clean", () => { + // The credential is inside the URL userinfo; once stripped, no keyword remains -> clean fast path. + const r = desensitize("endpoint at redis://svc:sk-abcdefghijklmnopqrstuvwxyz012345@cache.test:6379", "svc") + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.sanitized).not.toContain("sk-abcdefghijklmnopqrstuvwxyz012345") + expect(r.sanitized).toContain("redis://cache.test:6379") + } + }) + + test("fails closed when a bare 'token' keyword survives even after the value is stripped", () => { + // Conservative by design (§G.4 step 2): the value is excised but the keyword signal remains. + const r = desensitize("use token sk-abcdefghijklmnopqrstuvwxyz012345 for the endpoint", "svc") + expect(r.ok).toBe(false) + }) + + test("fails closed when a residual keyword+value remains after stripping", () => { + // "password: hunter2" is a keyword signal with a value we cannot structurally excise → review. + const r = desensitize("the password is hunter2, connect to db.test", "db") + expect(r.ok).toBe(false) + if (!r.ok) expect(r.reason).toBe("residual_sensitive") + }) + + test("a pure fact with no secrets desensitizes cleanly with no refs", () => { + const r = desensitize("milvus standalone on 10.0.0.4:19530, container milvus-standalone", "milvus") + expect(r.ok).toBe(true) + if (r.ok) expect(r.secretRefs).toEqual([]) + }) +}) + +describe("V3.8.1 §G decideFastPath routing", () => { + const clean: EnvironmentFactCandidate = { + description: "milvus integration test server", + body: { host: "10.0.0.4", port: 19530, container: "milvus-standalone", purpose: "integration tests", last_confirmed_at: "2026-07-07T00:00:00Z" }, + } + + test("clean fact takes the fast path", () => { + const d = decideFastPath(clean) + expect(d.kind).toBe("fast_path") + }) + + test("fact whose notes leak a residual secret routes to review", () => { + const d = decideFastPath({ ...clean, body: { ...clean.body, notes: "root password is s3cr3tpw manual" } }) + expect(d.kind).toBe("review") + }) + + test("fast path folds minted refs into the sanitized body", () => { + const d = decideFastPath({ + ...clean, + body: { ...clean.body, notes: "admin url mysql://u:p@10.0.0.4:3306" }, + }) + expect(d.kind).toBe("fast_path") + if (d.kind === "fast_path") expect(d.sanitizedBody.secret_refs?.length).toBeGreaterThan(0) + }) +}) + +describe("V3.8.1 §G provisional store write + no silent injection", () => { + test("environment_fact lands at provisional in user-global and is NOT returned by retrieve()", () => { + const store = openUserGlobalStore(base()) + const doc = store.stageProvisionalEnvironmentFact({ + description: "milvus test server", + body: JSON.stringify({ host: "10.0.0.4", port: 19530, last_confirmed_at: "2026-07-07T00:00:00Z" }), + provenance: { source: "human" }, + }) + expect(doc.status).toBe("provisional") + expect(doc.scope).toBe("durable") + + // retrieve() must never surface it (whitelist excludes environment_fact AND status!=active). + const got = store.retrieve({ types: ["environment_fact", "knowledge", "memory"], limit: 20 }) + expect(got.find((s) => s.doc.id === doc.id)).toBeUndefined() + + // but it IS discoverable through the use-gate listing. + expect(store.listProvisionalEnvironmentFacts().some((r) => r.id === doc.id)).toBe(true) + }) + + test("re-declaring the same fact updates in place (idempotent, no row pileup)", () => { + const store = openUserGlobalStore(base()) + const first = store.stageProvisionalEnvironmentFact({ + description: "milvus test server", + body: JSON.stringify({ host: "10.0.0.4", last_confirmed_at: "2026-07-07T00:00:00Z" }), + provenance: { source: "human" }, + }) + store.stageProvisionalEnvironmentFact({ + description: "milvus test server", + body: JSON.stringify({ host: "10.0.0.5", last_confirmed_at: "2026-07-09T00:00:00Z" }), + provenance: { source: "human" }, + }) + expect(store.listProvisionalEnvironmentFacts().length).toBe(1) + expect(store.listProvisionalEnvironmentFacts()[0]!.id).toBe(first.id) + }) + + test("markEnvironmentFactStale quarantines the fact and rejects non-env docs", () => { + const store = openUserGlobalStore(base()) + const doc = store.stageProvisionalEnvironmentFact({ + description: "flaky server", + body: JSON.stringify({ host: "10.0.0.9", last_confirmed_at: "2026-07-07T00:00:00Z" }), + provenance: { source: "human" }, + }) + expect(store.markEnvironmentFactStale(doc.id)).toBe(true) + expect(store.markEnvironmentFactStale("doc:knowledge:nope")).toBe(false) + expect(store.listProvisionalEnvironmentFacts().some((r) => r.id === doc.id)).toBe(false) // no longer provisional + }) +}) + +describe("V3.8.1 §G use-gate adoption (per project × fact)", () => { + const facts: readonly AdoptionRecord[] = [ + { fact_id: "doc:environment_fact:milvus", stance: "adopted", decided_at: "2026-07-08T00:00:00Z", adopted_version: 1 }, + { fact_id: "doc:environment_fact:old-redis", stance: "rejected", decided_at: "2026-07-08T00:00:00Z" }, + { fact_id: "doc:environment_fact:pg", stance: "adopted", decided_at: "2026-07-08T00:00:00Z", override_doc_id: "doc:environment_fact:pg-local" }, + ] + + test("unseen fact -> ask", () => { + expect(useGateAction("doc:environment_fact:new", facts).action).toBe("ask") + }) + test("adopted fact -> use silently", () => { + expect(useGateAction("doc:environment_fact:milvus", facts).action).toBe("use") + }) + test("rejected fact -> skip (never ask again in this project)", () => { + expect(useGateAction("doc:environment_fact:old-redis", facts).action).toBe("skip") + }) + test("project-override adoption returns the override doc id", () => { + const r = useGateAction("doc:environment_fact:pg", facts) + expect(r.action).toBe("use") + expect(r.overrideDocId).toBe("doc:environment_fact:pg-local") + }) +}) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts index 40bdf2d4..7f80fafc 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/deepagent.ts @@ -186,6 +186,43 @@ export const DeepAgentShipGateResult = Schema.Struct({ per_group: Schema.Struct({ gen: Schema.Number, high: Schema.Number, max: Schema.Number }), }) +// V3.8.1 §G environment-fact use-gate. Provisional user-global environment facts (verifiable, +// non-directive, desensitized operational facts — test servers/containers/endpoints) surface here so +// each project decides, at first use, whether to adopt them. Credentials are NEVER carried in the +// body: only secret_ref pointers. `degraded` = the last connection attempt failed (§G.6). +export const DeepAgentEnvFactBody = Schema.Struct({ + host: Schema.optional(Schema.String), + port: Schema.optional(Schema.Number), + container: Schema.optional(Schema.String), + purpose: Schema.optional(Schema.String), + secret_refs: Schema.optional(Schema.Array(Schema.String)), + last_confirmed_at: Schema.String, + notes: Schema.optional(Schema.String), +}) +export const DeepAgentEnvFactItem = Schema.Struct({ + fact_id: Schema.String, + version: Schema.Number, + description: Schema.String, + body: Schema.NullOr(DeepAgentEnvFactBody), + degraded: Schema.Boolean, +}) +export const DeepAgentEnvFactList = Schema.Struct({ + adopted: Schema.Array(DeepAgentEnvFactItem), + pending: Schema.Array(DeepAgentEnvFactItem), +}) +export const DeepAgentEnvFactDecisionInput = Schema.Struct({ + factId: Schema.String, + decision: Schema.Literals(["adopt", "reject"]), +}) +export const DeepAgentEnvFactModifyInput = Schema.Struct({ + factId: Schema.String, + description: Schema.String, + body: DeepAgentEnvFactBody, + domain: Schema.optional(Schema.NullOr(Schema.String)), + mode: Schema.Literals(["global", "project"]), +}) +export const DeepAgentEnvFactResult = Schema.Struct({ ok: Schema.Boolean, factId: Schema.String }) + export const DeepAgentApi = HttpApi.make("deepagent").add( HttpApiGroup.make("deepagent") .add( @@ -318,6 +355,50 @@ export const DeepAgentApi = HttpApi.make("deepagent").add( error: DeepAgentPromotionError, }), ) + .add( + HttpApiEndpoint.get("envFacts", `${root}/env-facts`, { + query: WorkspaceRoutingQuery, + success: described(DeepAgentEnvFactList, "Provisional environment facts: adopted + pending for this project"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.envFacts.list", + summary: "List environment facts for the use-gate", + description: + "V3.8.1 §G: provisional user-global environment facts, partitioned into adopted (silently used) and pending (needs a decision) for the active project.", + }), + ), + ) + .add( + HttpApiEndpoint.post("envFactsDecide", `${root}/env-facts/decide`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentEnvFactDecisionInput, + success: described(DeepAgentEnvFactResult, "Adopt or reject a provisional environment fact for this project"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.envFacts.decide", + summary: "Adopt or reject an environment fact", + description: + "V3.8.1 §G.5: adopt (silently use in this project, never ask again) or reject (never ask again here; other projects unaffected).", + }), + ), + ) + .add( + HttpApiEndpoint.post("envFactsModify", `${root}/env-facts/modify`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentEnvFactModifyInput, + success: described(DeepAgentEnvFactResult, "Modified environment fact (global correction or project override)"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.envFacts.modify", + summary: "Modify an environment fact and adopt it", + description: + "V3.8.1 §G.5: edit a fact then adopt it. mode=global corrects the shared fact for all projects; mode=project writes a project-local override, leaving the global fact untouched.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "deepagent", description: "DeepAgent setup routes." })) .middleware(InstanceContextMiddleware) .middleware(WorkspaceRoutingMiddleware) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts index ac41b188..2135d28b 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/deepagent.ts @@ -352,6 +352,56 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen }) }) + // V3.8.1 §G environment-fact use-gate handlers. The adoption service roots at the same gateway + // baseDir the retriever reads (workspaceDir() calls configureGateway first), keyed by the active + // workspace path — so a project's adopt/reject decisions are isolated per project (§G.8). + const now = () => new Date().toISOString() + + const envFacts = Effect.fn("DeepAgentHttpApi.envFacts")(function* () { + const dir = yield* workspaceDir() + return yield* Effect.try({ + try: () => AgentGateway.DeepAgentKnowledgeSource.environmentFactAdoptionFor(dir).resolve(), + catch: (error) => + new DeepAgentPromotionError({ message: error instanceof Error ? error.message : String(error) }), + }) + }) + + const envFactsDecide = Effect.fn("DeepAgentHttpApi.envFactsDecide")(function* (ctx) { + const dir = yield* workspaceDir() + return yield* Effect.try({ + try: () => { + const adoption = AgentGateway.DeepAgentKnowledgeSource.environmentFactAdoptionFor(dir) + if (ctx.payload.decision === "adopt") adoption.adopt(ctx.payload.factId, now()) + else adoption.reject(ctx.payload.factId, now()) + AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache() + return { ok: true, factId: ctx.payload.factId } + }, + catch: (error) => + new DeepAgentPromotionError({ message: error instanceof Error ? error.message : String(error) }), + }) + }) + + const envFactsModify = Effect.fn("DeepAgentHttpApi.envFactsModify")(function* (ctx) { + const dir = yield* workspaceDir() + return yield* Effect.try({ + try: () => { + const adoption = AgentGateway.DeepAgentKnowledgeSource.environmentFactAdoptionFor(dir) + const { updatedId } = adoption.modify({ + factId: ctx.payload.factId, + description: ctx.payload.description, + body: ctx.payload.body, + ...(ctx.payload.domain !== undefined ? { domain: ctx.payload.domain } : {}), + mode: ctx.payload.mode, + now: now(), + }) + AgentGateway.DeepAgentKnowledgeRetriever.invalidateCache() + return { ok: true, factId: updatedId } + }, + catch: (error) => + new DeepAgentPromotionError({ message: error instanceof Error ? error.message : String(error) }), + }) + }) + return handlers .handle("reviews", reviews) .handle("promote", promote) @@ -364,5 +414,8 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen .handle("packsAll", packsAll) .handle("packsPin", packsPin) .handle("packsUnpin", packsUnpin) + .handle("envFacts", envFacts) + .handle("envFactsDecide", envFactsDecide) + .handle("envFactsModify", envFactsModify) }), ) From 22a08d202372ef60a3564682ffae3e5d759e5328 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Thu, 9 Jul 2026 15:01:55 +0800 Subject: [PATCH 4/4] fix(session): repair legacy OutputFormat on read + hide subagents from sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a code-review/researcher subagent conversation in the sidebar crashed the renderer and reloaded the desktop app. Root cause: subagent messages persisted before d6c325e stored `info.format` as a plain object. OutputFormatText/OutputFormatJsonSchema are Schema.Class with instanceof-gated encoders, so serializing such a stored message on the messages read path threw "Expected OutputFormatJsonSchema, got {...}" at ["info"]["format"], rejecting fetchMessages and killing the renderer. d6c325e fixed the write path but not already-persisted rows. - message-v2: normalizeFormat() coerces a stored plain-object format to an instance via the same decodeUnknownExit(Format) path the write side uses (idempotent, fills retryCount default, both variants). Applied in info(row) — the single choke point every stored message flows through on read. Also, per request, agent-spawned subagent conversations no longer appear in the sidebar; only human-initiated forks nest under their origin. - helpers: new forkParentID() (forkedFrom only, ignores parentID) drives directChildSessions. roots() still excludes subagents via sessionOriginID, so a hidden subagent neither nests nor leaks in as a top-level row. Subagents remain reachable via the subagent panel. Tests: structured-output 30/30 (4 new normalizeFormat regressions), message-v2 36/36, app layout helpers 29/29 (subagent-exclusion + roots no-leak). deepagent-code + app typecheck clean. Co-Authored-By: Claude Opus 4.8 --- packages/app/src/pages/layout/helpers.test.ts | 24 +++++++++++--- packages/app/src/pages/layout/helpers.ts | 24 ++++++++++---- .../deepagent-code/src/session/message-v2.ts | 25 +++++++++++++-- .../test/session/structured-output.test.ts | 32 +++++++++++++++++++ 4 files changed, 92 insertions(+), 13 deletions(-) diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 29644eb0..cc4a1b88 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -251,9 +251,10 @@ describe("layout workspace helpers", () => { expect(roots(store).map((s) => s.id)).toEqual(["root"]) }) - test("directChildSessions groups subagents and forks under their origin, newest first", () => { + test("directChildSessions nests ONLY human forks, never agent-spawned subagents", () => { const list = [ session({ id: "root", directory: "/w" }), + // A subagent (parentID) must NOT be nested — agent-spawned conversations stay out of the sidebar. session({ id: "sub", directory: "/w", parentID: "root", time: { created: 1, updated: 10, archived: undefined } }), session({ id: "fork", @@ -262,13 +263,26 @@ describe("layout workspace helpers", () => { time: { created: 2, updated: 20, archived: undefined }, }), session({ - id: "archived", + id: "fork2", directory: "/w", - parentID: "root", - time: { created: 3, updated: 30, archived: 99 }, + metadata: { forkedFrom: { parentSessionID: "root" } }, + time: { created: 3, updated: 25, archived: undefined }, + }), + session({ + id: "archived-fork", + directory: "/w", + metadata: { forkedFrom: { parentSessionID: "root" } }, + time: { created: 4, updated: 30, archived: 99 }, }), ] - expect(directChildSessions(list, "root").map((s) => s.id)).toEqual(["fork", "sub"]) + // Only forks, newest first; subagent "sub" and the archived fork are excluded. + expect(directChildSessions(list, "root").map((s) => s.id)).toEqual(["fork2", "fork"]) + }) + + test("roots still excludes subagents so hidden subagents don't leak in as top-level rows", () => { + const list = [session({ id: "root", directory: "/w" }), session({ id: "sub", directory: "/w", parentID: "root" })] + const store = { session: list, path: { directory: "/w" } } + expect(roots(store).map((s) => s.id)).toEqual(["root"]) }) test("formats fallback project display name", () => { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 49fa46a7..1db86e7a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -57,23 +57,35 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri } } -// The id of the session a child hangs off in the sidebar tree. Two lineage kinds are unified: +// The id of the session a child hangs off in the sidebar tree. Two lineage kinds exist: // • subagents — `parentID` (background workers spawned by the task tool) // • forks — `metadata.forkedFrom.parentSessionID` (foreground "derived from" sessions; forks // deliberately do NOT set parentID, which would give them subagent semantics) -// Roots (`roots()` above) are sessions with neither link, so a fork/subagent never also shows as a -// top-level row. +// This accessor recognizes BOTH so `roots()` can exclude either kind from the top-level list (a +// subagent/fork must never also appear as a root row). NOTE: this is intentionally NOT the accessor +// the sidebar uses to NEST children — see forkParentID / directChildSessions below. export const sessionOriginID = (session: Session): string | undefined => { if (session.parentID) return session.parentID const forkedFrom = (session.metadata as { forkedFrom?: { parentSessionID?: string } } | undefined)?.forkedFrom return forkedFrom?.parentSessionID } -// Direct children (subagents + forks) of a session, newest first. Used to nest sessions folder-style -// under their origin in the sidebar. +// The id of the session a FORK hangs off. Forks are the only lineage the sidebar nests: they are +// human-initiated "derived from" conversations. Subagents (background task-tool workers) are +// deliberately excluded — they carry `parentID`, which this accessor ignores — so agent-spawned +// subagent conversations never surface in the sidebar (they remain reachable via the subagent panel). +// `roots()` still excludes subagents via sessionOriginID, so a hidden subagent does not leak in as a +// top-level row either — it simply does not appear in the session tree. +export const forkParentID = (session: Session): string | undefined => { + const forkedFrom = (session.metadata as { forkedFrom?: { parentSessionID?: string } } | undefined)?.forkedFrom + return forkedFrom?.parentSessionID +} + +// Direct fork children of a session, newest first. Used to nest human forks folder-style under their +// origin in the sidebar. Subagents are NOT nested here (see forkParentID) — only forks. export const directChildSessions = (sessions: Session[] | undefined, originID: string): Session[] => (sessions ?? []) - .filter((s) => !s.time?.archived && sessionOriginID(s) === originID) + .filter((s) => !s.time?.archived && forkParentID(s) === originID) .sort((a, b) => (b.time?.updated ?? b.time?.created ?? 0) - (a.time?.updated ?? a.time?.created ?? 0)) // Max nesting depth mirrored from the backend fork cap (root → fork → fork-of-fork = 3 levels, i.e. diff --git a/packages/deepagent-code/src/session/message-v2.ts b/packages/deepagent-code/src/session/message-v2.ts index d0f54d7d..1f4b8201 100644 --- a/packages/deepagent-code/src/session/message-v2.ts +++ b/packages/deepagent-code/src/session/message-v2.ts @@ -36,7 +36,7 @@ import { errorMessage } from "@/util/error" import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" import * as EffectLogger from "@deepagent-code/core/effect/logger" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ @@ -89,9 +89,30 @@ export const cursor = { }, } +// Legacy-data repair: OutputFormatText/OutputFormatJsonSchema are Schema.Class, so their ENCODERS +// are instanceof-gated. Subagent (researcher/reviewer) messages persisted BEFORE the d6c325e fix +// stored `format` as a PLAIN OBJECT literal. On read, the messages endpoint encodes the Info through +// the schema; a plain-object format then throws "Expected OutputFormatJsonSchema, got {...}" at +// ["info"]["format"], which rejects fetchMessages and crashes the renderer (the app "restart" the +// user sees on clicking such a session). d6c325e fixed the WRITE path but not already-persisted rows, +// so we coerce here — the single choke point every stored message flows through on read. Idempotent: +// a value that is already an instance (or absent, or unrecognized) is returned untouched. +const decodeFormat = Schema.decodeUnknownExit(SessionV1.Format) +export const normalizeFormat = (data: Record): Record => { + const format = (data as { format?: unknown }).format + if (!format || typeof format !== "object") return data + if (format instanceof SessionV1.OutputFormatText || format instanceof SessionV1.OutputFormatJsonSchema) return data + // Coerce a legacy plain-object format to an instance through the SAME decode path the write-side + // fix (d6c325e prompt()) uses — idempotent, fills retryCount's decoding default, handles both + // variants. If it doesn't decode (unrecognized shape), leave it: the encoder will surface a clear + // error rather than us silently dropping a field. + const decoded = decodeFormat(format) + return Exit.isSuccess(decoded) ? { ...data, format: decoded.value } : data +} + const info = (row: typeof MessageTable.$inferSelect) => ({ - ...row.data, + ...normalizeFormat(row.data as Record), id: row.id, sessionID: row.session_id, }) as Info diff --git a/packages/deepagent-code/test/session/structured-output.test.ts b/packages/deepagent-code/test/session/structured-output.test.ts index 0a77daef..8e46d393 100644 --- a/packages/deepagent-code/test/session/structured-output.test.ts +++ b/packages/deepagent-code/test/session/structured-output.test.ts @@ -179,6 +179,38 @@ describe("structured-output.encode (sync-event serialization)", () => { const decoded = Schema.decodeUnknownSync(SessionV1.Format)({ type: "text" }) expect(Exit.isSuccess(encodeInfo(userWith(decoded)))).toBe(true) }) + + // Read-path repair: rows persisted BEFORE the d6c325e write fix still hold a plain-object format on + // disk. MessageV2.normalizeFormat coerces them to instances when hydrating, so the messages endpoint + // can encode the response instead of throwing "Expected OutputFormatJsonSchema" and crashing the + // renderer (the app "restart" on clicking such a subagent session). + describe("normalizeFormat (legacy stored-row repair)", () => { + test("coerces a stored plain-object json_schema format so the Info encodes", () => { + const repaired = MessageV2.normalizeFormat(userWith(jsonSchemaFormat)) + expect(Exit.isSuccess(encodeInfo(repaired))).toBe(true) + expect((repaired.format as { retryCount?: number }).retryCount).toBe(2) // decoding default filled + }) + + test("coerces a stored plain-object text format so the Info encodes", () => { + const repaired = MessageV2.normalizeFormat(userWith({ type: "text" })) + expect(Exit.isSuccess(encodeInfo(repaired))).toBe(true) + }) + + test("is idempotent on an already-constructed instance", () => { + const instance = new SessionV1.OutputFormatJsonSchema({ ...jsonSchemaFormat, retryCount: 5 }) + const repaired = MessageV2.normalizeFormat(userWith(instance)) + expect(repaired.format).toBe(instance) // untouched + expect(Exit.isSuccess(encodeInfo(repaired))).toBe(true) + }) + + test("leaves a message with no format untouched", () => { + const noFormat = { ...userWith(undefined) } + delete (noFormat as { format?: unknown }).format + const repaired = MessageV2.normalizeFormat(noFormat) + expect((repaired as { format?: unknown }).format).toBeUndefined() + expect(Exit.isSuccess(encodeInfo(repaired))).toBe(true) + }) + }) }) describe("structured-output.AssistantMessage", () => {