From c0b79979bd1b13facccd763931233af97e90e685 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 01:22:18 +0800 Subject: [PATCH 1/5] =?UTF-8?q?perf(core):=20=E4=BF=AE=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=85=A2=E6=A0=B9=E5=9B=A0=20=E2=80=94=20sessions.jso?= =?UTF-8?q?n=20=E9=98=B2=E6=8A=96=20+=20EventV2=20retention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **根因定位** - `deepagent-code.db` 704MB,其中 `event` 表独占 559MB(75K 行 EventV2 mirror 事件,无任何清理机制,只增不减) - `sessions.json` 2.6MB,每次状态变更(28 处调用点)同步全量写入(含 fsync) + pretty-print 额外 25% 体积;单个 session `userRequest` 字段高达 837KB - `import-snapshot.db` 1GB 一次性导入快照(已单独手动删除) **Fix 1 — sessions.json(packages/core/src/deepagent/session-state.ts)** - 去掉 `JSON.stringify(data, null, 2)` 的 pretty-print,改用紧凑 JSON - saveToDisk 改为防抖模式:多次连续调用只触发一次 `setImmediate` 写入, 不再每次 streaming delta 都阻塞 event loop 做 fsync - `userRequest` 字段超过 10KB 时在序列化前截断,防止单个大粘贴 把整个 sessions.json 膨胀数百 KB **Fix 2 — EventV2 retention(packages/core/src/deepagent/retention-sweeper.ts)** - 原有 RetentionSweeper 只清理 `deepagent_event`(DeepAgentEventBus), 不碰 `event`/`event_sequence`(EventV2 mirror),导致无限增长 - 在 sweepOnce 里为每个 workspace 增加一步:查找 `session` 表中 `time_archived IS NOT NULL AND time_archived < olderThan` 的会话, DELETE 其 `event_sequence` 行(CASCADE 自动清掉对应 `event` 行) - 引用安全:只 prune 已存档且超过 retentionDays(默认30天)的会话, 活跃会话的 event 日志完全不动 - SweepSummary 新增 `deletedEventV2Sequences` 字段用于可观测性 **测试(test/retention-sweeper.test.ts)** - 新增3个 §EventV2-retention 测试: prune 超期已存档会话事件 / 保留最近存档 / 保留未存档活跃会话 - 全部 11 个 sweeper 测试通过,turbo typecheck 15/15 全绿 Co-Authored-By: Claude Opus 4.8 --- .../core/src/deepagent/retention-sweeper.ts | 45 ++++++- packages/core/src/deepagent/session-state.ts | 35 +++++- packages/core/test/retention-sweeper.test.ts | 114 ++++++++++++++++++ 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/packages/core/src/deepagent/retention-sweeper.ts b/packages/core/src/deepagent/retention-sweeper.ts index 6686d9bc..5effac61 100644 --- a/packages/core/src/deepagent/retention-sweeper.ts +++ b/packages/core/src/deepagent/retention-sweeper.ts @@ -1,13 +1,16 @@ export * as RetentionSweeper from "./retention-sweeper" import { Cause, Context, Duration, Effect, Layer, Schedule } from "effect" -import { and, eq, lt } from "drizzle-orm" +import { and, eq, isNotNull, lt } from "drizzle-orm" import { Database } from "../database/database" import { DeepAgentEventBus } from "./deepagent-event-bus" import { DeepAgentEventTable } from "./deepagent-event-sql" import { ApprovalQueueTable } from "./approval-queue-sql" import { WorkspaceConfig } from "./workspace-config" import { AgentPushLogTable } from "../im/push-log-sql" +import { EventSequenceTable } from "../event/sql" +import { SessionTable } from "../session/sql" +import type { WorkspaceV2 } from "../workspace" import * as Log from "../util/log" // V4.0 §A3 保留期 — the periodic RETENTION SWEEPER. For each workspace that has durable events it reads @@ -34,6 +37,9 @@ export interface SweepSummary { readonly deletedEvents: number readonly deletedPushLogs: number readonly deletedApprovals: number + // PERF: EventV2 mirror events pruned for archived sessions. Reported separately because the + // accounting is by aggregate (session), not individual rows, so it has different semantics. + readonly deletedEventV2Sequences: number } export interface Interface { @@ -81,6 +87,7 @@ export const layerWith = (options?: LayerOptions) => let deletedEvents = 0 let deletedPushLogs = 0 let deletedApprovals = 0 + let deletedEventV2Sequences = 0 for (const { workspaceID } of workspaceRows) { const resolved = yield* config.get(workspaceID) @@ -119,6 +126,40 @@ export const layerWith = (options?: LayerOptions) => .all() .pipe(Effect.orDie) deletedApprovals += approvalDeleted.length + + // PERF §EventV2-retention: prune EventV2 mirror events for sessions that have been + // archived for longer than retentionDays. The EventV2 `event` table records every + // streaming delta (message.part.updated, etc.) and has no built-in expiry — it grows + // unboundedly and can reach 500MB+ on long-running deployments. Once a session is + // archived its event history is cold and safe to discard: active sessions keep their + // full event log for live projector replay, but archived ones no longer need it. + // + // Implementation: delete rows from `event_sequence` where the corresponding session row + // (same workspace) has time_archived IS NOT NULL AND time_archived < olderThan. + // The EventTable has ON DELETE CASCADE from event_sequence, so this one DELETE removes + // both the sequence header and all its events in a single statement. + const archivedSessions = yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where( + and( + eq(SessionTable.workspace_id, workspaceID as WorkspaceV2.ID), + isNotNull(SessionTable.time_archived), + lt(SessionTable.time_archived, olderThan), + ), + ) + .all() + .pipe(Effect.orDie) + + for (const { id } of archivedSessions) { + const seqDeleted = yield* db + .delete(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, id)) + .returning({ aggregate_id: EventSequenceTable.aggregate_id }) + .all() + .pipe(Effect.orDie) + deletedEventV2Sequences += seqDeleted.length + } } return { @@ -126,6 +167,7 @@ export const layerWith = (options?: LayerOptions) => deletedEvents, deletedPushLogs, deletedApprovals, + deletedEventV2Sequences, } }) @@ -141,6 +183,7 @@ export const layerWith = (options?: LayerOptions) => deletedEvents: 0, deletedPushLogs: 0, deletedApprovals: 0, + deletedEventV2Sequences: 0, }), ), ), diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index 0ffad154..9f0f520c 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -522,14 +522,45 @@ export const pruneCompleted = (maxAge_ms = 24 * 60 * 60 * 1000): void => { saveToDisk() } +// PERF: maximum bytes stored for the userRequest field. The field is used for display/context +// heuristics only — a 10KB cap prevents one large paste from bloating sessions.json by ~800KB. +const MAX_USER_REQUEST_BYTES = 10_000 + +// PERF: debounce timer handle — collapses many back-to-back saveToDisk() calls (e.g. per streaming +// delta) into one write at the end of the current JS task. setImmediate fires after I/O callbacks +// so the write never races with in-progress DB operations. +let _savePending = false + function saveToDisk() { + if (!stateDir) return + if (_savePending) return + _savePending = true + // Schedule the actual write after the current synchronous work finishes. This collapses N + // rapid-fire calls (tool result ingestion, round-control advances, plan latch flips …) into a + // single atomic write per event-loop turn. + setImmediate(flushToDisk) +} + +function flushToDisk() { + _savePending = false if (!stateDir) return // P2-G: atomic rewrite so a crash can't truncate sessions.json. P2-D: surface write failures to // stderr instead of silently dropping run state (a lost session-state write is a real data loss, // not something to swallow). Still non-throwing: persistence failure must not crash the turn. try { - const data = Object.fromEntries(sessions) - writeFileAtomic(path.join(stateDir, "sessions.json"), JSON.stringify(data, null, 2)) + // Build a serialization-safe snapshot: truncate oversized fields so one large session + // (e.g. a user pasting a multi-hundred-KB document) does not inflate the whole file. + const data: Record = {} + for (const [id, state] of sessions) { + const serialized: Record = { ...state } + if (typeof serialized.userRequest === "string" && serialized.userRequest.length > MAX_USER_REQUEST_BYTES) { + serialized.userRequest = serialized.userRequest.slice(0, MAX_USER_REQUEST_BYTES) + } + data[id] = serialized + } + // Compact JSON (no pretty-print): sessions.json is machine-read only; the 2-space indent + // was adding ~25% bloat to a file that is synchronously read and written on every state change. + writeFileAtomic(path.join(stateDir, "sessions.json"), JSON.stringify(data)) } catch (error) { console.error("deepagent session-state: failed to persist sessions.json", error) } diff --git a/packages/core/test/retention-sweeper.test.ts b/packages/core/test/retention-sweeper.test.ts index 0585c03e..986bb4e0 100644 --- a/packages/core/test/retention-sweeper.test.ts +++ b/packages/core/test/retention-sweeper.test.ts @@ -8,6 +8,8 @@ import { Database } from "@deepagent-code/core/database/database" import { DeepAgentEventDeliveryTable } from "@deepagent-code/core/deepagent/deepagent-event-sql" import { ApprovalQueueTable } from "@deepagent-code/core/deepagent/approval-queue-sql" import { AgentPushLogTable } from "@deepagent-code/core/im/push-log-sql" +import { EventSequenceTable, EventTable } from "@deepagent-code/core/event/sql" +import { SessionTable } from "@deepagent-code/core/session/sql" import { testEffect } from "./lib/effect" // V4.0 §A3 保留期 — the retention sweep + sweeper daemon. Verifies age-based deletion, referential @@ -301,4 +303,116 @@ describe("RetentionSweeper", () => { expect(logs.map((l) => l.id)).toEqual(["push_new"]) }), ) + + // PERF §EventV2-retention: verify that EventV2 mirror events are pruned for archived sessions + // that exceeded retentionDays. The event table has no timestamp of its own; we use the session's + // time_archived as the retention anchor. + + /** Insert a minimal project + session row so the event_sequence FK is satisfiable. */ + const insertArchivedSession = (opts: { sessionID: string; workspaceID: string; timeArchived: number }) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .run( + `INSERT OR IGNORE INTO project (id, worktree, sandboxes, time_created, time_updated) + VALUES ('proj_test', '/tmp', '[]', 0, 0)`, + ) + .pipe(Effect.orDie) + yield* db + .run( + `INSERT OR IGNORE INTO session + (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated, time_archived) + VALUES ('${opts.sessionID}', 'proj_test', '${opts.workspaceID}', 'test', + '/tmp', 'test', '1', 0, 0, ${opts.timeArchived})`, + ) + .pipe(Effect.orDie) + }) + + /** Insert event_sequence + one event row for a session aggregate. */ + const insertEventV2 = (opts: { sessionID: string; eventID: string }) => + Effect.gen(function* () { + const { db } = yield* Database.Service + yield* db + .run(`INSERT OR IGNORE INTO event_sequence (aggregate_id, seq) VALUES ('${opts.sessionID}', 1)`) + .pipe(Effect.orDie) + yield* db + .run( + `INSERT OR IGNORE INTO event (id, aggregate_id, seq, type, data) + VALUES ('${opts.eventID}', '${opts.sessionID}', 1, 'message.part.updated.1', '{}')`, + ) + .pipe(Effect.orDie) + }) + + it.effect("§EventV2-retention: prunes event_sequence + events for archived sessions past retentionDays", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const b = yield* DeepAgentEventBus.Service + const c = yield* WorkspaceConfig.Service + const s = yield* RetentionSweeper.Service + yield* c.set("wrk_1", { retentionDays: 30 }) + // Publish a DeepAgentEvent so the workspace is enumerated by the sweeper. + yield* publishAt(b, 1_000) + + // An archived session whose time_archived is ancient (1 ms epoch = 1970). + yield* insertArchivedSession({ sessionID: "ses_old", workspaceID: "wrk_1", timeArchived: 1_000 }) + yield* insertEventV2({ sessionID: "ses_old", eventID: "evt_old" }) + + setNow(100 * DAY) + const summary = yield* s.sweepOnce() + expect(summary.deletedEventV2Sequences).toBe(1) + + // Both the event_sequence header and the event row (cascaded) must be gone. + const seqs = yield* db.select().from(EventSequenceTable).all().pipe(Effect.orDie) + expect(seqs.find((r) => r.aggregate_id === "ses_old")).toBeUndefined() + const evts = yield* db.select().from(EventTable).all().pipe(Effect.orDie) + expect(evts.find((r) => r.id === "evt_old")).toBeUndefined() + }), + ) + + it.effect("§EventV2-retention: spares event_sequence rows for recently-archived sessions", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const b = yield* DeepAgentEventBus.Service + const c = yield* WorkspaceConfig.Service + const s = yield* RetentionSweeper.Service + yield* c.set("wrk_1", { retentionDays: 30 }) + yield* publishAt(b, 100 * DAY) + + // Archived just now — not yet past retentionDays. + yield* insertArchivedSession({ sessionID: "ses_new", workspaceID: "wrk_1", timeArchived: 100 * DAY }) + yield* insertEventV2({ sessionID: "ses_new", eventID: "evt_new" }) + + setNow(100 * DAY) + const summary = yield* s.sweepOnce() + expect(summary.deletedEventV2Sequences).toBe(0) + + const seqs = yield* db.select().from(EventSequenceTable).all().pipe(Effect.orDie) + expect(seqs.find((r) => r.aggregate_id === "ses_new")).toBeDefined() + }), + ) + + it.effect("§EventV2-retention: spares event_sequence rows for non-archived sessions", () => + Effect.gen(function* () { + const { db } = yield* Database.Service + const b = yield* DeepAgentEventBus.Service + const c = yield* WorkspaceConfig.Service + const s = yield* RetentionSweeper.Service + yield* c.set("wrk_1", { retentionDays: 30 }) + yield* publishAt(b, 1_000) + + // Insert with a placeholder time_archived, then clear it to NULL → active session. + yield* insertArchivedSession({ sessionID: "ses_active", workspaceID: "wrk_1", timeArchived: 0 }) + yield* db + .run(`UPDATE session SET time_archived = NULL WHERE id = 'ses_active'`) + .pipe(Effect.orDie) + yield* insertEventV2({ sessionID: "ses_active", eventID: "evt_active" }) + + setNow(100 * DAY) + const summary = yield* s.sweepOnce() + expect(summary.deletedEventV2Sequences).toBe(0) + + const seqs = yield* db.select().from(EventSequenceTable).all().pipe(Effect.orDie) + expect(seqs.find((r) => r.aggregate_id === "ses_active")).toBeDefined() + }), + ) }) From e6bcf6ab3cc5c83f3a512bbc6f2722c3a1739fd7 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Tue, 21 Jul 2026 10:05:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(provider):=20=E4=BF=AE=E5=A4=8D=20OpenA?= =?UTF-8?q?I/Anthropic=20=E6=A8=A1=E5=9E=8B=E6=8E=A8=E7=90=86=E5=BC=BA?= =?UTF-8?q?=E5=BA=A6=E7=BC=BA=E5=A4=B1=20xhigh=20=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题** 1. OpenAI gpt-5.6-sol 等新模型在走 @ai-sdk/openai-compatible 路径时只显示 low/medium/high(无 xhigh),而 Codex 等 @ai-sdk/openai 路径可以正确显示。 2. Anthropic Fable 5 等新模型不在 anthropicAdaptiveEfforts() 的识别列表里, 导致退回旧的 budgetTokens 模式,只有 high/max 两档。 **根因** - Bug #1 (@ai-sdk/openai-compatible):variants() 里该 case 直接硬写 WIDELY_SUPPORTED_EFFORTS,从不调 openaiCompatibleReasoningEfforts(), 所以无论 api.id 是多新的 gpt-5.x,都只给 low/medium/high。 @ai-sdk/openai(直连)和 @ai-sdk/gateway 路径调用了正确的函数,所以表现一致。 - Bug #2 (Fable 5):anthropicAdaptiveEfforts()/anthropicOpus47OrLater() 只匹配 "opus-X.Y" 和 "sonnet-4.6" 模式,"fable-5" 不含任何上述关键词 → 返回 null → 走旧 { high: {budgetTokens:16000}, max: {budgetTokens:31999} }。 **修复** - Bug #1:@ai-sdk/openai-compatible case 里对 GPT-5 family (GPT5_FAMILY_RE) 的模型 调用 openaiCompatibleReasoningEfforts(),非 GPT-5 模型维持原有 low/medium/high。 - Bug #2:新增 anthropicFable5OrLater() 函数(匹配 "fable-5/fable.5" 等格式, 版本号≥5 即生效),更新 anthropicAdaptiveEfforts() 和 adaptiveOpus 同时识别 Fable 5+。 Fable 5 获得 ["low","medium","high","xhigh","max"] 和 display:"summarized"(与 Opus 4.7+ 一致)。 **测试** - 新增:openai-compatible gpt-5.6-sol 返回 xhigh / 非 gpt-5 仍只有 low/medium/high - 新增:fable-5 / claude-fable-5-20260720 返回 xhigh 含 display:summarized - 260/261 通过(唯一失败是预存在的 azure 测试,与本次改动无关) Co-Authored-By: Claude Opus 4.8 --- .../deepagent-code/src/provider/transform.ts | 27 +++++++++++-- .../test/provider/transform.test.ts | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/deepagent-code/src/provider/transform.ts b/packages/deepagent-code/src/provider/transform.ts index d2a0f1aa..2b44e055 100644 --- a/packages/deepagent-code/src/provider/transform.ts +++ b/packages/deepagent-code/src/provider/transform.ts @@ -591,8 +591,16 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } +// Matches "fable-5", "claude-fable-5", "claude-fable-5-20260720", etc. +// Fable 5 supports the full adaptive thinking effort set (same as Opus 4.7+). +function anthropicFable5OrLater(apiId: string) { + const m = /fable[._-]?(\d+)/i.exec(apiId) + return m !== null && Number(m[1]) >= 5 +} + function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId)) { + // Opus 4.7+ and Fable 5+ support the full adaptive effort set including xhigh. + if (anthropicOpus47OrLater(apiId) || anthropicFable5OrLater(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } if ( @@ -648,7 +656,9 @@ export function variants(model: Provider.Model): Record [effort, { reasoningEffort: effort }])) + } const efforts = [...WIDELY_SUPPORTED_EFFORTS] - if (model.api.id.toLowerCase().includes("deepseek-v4")) { + if (apiId.includes("deepseek-v4")) { efforts.push("max") } return Object.fromEntries(efforts.map((effort) => [effort, { reasoningEffort: effort }])) + } case "@ai-sdk/azure": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure diff --git a/packages/deepagent-code/test/provider/transform.test.ts b/packages/deepagent-code/test/provider/transform.test.ts index 98eaf240..b09ce0bd 100644 --- a/packages/deepagent-code/test/provider/transform.test.ts +++ b/packages/deepagent-code/test/provider/transform.test.ts @@ -2528,6 +2528,37 @@ describe("ProviderTransform.variants", () => { }) }) + // Bug fix: gpt-5.x models on @ai-sdk/openai-compatible endpoints now get the extended + // effort set (xhigh from gpt-5.2+). Previously the adapter always returned low/medium/high. + test("openai-compatible gpt-5.6-sol returns xhigh reasoning effort", () => { + const model = createMockModel({ + id: "cortecs/gpt-5.6-sol", + providerID: "cortecs", + api: { + id: "gpt-5.6-sol", + url: "https://api.cortecs.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["none", "low", "medium", "high", "xhigh"]) + expect(result.xhigh).toEqual({ reasoningEffort: "xhigh" }) + }) + + test("openai-compatible non-gpt5 model still gets only low/medium/high", () => { + const model = createMockModel({ + id: "third-party/some-model", + providerID: "third-party", + api: { + id: "some-model", + url: "https://api.third-party.ai/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high"]) + }) + test("mistral models with reasoning support return variants", () => { const model = createMockModel({ id: "mistral/mistral-small-latest", @@ -3388,6 +3419,14 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + // Bug fix: Fable 5 is a distinct Claude model family that supports the full adaptive + // effort set (including xhigh) with display: "summarized" (like Opus 4.7+). + name: "fable 5", + apiIds: ["claude-fable-5", "claude-fable-5-20260720"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => { From 9989c342a47387ad5ad664c1438b85b679c1204e Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Wed, 22 Jul 2026 20:41:45 +0800 Subject: [PATCH 3/5] Fix/provider reasoning intensity xhigh (#83) ### Issue for this PR Closes # ### Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor / code improvement - [ ] Documentation ### What does this PR do? Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** ### How did you verify your code works? ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ ### Checklist - [ ] I have tested my changes locally - [ ] I have not included unrelated changes in this PR _If you do not follow this template your PR will be automatically rejected._ --------- Co-authored-by: Claude Opus 4.8 --- packages/app/package.json | 2 +- packages/app/src/app.tsx | 21 +- .../deepagent/oversight-dashboard.tsx | 46 ++++- .../src/components/deepagent/oversight.api.ts | 5 +- .../src/components/session/session-header.tsx | 23 ++- packages/app/src/context/layout.tsx | 23 ++- packages/app/src/context/terminal.test.ts | 28 +++ packages/app/src/context/terminal.tsx | 70 +++++-- packages/app/src/i18n/en.ts | 3 +- packages/app/src/i18n/zh.ts | 3 +- packages/app/src/pages/layout.tsx | 4 +- packages/app/src/pages/session.tsx | 60 +++--- .../composer/session-composer-region.tsx | 2 + .../composer/session-followup-dock.tsx | 10 + .../pages/session/message-timeline.data.ts | 4 +- .../src/pages/session/session-side-panel.tsx | 94 ++++----- .../pages/session/side-panel-subagents.tsx | 147 +++++++++++--- .../src/pages/session/side-panel-terminal.tsx | 141 ++++++++------ .../app/src/pages/session/terminal-panel.tsx | 65 +++---- .../app/src/pages/session/terminal-view.tsx | 38 ++-- .../pages/session/use-session-commands.tsx | 60 +++--- packages/core/src/database/database.ts | 3 +- .../core/src/deepagent/deepagent-event-bus.ts | 10 +- packages/deepagent-code/src/cli/cmd/mcp.ts | 16 +- .../src/panel/panel-convene-consumer.ts | 6 +- .../deepagent-code/src/provider/transform.ts | 20 +- .../instance/httpapi/handlers/session.ts | 7 +- .../src/session/event-dispatcher.ts | 1 + .../src/session/goal-tick-consumer.ts | 6 +- packages/deepagent-code/src/session/prompt.ts | 105 +++++++++- .../src/session/supervisor-notifier.ts | 6 +- .../src/session/v4-event-runtime.ts | 28 ++- packages/deepagent-code/src/tool/registry.ts | 4 + packages/deepagent-code/src/tool/task.ts | 78 ++++++-- packages/deepagent-code/src/tool/task_read.ts | 182 ++++++++++++++++++ .../deepagent-code/src/tool/task_status.ts | 99 ++++++++-- .../src/wiki/event-driven-archiver.ts | 6 +- .../deepagent-code/test/cli/mcp-add.test.ts | 17 +- .../test/provider/transform.test.ts | 29 ++- .../test/session/structured-output.test.ts | 70 +++++++ .../test/tool/task-status.test.ts | 79 +++----- .../test/tool/task-takeover.test.ts | 10 +- packages/desktop/package.json | 2 +- packages/desktop/src/main/index.ts | 8 +- packages/desktop/src/main/sidecar.ts | 10 +- 45 files changed, 1209 insertions(+), 442 deletions(-) create mode 100644 packages/deepagent-code/src/tool/task_read.ts diff --git a/packages/app/package.json b/packages/app/package.json index 625b3083..3d4e8646 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@deepagent-code/app", - "version": "1.4.2", + "version": "1.4.3", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 1568b3df..7acbad26 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -9,7 +9,7 @@ import { Font } from "@deepagent-code/ui/font" import { Splash } from "@deepagent-code/ui/logo" import { ThemeProvider } from "@deepagent-code/ui/theme/context" import { MetaProvider } from "@solidjs/meta" -import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" +import { type BaseRouterProps, Navigate, Route, Router, useLocation, useNavigate } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { Effect } from "effect" import { @@ -45,7 +45,7 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" -import { TabsProvider } from "@/context/tabs" +import { TabsProvider, useTabs } from "@/context/tabs" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" @@ -143,6 +143,23 @@ function SessionProviders(props: ParentProps) { } function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + const tabs = useTabs() + const navigate = useNavigate() + const location = useLocation() + + // On startup: as soon as persisted tabs are loaded (local disk, no HTTP calls), + // navigate to the last visited project's session list. This matches opencode's approach — + // use local/cached data to drive the first frame, let the server data fill in asynchronously. + // We navigate to /:dir/session (no specific session ID) so autoselecting can handle + // the final session selection independently without conflicting navigation. + createEffect(() => { + if (!tabs.ready()) return + if (location.pathname !== "/") return + const first = tabs.store[0] + if (!first) return + navigate(`/${first.dirBase64}/session`, { replace: true }) + }) + return ( {/*}>*/} diff --git a/packages/app/src/components/deepagent/oversight-dashboard.tsx b/packages/app/src/components/deepagent/oversight-dashboard.tsx index 58ec0cd5..09f72cd3 100644 --- a/packages/app/src/components/deepagent/oversight-dashboard.tsx +++ b/packages/app/src/components/deepagent/oversight-dashboard.tsx @@ -55,7 +55,16 @@ function MetricCard(props: { label: string; value: string; tone?: "ok" | "warn" ) } -export const OversightDashboard: Component = () => { +// Phase 2: OversightDashboard now accepts a selected subagent session ID so takeover/rollback +// automatically target the chosen session, and trace nodes can reverse-select a subagent. +export type OversightDashboardProps = { + /** When provided, takeover and rollback default to this session ID. */ + selectedSessionID?: string + /** Called when a trace node with a sessionID is clicked so the caller can select that subagent. */ + onSessionSelect?: (sessionID: string) => void +} + +export const OversightDashboard: Component = (props) => { const sdk = useSDK() const language = useLanguage() const client = () => sdk.client as unknown as OversightClient @@ -118,7 +127,9 @@ export const OversightDashboard: Component = () => { if (!reason) return setTakeoverBusy(true) setTakeoverNote(null) - const result = await recordHumanTakeover(client(), { reason }) + // Phase 2: automatically pass the selected session ID when present. + const sessionID = props.selectedSessionID + const result = await recordHumanTakeover(client(), { reason, ...(sessionID ? { sessionID } : {}) }) setTakeoverBusy(false) if (result.ok) { setTakeoverReason("") @@ -133,13 +144,17 @@ export const OversightDashboard: Component = () => { } // ── §D2 rollback (P4.4) ───────────────────────────────────────────────────────── - const [rollbackSession, setRollbackSession] = createSignal("") + // Phase 2: rollback input is pre-seeded with the selected session ID. Users may override it. const [rollbackReason, setRollbackReason] = createSignal("") const [rollbackBusy, setRollbackBusy] = createSignal(false) const [rollbackNote, setRollbackNote] = createSignal(null) + // The effective session ID for rollback: the input field overrides; falls back to selectedSessionID. + const [rollbackSessionOverride, setRollbackSessionOverride] = createSignal("") + const effectiveRollbackSession = () => rollbackSessionOverride() || props.selectedSessionID || "" + const submitRollback = async () => { - const sessionID = rollbackSession().trim() + const sessionID = effectiveRollbackSession().trim() if (!sessionID) return const reason = rollbackReason().trim() setRollbackBusy(true) @@ -147,7 +162,7 @@ export const OversightDashboard: Component = () => { const result = await recordRollback(client(), { sessionID, ...(reason ? { reason } : {}) }) setRollbackBusy(false) if (result.ok) { - setRollbackSession("") + setRollbackSessionOverride("") setRollbackReason("") setRollbackNote( result.record?.outcome === "noop" @@ -328,6 +343,17 @@ export const OversightDashboard: Component = () => {
{fmtTime(node.createdAt)}
+ {/* Phase 2: if this node is tied to a specific subagent session, offer + a reverse-select link so the user can jump to that subagent row. */} + + + )} @@ -372,11 +398,13 @@ export const OversightDashboard: Component = () => {

{language.t("oversight.rollback.title")}

{language.t("oversight.rollback.description")}

+ {/* Phase 2: when a subagent is selected the placeholder is replaced by the session ID. + Typing in this field overrides the pre-selected session. */} setRollbackSession(e.currentTarget.value)} + placeholder={props.selectedSessionID || language.t("oversight.rollback.sessionPlaceholder")} + value={rollbackSessionOverride()} + onInput={(e) => setRollbackSessionOverride(e.currentTarget.value)} />