From d10b5160d313a78013f5a262939ca6a7b238c3fd Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 08:59:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(v3.9):=20expose=20Expert=20Panel=20+?= =?UTF-8?q?=20Goal=20Loop=20=E2=80=94=20backend=20slice=20(P1=E2=80=93P3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the two built-but-unexposed V3.9 engines toward user invocation. Before this, runPanel had only the goal-loop grader as a caller and makeGoalLoop/makeGoalLoopWiring had zero production callers. P1 — state foundation: - settings: expertPanelDefault (global default arm-state for the panel) - session-state: panelArmed (per-conversation toggle) + activeGoal pointer (goalId/planDocId/phase/startedAt, phase adds "paused"); accessors + normalizeState backfill P2 — Expert Panel standalone entry: - panel/panelist-runner.ts: extracted the shared panelist logic so the panel and the goal loop convene identically; upgraded to load the real per-lens prompts (panel/*.txt) instead of a generic inline prompt - panel/consult.ts: consultPanel(), standalone convener (default/security policies), decoupled from the goal loop - goal-loop-wiring.ts: refactored to reuse the shared runner P3 — Goal Loop driver: - session/goal-driver.ts: materializePlanDoc (in-memory plan → the graded store doc, idempotent INV-4), startGoal, runToCompletion (background tick loop with cooperative pause/stop + status ports) Tests: session-state slots (9), settings (+2), consult (4), goal-driver (6). All typecheck clean; existing panel/goal-loop suites green. Co-Authored-By: Claude Opus 4.8 --- packages/core/src/deepagent/session-state.ts | 69 +++++++ .../session-state-panel-goal.test.ts | 105 +++++++++++ packages/deepagent-code/src/panel/consult.ts | 79 ++++++++ .../src/panel/panelist-runner.ts | 108 +++++++++++ .../deepagent-code/src/session/goal-driver.ts | 169 +++++++++++++++++ .../src/session/goal-loop-wiring.ts | 106 +++-------- packages/deepagent-code/src/settings/store.ts | 6 + .../deepagent-code/test/panel/consult.test.ts | 110 +++++++++++ .../test/session/goal-driver.test.ts | 171 ++++++++++++++++++ .../test/settings/store.test.ts | 19 ++ 10 files changed, 862 insertions(+), 80 deletions(-) create mode 100644 packages/core/test/deepagent/session-state-panel-goal.test.ts create mode 100644 packages/deepagent-code/src/panel/consult.ts create mode 100644 packages/deepagent-code/src/panel/panelist-runner.ts create mode 100644 packages/deepagent-code/src/session/goal-driver.ts create mode 100644 packages/deepagent-code/test/panel/consult.test.ts create mode 100644 packages/deepagent-code/test/session/goal-driver.test.ts diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index 40a921a6..a52cf643 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -54,10 +54,33 @@ export type SessionRunState = { // update. The SEMANTIC primary trigger for the progress nudge ("a step probably just finished"). // Reset with the counter on a real status change. validationPassedSinceReport: boolean + // V3.9 §C: whether this conversation has the Expert Panel "armed". Seeded from the global + // `expertPanelDefault` setting when the session is created; toggled per-conversation from the chat + // dialog. Armed means the user may convene a panel (button press) and — when a goal loop is running + // — the loop may convene the panel at high-risk decision points. Quiet until woken (§C activation). + panelArmed: boolean + // V3.9 §D: a lightweight pointer to the goal currently driven for this session (the authoritative + // loop state lives in the DocumentStore run_context doc — this is only enough to find/resume it and + // reflect its phase in the UI). Null when no goal is running. + activeGoal: ActiveGoalPointer | null createdAt: string completedAt: string | null } +// V3.9 §D: session-state pointer to a running goal. The GoalLoop's GoalStatus (persisted in the +// DocumentStore) is the source of truth for ledger/gaps; this pointer just lets the server locate the +// goal, drive its ticks, and lets the UI show a phase without re-reading the store on every render. +export type ActiveGoalPointer = { + readonly goalId: string + readonly planDocId: string + readonly phase: GoalPointerPhase + readonly startedAt: string +} + +// Mirrors goal-loop.ts GoalPhase plus "paused" (a UI/driver-level state that suspends ticking without +// tearing down the loop — the core phase stays "running" and resumes on unpause). +export type GoalPointerPhase = "running" | "paused" | "done" | "needs_human" | "rolled_back" | "stopped" + let stateDir: string | null = null const sessions = new Map() @@ -86,6 +109,8 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState plan: null, mutationsSinceReport: 0, validationPassedSinceReport: false, + panelArmed: false, + activeGoal: null, createdAt: new Date().toISOString(), completedAt: null, } @@ -171,6 +196,47 @@ export const setPlan = (sessionId: string, plan: PlanDoc): void => { export const getPlan = (sessionId: string): PlanDoc | null => sessions.get(sessionId)?.plan ?? null +// V3.9 §C — Expert Panel per-session arming. +// Seed the armed flag once at session creation from the global default (only if the session exists and +// has not been explicitly toggled). Returns the effective armed state. +export const seedPanelArmed = (sessionId: string, globalDefault: boolean): boolean => { + const state = sessions.get(sessionId) + if (!state) return globalDefault + state.panelArmed = globalDefault + saveToDisk() + return state.panelArmed +} + +export const setPanelArmed = (sessionId: string, armed: boolean): void => { + const state = sessions.get(sessionId) + if (!state) return + state.panelArmed = armed + saveToDisk() +} + +export const isPanelArmed = (sessionId: string): boolean => sessions.get(sessionId)?.panelArmed ?? false + +// V3.9 §D — active-goal pointer. The GoalLoop status doc in the DocumentStore is authoritative; this +// pointer is the session-local index the server/UI use to find and reflect the running goal. +export const setActiveGoal = (sessionId: string, pointer: ActiveGoalPointer | null): void => { + const state = sessions.get(sessionId) + if (!state) return + state.activeGoal = pointer + saveToDisk() +} + +export const getActiveGoal = (sessionId: string): ActiveGoalPointer | null => + sessions.get(sessionId)?.activeGoal ?? null + +// Patch just the phase of the active-goal pointer (driver transitions running↔paused, terminal states). +// No-op when there is no active goal (a stale transition after stop must not resurrect a pointer). +export const setActiveGoalPhase = (sessionId: string, phase: GoalPointerPhase): void => { + const state = sessions.get(sessionId) + if (!state || state.activeGoal == null) return + state.activeGoal = { ...state.activeGoal, phase } + saveToDisk() +} + // U10: count one mutating tool call toward the progress-nudge budget. Called after a mutating tool // executes. No-op when there is no plan (the nudge only applies once the model has a plan to report // against). @@ -290,6 +356,9 @@ function normalizeState(state: SessionRunState): SessionRunState { // Backfill: sessions persisted before U10 have no counter on disk. mutationsSinceReport: state.mutationsSinceReport ?? 0, validationPassedSinceReport: state.validationPassedSinceReport ?? false, + // Backfill: sessions persisted before V3.9 §C/§D have neither slot on disk. + panelArmed: state.panelArmed ?? false, + activeGoal: state.activeGoal ?? null, } sessions.set(state.sessionId, normalized) return normalized diff --git a/packages/core/test/deepagent/session-state-panel-goal.test.ts b/packages/core/test/deepagent/session-state-panel-goal.test.ts new file mode 100644 index 00000000..ad81eeba --- /dev/null +++ b/packages/core/test/deepagent/session-state-panel-goal.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import * as SessionState from "../../src/deepagent/session-state" + +// V3.9 §C/§D: the per-session Expert Panel arming flag and the active-goal pointer. These are the +// server/UI seams for the panel toggle and the goal status bar. Verifies default-off, explicit toggle, +// global-default seeding, and that the goal pointer phase patch is a no-op once the goal is cleared. + +describe("session-state panel arming (§C)", () => { + beforeEach(() => { + SessionState.configure(mkdtempSync(path.join(tmpdir(), "panel-arm-"))) + }) + + test("a new session starts disarmed", () => { + SessionState.getOrCreate("panel-s1", "high") + expect(SessionState.isPanelArmed("panel-s1")).toBe(false) + }) + + test("seedPanelArmed applies the global default", () => { + SessionState.getOrCreate("panel-s2", "high") + expect(SessionState.seedPanelArmed("panel-s2", true)).toBe(true) + expect(SessionState.isPanelArmed("panel-s2")).toBe(true) + }) + + test("setPanelArmed overrides the seeded default per conversation", () => { + SessionState.getOrCreate("panel-s3", "high") + SessionState.seedPanelArmed("panel-s3", true) + SessionState.setPanelArmed("panel-s3", false) + expect(SessionState.isPanelArmed("panel-s3")).toBe(false) + }) + + test("arming an unknown session is a no-op, isPanelArmed reads false", () => { + expect(SessionState.isPanelArmed("panel-missing")).toBe(false) + SessionState.setPanelArmed("panel-missing", true) // must not throw / create state + expect(SessionState.isPanelArmed("panel-missing")).toBe(false) + }) +}) + +describe("session-state active-goal pointer (§D)", () => { + beforeEach(() => { + SessionState.configure(mkdtempSync(path.join(tmpdir(), "goal-ptr-"))) + }) + + test("a new session has no active goal", () => { + SessionState.getOrCreate("goal-s1", "high") + expect(SessionState.getActiveGoal("goal-s1")).toBeNull() + }) + + test("setActiveGoal stores the pointer, getActiveGoal returns it", () => { + SessionState.getOrCreate("goal-s2", "high") + SessionState.setActiveGoal("goal-s2", { + goalId: "goal_abc", + planDocId: "plan_1", + phase: "running", + startedAt: new Date().toISOString(), + }) + const ptr = SessionState.getActiveGoal("goal-s2") + expect(ptr?.goalId).toBe("goal_abc") + expect(ptr?.phase).toBe("running") + }) + + test("setActiveGoalPhase patches only the phase", () => { + SessionState.getOrCreate("goal-s3", "high") + SessionState.setActiveGoal("goal-s3", { + goalId: "goal_abc", + planDocId: "plan_1", + phase: "running", + startedAt: "2026-07-09T00:00:00.000Z", + }) + SessionState.setActiveGoalPhase("goal-s3", "paused") + const ptr = SessionState.getActiveGoal("goal-s3") + expect(ptr?.phase).toBe("paused") + expect(ptr?.startedAt).toBe("2026-07-09T00:00:00.000Z") // untouched + }) + + test("setActiveGoalPhase is a no-op once the goal is cleared (no resurrection)", () => { + SessionState.getOrCreate("goal-s4", "high") + SessionState.setActiveGoal("goal-s4", { + goalId: "goal_abc", + planDocId: "plan_1", + phase: "running", + startedAt: new Date().toISOString(), + }) + SessionState.setActiveGoal("goal-s4", null) + SessionState.setActiveGoalPhase("goal-s4", "done") + expect(SessionState.getActiveGoal("goal-s4")).toBeNull() + }) + + test("both slots survive normalize (disk backfill) for a pre-V3.9 session", () => { + SessionState.getOrCreate("goal-s5", "high") + SessionState.setPanelArmed("goal-s5", true) + SessionState.setActiveGoal("goal-s5", { + goalId: "goal_x", + planDocId: "plan_x", + phase: "running", + startedAt: new Date().toISOString(), + }) + // getOrCreate on an existing session runs normalizeState — the slots must be preserved. + SessionState.getOrCreate("goal-s5", "high") + expect(SessionState.isPanelArmed("goal-s5")).toBe(true) + expect(SessionState.getActiveGoal("goal-s5")?.goalId).toBe("goal_x") + }) +}) diff --git a/packages/deepagent-code/src/panel/consult.ts b/packages/deepagent-code/src/panel/consult.ts new file mode 100644 index 00000000..9da209c8 --- /dev/null +++ b/packages/deepagent-code/src/panel/consult.ts @@ -0,0 +1,79 @@ +import { Effect } from "effect" +import { runPanel, type PanelArchiver } from "./orchestrator" +import { buildPanelistRunner, type PanelTurnRunner } from "./panelist-runner" +import { + PANEL_LENSES, + DEFAULT_QUORUM_POLICY, + SECURITY_AUDIT_QUORUM_POLICY, + type PanelLens, + type PanelVerdict, + type QuorumPolicy, +} from "../agent/schema/panel" + +/** + * V3.9 §C — the STANDALONE Expert Panel entry (会诊), decoupled from the Goal Loop. + * + * The panel engine (`orchestrator.runPanel` + deterministic `arbiter`) is convened here directly by a + * user action (the chat-dialog panel button) instead of only as a goal-loop grader. The flow matches + * §C.4: freeze the question → fan out the lens panelists (mutually invisible) → optional debate rounds + * → deterministic arbitration → `PanelVerdict`. Activation semantics (§C, per the product spec): + * - The panel is "armed" per conversation (seeded from the global `expertPanelDefault` setting). + * - Arming mid-conversation (button OFF→ON) convenes ONE panel on the CURRENT context immediately, + * then goes quiet ("等待唤醒") — no per-turn re-runs. + * - A subsequent button press while armed re-convenes on demand. + * The server route owns the arm/disarm state (session-state.panelArmed) and the "run now" trigger; this + * module owns the convening itself so it is unit-testable without HTTP. + */ + +export type ConsultInput = { + /** The frozen question the panel answers (built from the current conversation context by the caller). */ + readonly question: string + /** Code references (file / file:line) the panelists ground findings in. May be empty. */ + readonly codeRefs: readonly string[] + /** Parent (conversation) session id — the TaskConcurrency semaphore key + panelist child parent. */ + readonly parentSessionID: string + /** The lens set to convene. Defaults to all five core lenses (§C.3). Deduped + capped by runPanel. */ + readonly lenses?: readonly PanelLens[] + /** Debate-round cap R (≥ 1). Round 1 always runs; 2..R are debate. Defaults to 1 (single round). */ + readonly maxRounds?: number + /** + * Which quorum policy governs arbitration. "default" = weighted majority / conservative-on-tie; + * "security" = any block blocks (§C.6 安全审计). Defaults to "default". A caller may also pass a full + * QuorumPolicy object for a custom event type. + */ + readonly policy?: "default" | "security" | QuorumPolicy +} + +export type ConsultDeps = { + /** The real subagent turn runner (a lens-prompted reviewer child session); tests inject a stub. */ + readonly runTurn: PanelTurnRunner + /** Optional archiver so each opinion is projected into the Document Graph (§B Wiki). */ + readonly archive?: PanelArchiver +} + +const resolvePolicy = (policy: ConsultInput["policy"]): QuorumPolicy => { + if (policy == null || policy === "default") return DEFAULT_QUORUM_POLICY + if (policy === "security") return SECURITY_AUDIT_QUORUM_POLICY + return policy +} + +/** + * Convene the Expert Panel on a frozen question and return the deterministic `PanelVerdict`. Never + * throws: a panel that cannot reach quorum (all panelists absent / below minQuorum) returns a + * `needs_human` verdict via the Arbiter, never a silent approve. + */ +export const consultPanel = (input: ConsultInput, deps: ConsultDeps): Effect.Effect => + runPanel({ + question: { + question: input.question, + codeRefs: input.codeRefs, + lenses: input.lenses ?? PANEL_LENSES, + maxRounds: input.maxRounds ?? 1, + policy: resolvePolicy(input.policy), + }, + runPanelist: buildPanelistRunner(deps.runTurn), + ...(deps.archive ? { archive: deps.archive } : {}), + parentSessionID: input.parentSessionID, + }) + +export * as PanelConsult from "./consult" diff --git a/packages/deepagent-code/src/panel/panelist-runner.ts b/packages/deepagent-code/src/panel/panelist-runner.ts new file mode 100644 index 00000000..8ab9a75c --- /dev/null +++ b/packages/deepagent-code/src/panel/panelist-runner.ts @@ -0,0 +1,108 @@ +import { Effect } from "effect" +import type { PanelistRunInput, PanelistRunner } from "./orchestrator" +import { type PanelLens, type PanelOpinion } from "../agent/schema/panel" +import { ReviewResult } from "../agent/schema/orchestration" +import { ToolJsonSchema } from "../tool/json-schema" +import PROMPT_CORRECTNESS from "../agent/prompt/panel/correctness.txt" +import PROMPT_SECURITY from "../agent/prompt/panel/security.txt" +import PROMPT_PERFORMANCE from "../agent/prompt/panel/performance.txt" +import PROMPT_ARCHITECTURE from "../agent/prompt/panel/architecture.txt" +import PROMPT_REPRO from "../agent/prompt/panel/repro.txt" + +/** + * V3.9 §C — the shared PANELIST RUNNER, extracted so BOTH the standalone Expert Panel entry + * (`panel/consult.ts`) and the Goal Loop's `panel_approves` grader (`session/goal-loop-wiring.ts`) + * convene panelists identically. A panelist is a lens-specialized reviewer subagent: it is driven with + * that lens's differentiated system prompt (`agent/prompt/panel/.txt`, §C.3 — this is what makes + * the panel genuinely differentiated rather than reviewer clones) and its structured `ReviewResult` + * becomes a `PanelOpinion`. An absent / failed / malformed turn ⇒ `null` (§C.8 缺席, never a fabricated + * opinion). + * + * The turn itself is injected as a `PanelTurnRunner` port so the caller supplies the real + * child-session subagent runner (production) or a deterministic stub (tests) — this module owns ONLY + * the panelist prompt + opinion mapping, never session creation. + */ + +/** The differentiated per-lens system-prompt guidance appended to each panelist turn. */ +const LENS_PROMPT: Record = { + correctness: PROMPT_CORRECTNESS, + security: PROMPT_SECURITY, + performance: PROMPT_PERFORMANCE, + architecture: PROMPT_ARCHITECTURE, + repro: PROMPT_REPRO, +} + +/** JSON Schema forcing a structured ReviewResult final turn (shared with the reviewer_clean gate). */ +export const REVIEWER_SCHEMA = ToolJsonSchema.fromSchema(ReviewResult) as unknown as Record + +/** + * The minimal turn seam a panelist needs. Mirrors the goal-loop `SubagentTurnRunner` shape but is + * declared here so this module does not depend on the goal-loop wiring (avoids a cycle: goal-loop-wiring + * imports THIS). `structured` is the parsed schema output; absent when the turn produced none. + */ +export type PanelTurnRunner = (input: { + readonly agentType: string + readonly prompt: string + readonly outputSchema?: Record +}) => Effect.Effect<{ readonly structured: unknown | undefined }> + +/** Parse a reviewer turn's structured output into a ReviewResult; malformed/absent ⇒ null (fail-closed). */ +export const parseReviewResult = (structured: unknown): ReviewResult | null => { + if (structured == null) return null + try { + return ReviewResult.make(structured as ReviewResult) + } catch { + // structured may already be a decoded object from a prior JSON round-trip; accept a shape-check. + const anyVal = structured as { findings?: unknown; verdict?: unknown } + if (Array.isArray(anyVal.findings) && typeof anyVal.verdict === "string") return anyVal as ReviewResult + return null + } +} + +/** Map a reviewer turn output → the panel's PanelOpinion shape. */ +export const opinionFromReview = (lens: PanelLens, review: ReviewResult | null): PanelOpinion | null => { + if (review == null) return null + // Confidence = max finding confidence (approve with no findings ⇒ full confidence in "approve"). + const confidence = + review.findings.length === 0 + ? 1 + : review.findings.reduce((m, f) => Math.max(m, Number.isFinite(f.confidence) ? f.confidence : 0), 0) + return { lens, verdict: review.verdict, findings: review.findings, confidence } +} + +/** + * Render the panelist turn prompt: the lens's differentiated system guidance, the frozen question, the + * code refs to ground findings in, and (in debate rounds) the anonymized peer verdicts. §C.8: peers are + * anonymized — no lens/seat identity crosses the boundary, only verdict/confidence. + */ +export const renderPanelistPrompt = (input: PanelistRunInput): string => { + const parts = [ + LENS_PROMPT[input.spec.lens], + "", + `Question (frozen): ${input.question.question}`, + input.question.codeRefs.length > 0 ? `Code references: ${input.question.codeRefs.join(", ")}` : "", + ].filter((s) => s.length > 0) + if (input.round > 1 && input.peers.length > 0) { + parts.push( + `Anonymized peer verdicts from the previous round: ${input.peers + .map((p) => `${p.verdict}(${p.confidence.toFixed(2)})`) + .join(", ")}. You may revise, but justify any change with reproducible evidence.`, + ) + } + return parts.join("\n") +} + +/** + * Build a `PanelistRunner` over an injected turn runner. Each seat is a lens-prompted reviewer subagent + * whose structured ReviewResult becomes a PanelOpinion. Absent / failed / malformed ⇒ null (§C.8). + */ +export const buildPanelistRunner = (runTurn: PanelTurnRunner): PanelistRunner => + (input: PanelistRunInput) => + runTurn({ + agentType: "reviewer", + prompt: renderPanelistPrompt(input), + outputSchema: REVIEWER_SCHEMA, + }).pipe( + Effect.map((turn) => opinionFromReview(input.spec.lens, parseReviewResult(turn.structured))), + Effect.catchCause(() => Effect.succeed(null as PanelOpinion | null)), + ) diff --git a/packages/deepagent-code/src/session/goal-driver.ts b/packages/deepagent-code/src/session/goal-driver.ts new file mode 100644 index 00000000..58296680 --- /dev/null +++ b/packages/deepagent-code/src/session/goal-driver.ts @@ -0,0 +1,169 @@ +import { Effect } from "effect" +import type { DocumentStore } from "@deepagent-code/core/deepagent/document-store" +import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" +import { + makeGoalLoop, + type ControllerDeps, + type GoalHandle, + type GoalSpec, + type GoalStatus, + type TickOutcome, + type CompletionCriterion, + type GoalLimits, +} from "@deepagent-code/core/deepagent/goal-loop" + +/** + * V3.9 §D — the GOAL DRIVER. `goal-loop.ts` (core) is a pure tick state machine; `goal-loop-wiring.ts` + * assembles its ports (`ControllerDeps`). Neither STARTS a goal or DRIVES the ticks — that missing + * production seam is here. The driver: + * 1. MATERIALIZES the goal's plan into a `type:"plan"` DocumentStore doc (`materializePlanDoc`) — the + * loop reads a store doc as the goal carrier, while the `plan` tool writes in-memory session-state + * (two disconnected stores). Per the product decision, a goal is started FROM a plan the user + * produced in plan mode: this snapshots that in-memory plan into the store doc the loop grades. + * 2. STARTS the loop (`makeGoalLoop(deps).start(spec)`), returning a GoalHandle. + * 3. DRIVES ticks in the background until a terminal outcome (`runToCompletion`), publishing status via + * injected ports (so the server wires the real GoalEvent + session-state pointer, and tests assert + * the loop mechanics with a stub). Pause is cooperative: the driver checks `shouldPause()` before + * each tick and suspends without tearing down the loop (the persisted run_context doc means an + * unpause resumes exactly). + * + * The driver never elevates permission and never executes tools itself — every step runs through the + * injected executor (a goal-worker child-session turn), exactly as the loop contract requires (§D.6). + */ + +/** A single completion criterion the user's goal must satisfy (mirrors the core union, re-exported for callers). */ +export type GoalCriterion = CompletionCriterion + +export type MaterializePlanInput = { + readonly store: DocumentStore + readonly sessionId: string + /** The in-memory plan (from the `plan` tool's session-state) to snapshot into the store. */ + readonly plan: PlanDoc +} + +/** planScope(sessionId) = "run:" — every goal doc co-locates under the session's run scope. */ +const planScope = (sessionId: string): string => `run:${sessionId}` + +/** + * Snapshot an in-memory PlanDoc into a `type:"plan"` store doc (the goal carrier). Idempotent per + * session: `upsert` keyed on the stable `plan-` slug returns the same doc id and is a no-op + * when the body is unchanged (INV-4), so re-materializing the same plan does not bump the version. + * Returns the plan doc id the GoalSpec references. + */ +export const materializePlanDoc = (input: MaterializePlanInput): string => { + const doc = input.store.upsert({ + type: "plan", + scope: planScope(input.sessionId), + description: `goal plan ${input.sessionId}`, + idSlug: `plan-${input.sessionId}`, + body: JSON.stringify(input.plan), + provenance: { source: "model", run_ref: planScope(input.sessionId) }, + }) + return doc.id +} + +/** Ports the driver publishes progress through — the server wires real ones; tests inject stubs. */ +export type GoalDriverPorts = { + /** Called with each new status after a tick (server → GoalEvent + session-state pointer). Best-effort. */ + readonly onStatus: (status: GoalStatus) => Effect.Effect + /** + * Cooperative pause check evaluated BEFORE each tick. When true, the driver suspends: it stops ticking + * and returns control WITHOUT marking the loop terminal (the persisted state resumes on the next + * runToCompletion). The server backs this with the session-state pointer phase === "paused". + */ + readonly shouldPause: () => Effect.Effect + /** True once the user requested a hard stop — the driver stops the loop and exits. */ + readonly shouldStop: () => Effect.Effect +} + +/** No-op ports (fire-and-forget usage / tests that only care about the terminal outcome). */ +export const noopPorts: GoalDriverPorts = { + onStatus: () => Effect.void, + shouldPause: () => Effect.succeed(false), + shouldStop: () => Effect.succeed(false), +} + +export type StartGoalInput = { + readonly deps: ControllerDeps + readonly planDocId: string + readonly criteria: readonly GoalCriterion[] + readonly limits: GoalLimits + readonly stallThreshold?: number +} + +/** Build the GoalSpec + start the loop. Surfaces the core InvalidGoalError to the caller (route → 400). */ +export const startGoal = (input: StartGoalInput) => { + const loop = makeGoalLoop(input.deps) + const spec: GoalSpec = { + planDocId: input.planDocId, + criteria: input.criteria, + limits: input.limits, + stallThreshold: input.stallThreshold ?? 3, + } + return Effect.map(loop.start(spec), (handle) => ({ loop, handle })) +} + +/** Whether an outcome is terminal (the loop is done / escalated / rolled back — not "continue"). */ +const isTerminalOutcome = (outcome: TickOutcome): boolean => outcome !== "continue" + +export type RunToCompletionInput = { + readonly deps: ControllerDeps + readonly handle: GoalHandle + readonly ports?: GoalDriverPorts + /** Hard cap on driver iterations as a belt-and-braces guard above the loop's own maxTicks. */ + readonly maxIterations?: number +} + +/** + * Drive ticks until a terminal outcome, a pause, or a hard stop. Returns the last outcome (or "continue" + * when it exited due to pause — the caller learns pause via the ports). Never throws: the loop's tick + * lives on `never`, and the driver wraps status/port calls so a defect cannot crash the background task. + */ +export const runToCompletion = (input: RunToCompletionInput): Effect.Effect => + Effect.gen(function* () { + const loop = makeGoalLoop(input.deps) + const ports = input.ports ?? noopPorts + const maxIterations = input.maxIterations ?? 10_000 + let last: TickOutcome = "continue" + + for (let i = 0; i < maxIterations; i++) { + if (yield* safeBool(ports.shouldStop())) { + yield* safe(loop.stop(input.handle)) + return "needs_human" + } + if (yield* safeBool(ports.shouldPause())) { + // Cooperative suspend: do NOT tick, do NOT mark terminal — the persisted state resumes later. + return "continue" + } + + last = yield* loop.tick(input.handle) + const status = yield* safeStatus(loop, input.handle) + if (status) yield* safe(ports.onStatus(status)) + + if (isTerminalOutcome(last)) return last + } + // Exhausted the driver guard without a terminal outcome — treat as needs_human (never loop forever). + return "needs_human" + }) + +// The loop's tick/status/stop already live on `never`, but the injected ports do not — wrap them so a +// port defect degrades to a safe default and never crashes the background driver. +const safe = (effect: Effect.Effect): Effect.Effect => + effect.pipe( + Effect.asVoid, + Effect.catchCause(() => Effect.void), + ) + +const safeBool = (effect: Effect.Effect): Effect.Effect => + effect.pipe(Effect.catchCause(() => Effect.succeed(false))) + +const safeStatus = ( + loop: ReturnType, + handle: GoalHandle, +): Effect.Effect => + loop.status(handle).pipe(Effect.catchCause(() => Effect.succeed(null as GoalStatus | null))) + +// Re-export the materialize helper's scope so the server route can co-locate other goal docs. +export { planScope as goalPlanScope } + +export * as GoalDriver from "./goal-driver" diff --git a/packages/deepagent-code/src/session/goal-loop-wiring.ts b/packages/deepagent-code/src/session/goal-loop-wiring.ts index 2769b23f..1a5ca887 100644 --- a/packages/deepagent-code/src/session/goal-loop-wiring.ts +++ b/packages/deepagent-code/src/session/goal-loop-wiring.ts @@ -13,10 +13,10 @@ import { ModelV2 } from "@deepagent-code/core/model" import { ProviderV2 } from "@deepagent-code/core/provider" import type * as LSPClient from "../lsp/client" import { LSP } from "../lsp/lsp" -import { runPanel, type PanelistRunInput, type PanelistRunner } from "../panel/orchestrator" -import { DEFAULT_QUORUM_POLICY, type PanelLens, type PanelOpinion, type PanelVerdict } from "../agent/schema/panel" +import { runPanel } from "../panel/orchestrator" +import { buildPanelistRunner, parseReviewResult, REVIEWER_SCHEMA } from "../panel/panelist-runner" +import { DEFAULT_QUORUM_POLICY, type PanelLens, type PanelVerdict } from "../agent/schema/panel" import { ReviewResult } from "../agent/schema/orchestration" -import { ToolJsonSchema } from "../tool/json-schema" import { RuntimeFlags } from "../effect/runtime-flags" import { SessionPrompt } from "./prompt" import { SessionRevert } from "./revert" @@ -157,32 +157,9 @@ export type PanelQuestionInput = { readonly maxRounds?: number } -// Parse a reviewer subagent's structured ReviewResult; a malformed / absent result is treated as -// "findings unknown" → the gate cannot confirm clean → NOT clean (fail-closed, never a silent pass). -const parseReviewResult = (structured: unknown): ReviewResult | null => { - if (structured == null) return null - try { - return ReviewResult.make(structured as ReviewResult) - } catch { - // structured may already be a decoded object from a prior JSON round-trip; accept a shape-check. - const anyVal = structured as { findings?: unknown; verdict?: unknown } - if (Array.isArray(anyVal.findings) && typeof anyVal.verdict === "string") return anyVal as ReviewResult - return null - } -} - -/** Map a reviewer turn output → the panel's PanelOpinion shape (for the panelist runner). */ -const opinionFromReview = (lens: PanelLens, review: ReviewResult | null): PanelOpinion | null => { - if (review == null) return null - // Confidence = max finding confidence (approve with no findings ⇒ full confidence in "approve"). - const confidence = - review.findings.length === 0 - ? 1 - : review.findings.reduce((m, f) => Math.max(m, Number.isFinite(f.confidence) ? f.confidence : 0), 0) - return { lens, verdict: review.verdict, findings: review.findings, confidence } -} - -const REVIEWER_SCHEMA = ToolJsonSchema.fromSchema(ReviewResult) as unknown as Record +// §C: parseReviewResult / REVIEWER_SCHEMA / the panelist runner are shared with the standalone Expert +// Panel entry via `panel/panelist-runner.ts` (single source of truth for how a lens panelist is driven +// and how its ReviewResult maps to a PanelOpinion). This module keeps only the goal-loop-specific glue. // Catch BOTH typed failures AND defects (die) so a port always resolves to a concrete result and // never crashes the loop (the ports live on the `never` channel — see goal-loop.ts). `orElseSucceed` @@ -218,59 +195,28 @@ export const buildGraderPorts = (deps: GraderPortsDeps): GraderPorts => ({ !deps.expertPanelEnabled ? Effect.succeed({ decision: "needs_human" }) : safe( - buildPanelistRunner(deps.runTurn).pipe( - Effect.flatMap((runPanelist) => { - const q = deps.panelQuestion() - return runPanel({ - question: { - question: q.question, - codeRefs: q.codeRefs, - lenses: q.lenses, - maxRounds: q.maxRounds ?? 1, - policy: DEFAULT_QUORUM_POLICY, - }, - runPanelist, - parentSessionID: deps.parentSessionID, - }) - }), - Effect.map((verdict: PanelVerdict) => ({ decision: verdict.decision })), - ), - // A panel that cannot run at all ⇒ escalate to a human, never a silent approve. - { decision: "needs_human" }, - ), + Effect.suspend(() => { + const q = deps.panelQuestion() + // The panelist runner is SHARED with the standalone Expert Panel entry (panelist-runner.ts): + // both convene identically-prompted lens panelists. deps.runTurn matches the PanelTurnRunner + // shape (agentType/prompt/outputSchema → { structured }). + return runPanel({ + question: { + question: q.question, + codeRefs: q.codeRefs, + lenses: q.lenses, + maxRounds: q.maxRounds ?? 1, + policy: DEFAULT_QUORUM_POLICY, + }, + runPanelist: buildPanelistRunner(deps.runTurn), + parentSessionID: deps.parentSessionID, + }) + }).pipe(Effect.map((verdict: PanelVerdict) => ({ decision: verdict.decision }))), + // A panel that cannot run at all ⇒ escalate to a human, never a silent approve. + { decision: "needs_human" }, + ), }) -// A panelist runner over the turn runner: each seat is a lens-prompted reviewer subagent whose -// structured ReviewResult becomes a PanelOpinion. Absent/failed ⇒ null (§C.8 缺席). -const buildPanelistRunner = (runTurn: SubagentTurnRunner): Effect.Effect => - Effect.sync( - () => (input: PanelistRunInput) => - runTurn({ - agentType: "reviewer", - prompt: renderPanelistPrompt(input), - outputSchema: REVIEWER_SCHEMA, - }).pipe( - Effect.map((turn) => opinionFromReview(input.spec.lens, parseReviewResult(turn.structured))), - Effect.catchCause(() => Effect.succeed(null as PanelOpinion | null)), - ), - ) - -const renderPanelistPrompt = (input: PanelistRunInput): string => { - const base = [ - `You are the ${input.spec.lens} expert on a review panel (round ${input.round}).`, - `Question (frozen): ${input.question.question}`, - input.question.codeRefs.length > 0 ? `Code references: ${input.question.codeRefs.join(", ")}` : "", - ].filter((s) => s.length > 0) - if (input.round > 1 && input.peers.length > 0) { - base.push( - `Anonymized peer opinions from the previous round: ${input.peers - .map((p) => `${p.verdict}(${p.confidence.toFixed(2)})`) - .join(", ")}. You may revise, but justify with reproducible evidence.`, - ) - } - return base.join("\n") -} - // --------------------------------------------------------------------------------------------------- // StepExecutor + RollbackPort builders. // --------------------------------------------------------------------------------------------------- diff --git a/packages/deepagent-code/src/settings/store.ts b/packages/deepagent-code/src/settings/store.ts index f06b25f3..79e9aa7e 100644 --- a/packages/deepagent-code/src/settings/store.ts +++ b/packages/deepagent-code/src/settings/store.ts @@ -36,6 +36,10 @@ export namespace SettingsStore { runsDir?: string allowProviderExecutedTools?: boolean allowProviderExecutedToolNames?: string[] + // V3.9 §C: the GLOBAL default for whether a new conversation starts with the Expert Panel + // "armed". A per-session toggle in the chat dialog overrides this per conversation; this only + // seeds the initial armed state. Undefined ≡ false (opt-in, grey rollout). + expertPanelDefault?: boolean } /** Transport tuning for a single official provider. Mirrors the transport keys the provider @@ -103,6 +107,8 @@ export namespace SettingsStore { if (apet !== undefined) out.allowProviderExecutedTools = apet const names = strArray(input.allowProviderExecutedToolNames) if (names) out.allowProviderExecutedToolNames = names + const epd = bool(input.expertPanelDefault) + if (epd !== undefined) out.expertPanelDefault = epd return Object.keys(out).length > 0 ? out : undefined } diff --git a/packages/deepagent-code/test/panel/consult.test.ts b/packages/deepagent-code/test/panel/consult.test.ts new file mode 100644 index 00000000..5094d45c --- /dev/null +++ b/packages/deepagent-code/test/panel/consult.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { consultPanel, type ConsultDeps } from "../../src/panel/consult" +import type { PanelTurnRunner } from "../../src/panel/panelist-runner" +import type { ReviewResult } from "../../src/agent/schema/orchestration" + +/** + * V3.9 §C — the STANDALONE Expert Panel entry. These assert that convening the panel WITHOUT the goal + * loop produces a deterministic verdict from the shared panelist-runner + arbiter, and that graceful + * degradation (all panelists absent) yields needs_human, never a silent approve. + */ + +// A stub PanelTurnRunner: returns a ReviewResult keyed by which lens prompt it sees (the lens name is +// embedded in the differentiated prompt). Lets us drive per-lens verdicts deterministically. +const reviewFor = (verdict: ReviewResult["verdict"], withFinding: boolean): ReviewResult => ({ + verdict, + findings: withFinding + ? [ + { + severity: "high", + category: "correctness", + file: "src/x.ts", + line: 10, + summary: "bug", + failureScenario: "input A ⇒ wrong B", + confidence: 0.9, + }, + ] + : [], +}) + +const runnerReturning = (byLensKeyword: Record): PanelTurnRunner => (input) => { + // The prompt embeds the lens's differentiated system prompt; match on a keyword present in it. + const lower = input.prompt.toLowerCase() + for (const [keyword, review] of Object.entries(byLensKeyword)) { + if (lower.includes(keyword)) return Effect.succeed({ structured: review }) + } + return Effect.succeed({ structured: reviewFor("approve", false) }) +} + +const deps = (runTurn: PanelTurnRunner): ConsultDeps => ({ runTurn }) + +describe("consultPanel (§C standalone)", () => { + test("all lenses approve ⇒ verdict approve", async () => { + const runTurn = runnerReturning({}) // default: everyone approves + const verdict = await Effect.runPromise( + consultPanel( + { + question: "Is this change safe?", + codeRefs: ["src/x.ts:10"], + parentSessionID: "sess-1", + lenses: ["correctness", "security"], + }, + deps(runTurn), + ), + ) + expect(verdict.decision).toBe("approve") + }) + + test("a security block with evidence forces block under the security policy", async () => { + // security lens blocks with a reproducible finding; others approve. + const runTurn = runnerReturning({ security: reviewFor("block", true) }) + const verdict = await Effect.runPromise( + consultPanel( + { + question: "Does this expose a vuln?", + codeRefs: ["src/auth.ts:5"], + parentSessionID: "sess-2", + lenses: ["correctness", "security", "performance"], + policy: "security", + }, + deps(runTurn), + ), + ) + expect(verdict.decision).toBe("block") + }) + + test("all panelists absent ⇒ needs_human (never a silent approve)", async () => { + // A runner that always dies ⇒ every panelist is absent ⇒ below quorum ⇒ needs_human. (The + // PanelTurnRunner lives on the `never` channel; a real failure surfaces as a defect, which + // buildPanelistRunner catches to null.) + const runTurn: PanelTurnRunner = () => Effect.die(new Error("panelist down")) + const verdict = await Effect.runPromise( + consultPanel( + { + question: "anything", + codeRefs: [], + parentSessionID: "sess-3", + lenses: ["correctness", "security"], + }, + deps(runTurn), + ), + ) + expect(verdict.decision).toBe("needs_human") + }) + + test("defaults to all five core lenses when none specified", async () => { + const seen = new Set() + const runTurn: PanelTurnRunner = (input) => { + for (const lens of ["correctness", "security", "performance", "architecture", "repro"]) { + if (input.prompt.toLowerCase().includes(lens)) seen.add(lens) + } + return Effect.succeed({ structured: reviewFor("approve", false) }) + } + await Effect.runPromise( + consultPanel({ question: "q", codeRefs: [], parentSessionID: "sess-4" }, deps(runTurn)), + ) + expect(seen.size).toBe(5) + }) +}) diff --git a/packages/deepagent-code/test/session/goal-driver.test.ts b/packages/deepagent-code/test/session/goal-driver.test.ts new file mode 100644 index 00000000..b00252a1 --- /dev/null +++ b/packages/deepagent-code/test/session/goal-driver.test.ts @@ -0,0 +1,171 @@ +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 { Effect } from "effect" +import { DocumentStore } from "@deepagent-code/core/deepagent/document-store" +import { createPlanDoc, type PlanDoc, type PlanStep } from "@deepagent-code/core/deepagent/plan-controller" +import type { + ControllerDeps, + GraderPorts, + StepExecutor, + RollbackPort, + GoalStatus, +} from "@deepagent-code/core/deepagent/goal-loop" +import { + materializePlanDoc, + startGoal, + runToCompletion, + noopPorts, + type GoalDriverPorts, +} from "../../src/session/goal-driver" + +/** + * V3.9 §D — the Goal Driver. Verifies the production seam that goal-loop.ts + goal-loop-wiring.ts left + * open: materialize the plan into a store doc, start the loop, drive ticks to a terminal outcome, and + * observe status + cooperative pause/stop through the ports. The loop mechanics themselves are covered + * by core/goal-loop.test.ts; here we assert the DRIVER wraps them correctly. + */ + +let root: string +let store: DocumentStore +const SESSION = "drv-session-1" + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), "goal-driver-")) + store = new DocumentStore(root) +}) +afterEach(() => rmSync(root, { recursive: true, force: true })) + +const clock = () => { + let t = 1_000 + return { now: () => t, advance: (ms: number) => (t += ms) } +} + +const step = (id: string, status: PlanStep["status"]): PlanStep => ({ + step_id: id, + title: id, + status, + acceptance: null, + assigned_agent: null, + evidence: [], + note: null, +}) + +const plan = (steps: PlanStep[]): PlanDoc => createPlanDoc(SESSION, "reach the goal", steps) + +const passingPorts = (): GraderPorts => ({ + runTests: () => Effect.succeed({ pass: true }), + diagnostics: () => Effect.succeed({ maxSeverity: null }), + reviewerClean: () => Effect.succeed({ pass: true }), + panelApproves: () => Effect.succeed({ decision: "approve" }), +}) + +const noopExecutor: StepExecutor = () => Effect.succeed({ tokensUsed: 10 }) +const noopRollback: RollbackPort = () => Effect.void + +const controllerDeps = (over: Partial = {}): ControllerDeps => ({ + store, + ports: passingPorts(), + executor: noopExecutor, + rollback: noopRollback, + now: clock().now, + ...over, +}) + +describe("materializePlanDoc", () => { + test("snapshots an in-memory plan into a type:plan store doc", () => { + const id = materializePlanDoc({ store, sessionId: SESSION, plan: plan([step("a", "pending")]) }) + const doc = store.get(id) + expect(doc?.type).toBe("plan") + expect(doc?.scope).toBe(`run:${SESSION}`) + const parsed = JSON.parse(doc!.body) as PlanDoc + expect(parsed.steps[0].step_id).toBe("a") + }) + + test("is idempotent: re-materializing the SAME plan does not bump the version (INV-4)", () => { + const p = plan([step("a", "pending")]) + const id1 = materializePlanDoc({ store, sessionId: SESSION, plan: p }) + const v1 = store.get(id1)!.version + const id2 = materializePlanDoc({ store, sessionId: SESSION, plan: p }) + expect(id2).toBe(id1) + expect(store.get(id2)!.version).toBe(v1) + }) +}) + +describe("startGoal + runToCompletion", () => { + test("a plan already complete + all criteria met ⇒ done", async () => { + const planDocId = materializePlanDoc({ store, sessionId: SESSION, plan: plan([step("a", "done")]) }) + const deps = controllerDeps() + const { handle } = await Effect.runPromise( + startGoal({ + deps, + planDocId, + criteria: [{ kind: "plan_complete" }], + limits: { maxTicks: 10, maxTokens: 10_000, maxWallclockMs: 10_000 }, + }), + ) + const outcome = await Effect.runPromise(runToCompletion({ deps, handle })) + expect(outcome).toBe("done") + }) + + test("onStatus port is called with the loop status after a tick", async () => { + const planDocId = materializePlanDoc({ store, sessionId: SESSION, plan: plan([step("a", "done")]) }) + const deps = controllerDeps() + const seen: GoalStatus[] = [] + const ports: GoalDriverPorts = { + ...noopPorts, + onStatus: (s) => Effect.sync(() => void seen.push(s)), + } + const { handle } = await Effect.runPromise( + startGoal({ + deps, + planDocId, + criteria: [{ kind: "plan_complete" }], + limits: { maxTicks: 10, maxTokens: 10_000, maxWallclockMs: 10_000 }, + }), + ) + await Effect.runPromise(runToCompletion({ deps, handle, ports })) + expect(seen.length).toBeGreaterThan(0) + expect(seen[seen.length - 1].phase).toBe("done") + }) + + test("shouldStop halts the driver before ticking ⇒ needs_human", async () => { + const planDocId = materializePlanDoc({ store, sessionId: SESSION, plan: plan([step("a", "pending")]) }) + const deps = controllerDeps() + const ports: GoalDriverPorts = { ...noopPorts, shouldStop: () => Effect.succeed(true) } + const { handle } = await Effect.runPromise( + startGoal({ + deps, + planDocId, + criteria: [{ kind: "plan_complete" }], + limits: { maxTicks: 10, maxTokens: 10_000, maxWallclockMs: 10_000 }, + }), + ) + const outcome = await Effect.runPromise(runToCompletion({ deps, handle, ports })) + expect(outcome).toBe("needs_human") + }) + + test("shouldPause suspends without a terminal outcome (resumes on next run)", async () => { + const planDocId = materializePlanDoc({ store, sessionId: SESSION, plan: plan([step("a", "pending")]) }) + const deps = controllerDeps() + let paused = true + const ports: GoalDriverPorts = { ...noopPorts, shouldPause: () => Effect.succeed(paused) } + const { handle } = await Effect.runPromise( + startGoal({ + deps, + planDocId, + criteria: [{ kind: "plan_complete" }], + limits: { maxTicks: 10, maxTokens: 10_000, maxWallclockMs: 10_000 }, + }), + ) + // Paused ⇒ the driver returns "continue" without marking terminal. + const first = await Effect.runPromise(runToCompletion({ deps, handle, ports })) + expect(first).toBe("continue") + // Unpause + complete the plan ⇒ resuming drives to done. + paused = false + store.update(planDocId, JSON.stringify(plan([step("a", "done")]))) + const second = await Effect.runPromise(runToCompletion({ deps, handle, ports })) + expect(second).toBe("done") + }) +}) diff --git a/packages/deepagent-code/test/settings/store.test.ts b/packages/deepagent-code/test/settings/store.test.ts index c28ea325..0118080f 100644 --- a/packages/deepagent-code/test/settings/store.test.ts +++ b/packages/deepagent-code/test/settings/store.test.ts @@ -98,6 +98,25 @@ describe("SettingsStore", () => { expect((await SettingsStore.read()).deepagent).toEqual({ agentMode: "high" }) }) + // V3.9 §C: the global Expert Panel default seeds each new conversation's armed state. + test("expertPanelDefault round-trips (write → read back)", async () => { + const w = await SettingsStore.update({ deepagent: { expertPanelDefault: true } }) + expect(w.changed).toBe(true) + expect(w.settings.deepagent).toEqual({ expertPanelDefault: true }) + + SettingsStore.invalidate() + expect((await SettingsStore.read()).deepagent).toEqual({ expertPanelDefault: true }) + }) + + test("non-boolean expertPanelDefault is dropped on read", async () => { + await fs.writeFile( + settingsFile(), + JSON.stringify({ deepagent: { expertPanelDefault: "yes", agentMode: "high" } }), + ) + SettingsStore.invalidate() + expect((await SettingsStore.read()).deepagent).toEqual({ agentMode: "high" }) + }) + test("keeps transport only for official providers", async () => { const result = await SettingsStore.update({ providers: { From 7bbd010508d9585008c7874b8f71b545f91744f2 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 09:15:38 +0800 Subject: [PATCH 2/6] feat(v3.9): server wiring for Expert Panel + Goal Loop (P3.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the P1–P3 engines over HTTP so the UI can invoke them. GoalManager service (session/goal-manager.ts): the resident in-process supervisor that owns running goals. start() materializes the session plan, assembles the flag-gated ControllerDeps (makeGoalLoopWiring), and drives runToCompletion as a BackgroundJob; pause/resume/stop/status track per-session control state observed live by the driver ports. Each tick publishes goal.updated and mirrors the session-state pointer. Registered in app-runtime + the httpapi route service graph. goal.updated event (session/goal-event.ts): live phase + budget ledger, flows through the same SSE stream as plan.updated. Routes (deepagent group): POST /deepagent/panel/consult + /panel/arm; POST /deepagent/goal/{start,pause,resume,stop} + GET /goal/status. Panel and goal are independently flag-gated server-side (handler fail-closes with 400 when disabled). Reachable from the app via client.request by path — no SDK regen required. Typecheck clean (core + deepagent-code); 71 panel/goal/app-runtime tests green. Co-Authored-By: Claude Opus 4.8 --- .../deepagent-code/src/effect/app-runtime.ts | 2 + .../instance/httpapi/groups/deepagent.ts | 164 +++++++- .../instance/httpapi/handlers/deepagent.ts | 137 +++++++ .../server/routes/instance/httpapi/server.ts | 2 + .../deepagent-code/src/session/goal-event.ts | 37 ++ .../src/session/goal-manager.ts | 369 ++++++++++++++++++ 6 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 packages/deepagent-code/src/session/goal-event.ts create mode 100644 packages/deepagent-code/src/session/goal-manager.ts diff --git a/packages/deepagent-code/src/effect/app-runtime.ts b/packages/deepagent-code/src/effect/app-runtime.ts index bf375935..e5d661b3 100644 --- a/packages/deepagent-code/src/effect/app-runtime.ts +++ b/packages/deepagent-code/src/effect/app-runtime.ts @@ -30,6 +30,7 @@ import { SessionCompaction } from "@/session/compaction" import { SessionRevert } from "@/session/revert" import { SessionSummary } from "@/session/summary" import { SessionPrompt } from "@/session/prompt" +import { GoalManager } from "@/session/goal-manager" import { Instruction } from "@/session/instruction" import { LLM } from "@/session/llm" import { LSP } from "@/lsp/lsp" @@ -87,6 +88,7 @@ export const AppLayer = Layer.mergeAll( SessionRevert.defaultLayer, SessionSummary.defaultLayer, SessionPrompt.defaultLayer, + GoalManager.defaultLayer, Instruction.defaultLayer, LLM.defaultLayer, LSP.defaultLayer, 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 7f80fafc..016ad144 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 @@ -2,7 +2,11 @@ import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" -import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" +import { + WorkspaceRoutingMiddleware, + WorkspaceRoutingQuery, + WorkspaceRoutingQueryFields, +} from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/deepagent" @@ -223,6 +227,89 @@ export const DeepAgentEnvFactModifyInput = Schema.Struct({ }) export const DeepAgentEnvFactResult = Schema.Struct({ ok: Schema.Boolean, factId: Schema.String }) +// ── V3.9 §C Expert Panel + §D Goal Loop ───────────────────────────────────── +// Panel consult (会诊) is convened on demand for a session; the goal lifecycle drives the autonomous +// loop. Both are gated by their independent experimental flags server-side (the handler fail-closes). + +const PanelLensSchema = Schema.Literals(["correctness", "security", "performance", "architecture", "repro"]) + +/** POST /deepagent/panel/consult — convene the Expert Panel on the current session context. */ +export const DeepAgentPanelConsultInput = Schema.Struct({ + sessionID: Schema.String, + /** The frozen question. When omitted the handler builds one from the session's recent context. */ + question: Schema.optional(Schema.String), + codeRefs: Schema.optional(Schema.Array(Schema.String)), + lenses: Schema.optional(Schema.Array(PanelLensSchema)), + maxRounds: Schema.optional(Schema.Number), + policy: Schema.optional(Schema.Literals(["default", "security"])), +}) + +export const DeepAgentPanelFinding = Schema.Struct({ + severity: Schema.String, + category: Schema.String, + file: Schema.optional(Schema.NullOr(Schema.String)), + line: Schema.optional(Schema.NullOr(Schema.Number)), + summary: Schema.String, + failureScenario: Schema.String, + confidence: Schema.Number, +}) +export const DeepAgentPanelDissent = Schema.Struct({ + lens: Schema.String, + verdict: Schema.String, + confidence: Schema.Number, + findings: Schema.Array(DeepAgentPanelFinding), +}) +export const DeepAgentPanelVerdict = Schema.Struct({ + decision: Schema.Literals(["approve", "revise", "block", "needs_human"]), + confidence: Schema.Number, + rounds: Schema.Number, + evidence: Schema.Array(Schema.String), + dissent: Schema.Array(DeepAgentPanelDissent), +}) + +/** POST /deepagent/panel/arm — set the per-session armed flag (button toggle). */ +export const DeepAgentPanelArmInput = Schema.Struct({ + sessionID: Schema.String, + armed: Schema.Boolean, +}) +export const DeepAgentPanelArmResult = Schema.Struct({ sessionID: Schema.String, armed: Schema.Boolean }) + +/** POST /deepagent/goal/start */ +export const DeepAgentGoalStartInput = Schema.Struct({ + sessionID: Schema.String, + criteria: Schema.optional( + Schema.Array( + Schema.Struct({ + kind: Schema.Literals(["tests_pass", "no_diagnostics", "reviewer_clean", "panel_approves", "plan_complete"]), + commands: Schema.optional(Schema.Array(Schema.String)), + maxSeverity: Schema.optional(Schema.String), + severityAtMost: Schema.optional(Schema.String), + }), + ), + ), + limits: Schema.optional( + Schema.Struct({ + maxTicks: Schema.optional(Schema.Number), + maxTokens: Schema.optional(Schema.Number), + maxWallclockMs: Schema.optional(Schema.Number), + maxCost: Schema.optional(Schema.Number), + }), + ), + stallThreshold: Schema.optional(Schema.Number), +}) + +/** Goal lifecycle mutations that only need the session id. */ +export const DeepAgentGoalSessionInput = Schema.Struct({ sessionID: Schema.String }) + +export const DeepAgentGoalSnapshot = Schema.Struct({ + goalId: Schema.String, + planDocId: Schema.String, + phase: Schema.String, + running: Schema.Boolean, +}) +export const DeepAgentGoalStatusResult = Schema.Struct({ goal: Schema.NullOr(DeepAgentGoalSnapshot) }) +export const DeepAgentGoalMutateResult = Schema.Struct({ ok: Schema.Boolean }) + export const DeepAgentApi = HttpApi.make("deepagent").add( HttpApiGroup.make("deepagent") .add( @@ -399,6 +486,81 @@ export const DeepAgentApi = HttpApi.make("deepagent").add( }), ), ) + .add( + HttpApiEndpoint.post("panelConsult", `${root}/panel/consult`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentPanelConsultInput, + success: described(DeepAgentPanelVerdict, "Expert Panel verdict for the convened question"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.panel.consult", + summary: "Convene the Expert Panel (会诊)", + description: + "V3.9 §C: freeze the question, fan out the lens panelists (equal-footing debate), and return the deterministic arbiter verdict. Gated by the expert-panel flag.", + }), + ), + ) + .add( + HttpApiEndpoint.post("panelArm", `${root}/panel/arm`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentPanelArmInput, + success: described(DeepAgentPanelArmResult, "The session's new panel armed state"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.panel.arm", + summary: "Arm or disarm the Expert Panel for a session", + description: "V3.9 §C: per-conversation toggle for the panel button; seeded from the global default.", + }), + ), + ) + .add( + HttpApiEndpoint.post("goalStart", `${root}/goal/start`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentGoalStartInput, + success: described(DeepAgentGoalSnapshot, "The started goal (goalId + initial phase)"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.goal.start", + summary: "Start a Goal Loop from the session's plan", + description: + "V3.9 §D: materialize the session plan into the graded doc and drive the autonomous loop as a resident background task. Gated by the goal-loop flag.", + }), + ), + ) + .add( + HttpApiEndpoint.post("goalPause", `${root}/goal/pause`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentGoalSessionInput, + success: described(DeepAgentGoalMutateResult, "Whether the goal was paused"), + error: DeepAgentPromotionError, + }), + ) + .add( + HttpApiEndpoint.post("goalResume", `${root}/goal/resume`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentGoalSessionInput, + success: described(DeepAgentGoalMutateResult, "Whether the goal was resumed"), + error: DeepAgentPromotionError, + }), + ) + .add( + HttpApiEndpoint.post("goalStop", `${root}/goal/stop`, { + query: WorkspaceRoutingQuery, + payload: DeepAgentGoalSessionInput, + success: described(DeepAgentGoalMutateResult, "Whether the goal was stopped"), + error: DeepAgentPromotionError, + }), + ) + .add( + HttpApiEndpoint.get("goalStatus", `${root}/goal/status`, { + query: Schema.Struct({ ...WorkspaceRoutingQueryFields, sessionID: Schema.String }), + success: described(DeepAgentGoalStatusResult, "The active goal for the session, or null"), + error: DeepAgentPromotionError, + }), + ) .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 2135d28b..eb289006 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 @@ -11,12 +11,50 @@ import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { InstanceHttpApi } from "../api" import { DeepAgentPromotionError } from "../groups/deepagent" import { WorkspaceRouteContext } from "../middleware/workspace-routing" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Session } from "@/session/session" +import { Agent } from "@/agent/agent" +import { SessionPrompt } from "@/session/prompt" +import { Provider } from "@/provider/provider" +import { GoalManager } from "@/session/goal-manager" +import { SessionID } from "@/session/schema" +import { consultPanel } from "@/panel/consult" +import { makeTaskSubagentRunner } from "@/session/goal-loop-wiring" +import type { PanelTurnRunner } from "@/panel/panelist-runner" +import type { PanelVerdict } from "@/agent/schema/panel" +import type { CompletionCriterion } from "@deepagent-code/core/deepagent/goal-loop" const dbgLog = Log.create({ service: "deepagent.packs.debug" }) export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagent", (handlers) => Effect.gen(function* () { const config = yield* Config.Service + // V3.9 §C/§D services — provided by the app runtime the server executes in. + const flags = yield* RuntimeFlags.Service + const sessions = yield* Session.Service + const agents = yield* Agent.Service + const sessionPrompt = yield* SessionPrompt.Service + const provider = yield* Provider.Service + const goals = yield* GoalManager.Service + + // Build a reviewer-subagent turn runner scoped to a session — the panelist seam consultPanel needs. + // Reuses makeTaskSubagentRunner (the same child-session + permission-derivation path the goal loop + // and the task tool use) and adapts its SubagentTurnResult to the PanelTurnRunner shape. + const panelTurnRunnerFor = (sessionID: string): Effect.Effect => + Effect.gen(function* () { + const model = yield* provider.defaultModel().pipe(Effect.orDie) + const runTurn = makeTaskSubagentRunner({ + sessions, + agents, + sessionPrompt, + parentSessionID: SessionID.make(sessionID), + model: { providerID: model.providerID, modelID: model.modelID }, + }) + return (turnInput) => + runTurn({ agentType: turnInput.agentType, prompt: turnInput.prompt, outputSchema: turnInput.outputSchema }).pipe( + Effect.map((r) => ({ structured: r.structured })), + ) + }) const resolveReviewRunsDir = Effect.fn("DeepAgentHttpApi.resolveReviewRunsDir")(function* () { const route = yield* WorkspaceRouteContext @@ -402,6 +440,98 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen }) }) + // ── V3.9 §C Expert Panel ──────────────────────────────────────────────── + const toVerdictResult = (v: PanelVerdict) => ({ + decision: v.decision, + confidence: v.confidence, + rounds: v.rounds, + evidence: [...v.evidence], + dissent: v.dissent.map((d) => ({ + lens: d.lens, + verdict: d.verdict, + confidence: d.confidence, + findings: d.findings.map((f) => ({ + severity: f.severity, + category: f.category, + file: f.file ?? null, + line: f.line ?? null, + summary: f.summary, + failureScenario: f.failureScenario, + confidence: f.confidence, + })), + })), + }) + + const panelConsult = Effect.fn("DeepAgentHttpApi.panelConsult")(function* (ctx) { + // §C: the panel is independently gated. Flag off ⇒ 400 (never silently run a disabled capability). + if (!flags.experimentalExpertPanel) + return yield* Effect.fail(new DeepAgentPromotionError({ message: "expert panel is disabled" })) + const { sessionID } = ctx.payload + const runTurn = yield* panelTurnRunnerFor(sessionID) + const verdict = yield* consultPanel( + { + question: + ctx.payload.question ?? "Review the current changes in this conversation for correctness, security, and design.", + codeRefs: ctx.payload.codeRefs ? [...ctx.payload.codeRefs] : [], + parentSessionID: sessionID, + ...(ctx.payload.lenses ? { lenses: [...ctx.payload.lenses] } : {}), + ...(ctx.payload.maxRounds != null ? { maxRounds: ctx.payload.maxRounds } : {}), + ...(ctx.payload.policy ? { policy: ctx.payload.policy } : {}), + }, + { runTurn }, + ) + return toVerdictResult(verdict) + }) + + const panelArm = Effect.fn("DeepAgentHttpApi.panelArm")(function* (ctx) { + const { sessionID, armed } = ctx.payload + AgentGateway.DeepAgentSessionState.setPanelArmed(sessionID, armed) + return { sessionID, armed: AgentGateway.DeepAgentSessionState.isPanelArmed(sessionID) } + }) + + // ── V3.9 §D Goal Loop lifecycle ───────────────────────────────────────── + const goalStart = Effect.fn("DeepAgentHttpApi.goalStart")(function* (ctx) { + if (!flags.experimentalGoalLoop) + return yield* Effect.fail(new DeepAgentPromotionError({ message: "goal loop is disabled" })) + type CriterionPayload = { + kind: CompletionCriterion["kind"] + commands?: readonly string[] + maxSeverity?: string + severityAtMost?: string + } + const criteria = ctx.payload.criteria?.map( + (c: CriterionPayload) => + ({ + kind: c.kind, + ...(c.commands ? { commands: [...c.commands] } : {}), + ...(c.maxSeverity != null ? { maxSeverity: c.maxSeverity } : {}), + ...(c.severityAtMost != null ? { severityAtMost: c.severityAtMost } : {}), + }) as CompletionCriterion, + ) + const snapshot = yield* goals + .start({ + sessionID: ctx.payload.sessionID, + ...(criteria ? { criteria } : {}), + ...(ctx.payload.limits ? { limits: ctx.payload.limits } : {}), + ...(ctx.payload.stallThreshold != null ? { stallThreshold: ctx.payload.stallThreshold } : {}), + }) + .pipe(Effect.mapError((e) => new DeepAgentPromotionError({ message: e.reason }))) + return snapshot + }) + + const goalPause = Effect.fn("DeepAgentHttpApi.goalPause")(function* (ctx) { + return { ok: yield* goals.pause(ctx.payload.sessionID) } + }) + const goalResume = Effect.fn("DeepAgentHttpApi.goalResume")(function* (ctx) { + return { ok: yield* goals.resume(ctx.payload.sessionID) } + }) + const goalStop = Effect.fn("DeepAgentHttpApi.goalStop")(function* (ctx) { + return { ok: yield* goals.stop(ctx.payload.sessionID) } + }) + const goalStatus = Effect.fn("DeepAgentHttpApi.goalStatus")(function* (ctx) { + return { goal: yield* goals.status(ctx.urlParams.sessionID) } + }) + return handlers .handle("reviews", reviews) .handle("promote", promote) @@ -417,5 +547,12 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen .handle("envFacts", envFacts) .handle("envFactsDecide", envFactsDecide) .handle("envFactsModify", envFactsModify) + .handle("panelConsult", panelConsult) + .handle("panelArm", panelArm) + .handle("goalStart", goalStart) + .handle("goalPause", goalPause) + .handle("goalResume", goalResume) + .handle("goalStop", goalStop) + .handle("goalStatus", goalStatus) }), ) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts index 3154f0b2..ed7ab85a 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/server.ts @@ -41,6 +41,7 @@ import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" import { LLM } from "@/session/llm" import { SessionPrompt } from "@/session/prompt" +import { GoalManager } from "@/session/goal-manager" import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" @@ -283,6 +284,7 @@ export function createRoutes( Session.defaultLayer, SessionCompaction.defaultLayer, SessionPrompt.defaultLayer, + GoalManager.defaultLayer, SessionRevert.defaultLayer, SessionShare.defaultLayer, SessionRunState.defaultLayer, diff --git a/packages/deepagent-code/src/session/goal-event.ts b/packages/deepagent-code/src/session/goal-event.ts new file mode 100644 index 00000000..3dde724b --- /dev/null +++ b/packages/deepagent-code/src/session/goal-event.ts @@ -0,0 +1,37 @@ +import { Schema } from "effect" +import { EventV2 } from "@deepagent-code/core/event" +import { SessionID } from "./schema" + +/** + * V3.9 §D — the live Goal Loop event. Published whenever a goal's phase or ledger changes (start, each + * tick's status, pause/resume, terminal). Mirrors `plan.updated` so it flows through the same SSE + * stream the app already consumes; the app reducer projects it into a per-session goal status bar + * (Codex thread-goal style: Active / Paused / Blocked-ish (needs_human) / Complete). + * + * `phase` is the DRIVER-level phase (adds "paused" over the core GoalPhase). `ledger` is the observable + * budget accounting (ticks / tokens / cost / wallclock) so the UI can render a live "12k / 50k tokens" + * progress readout. `gaps` are the unmet-criterion descriptions when the loop escalates (needs_human). + */ +export const GoalLedgerEvent = Schema.Struct({ + ticks: Schema.Number, + tokens: Schema.Number, + cost: Schema.Number, + wallclockMs: Schema.Number, +}) + +export const GoalEvent = { + Updated: EventV2.define({ + type: "goal.updated", + schema: { + sessionID: SessionID, + goalId: Schema.String, + planDocId: Schema.String, + /** running | paused | done | needs_human | rolled_back | stopped (driver-level GoalPointerPhase). */ + phase: Schema.String, + ledger: GoalLedgerEvent, + stallCount: Schema.Number, + /** Unmet-criterion / gap descriptions surfaced when the loop escalates or completes. */ + gaps: Schema.Array(Schema.String), + }, + }), +} diff --git a/packages/deepagent-code/src/session/goal-manager.ts b/packages/deepagent-code/src/session/goal-manager.ts new file mode 100644 index 00000000..a953fc04 --- /dev/null +++ b/packages/deepagent-code/src/session/goal-manager.ts @@ -0,0 +1,369 @@ +import { Effect, Layer, Context, SynchronizedRef } from "effect" +import path from "node:path" +import { Global } from "@deepagent-code/core/global" +import { AgentGateway } from "@deepagent-code/core/agent-gateway" +import { DocumentStore } from "@deepagent-code/core/deepagent/document-store" +import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" +import type { GoalStatus, GoalLimits, CompletionCriterion } from "@deepagent-code/core/deepagent/goal-loop" +import { InvalidGoalError } from "@deepagent-code/core/deepagent/goal-loop" +import { RuntimeFlags } from "../effect/runtime-flags" +import { EventV2Bridge } from "@/event-v2-bridge" +import { BackgroundJob } from "@/background/job" +import { Session } from "./session" +import { Agent } from "../agent/agent" +import { SessionPrompt } from "./prompt" +import { SessionRevert } from "./revert" +import { LSP } from "../lsp/lsp" +import { Provider } from "../provider/provider" +import { SessionID } from "./schema" +import { GoalEvent } from "./goal-event" +import { + GoalLoopWiring, + liveDiagnostics, + liveRollback, + makeTaskSubagentRunner, + type PanelQuestionInput, +} from "./goal-loop-wiring" +import { GoalDriver, type GoalDriverPorts } from "./goal-driver" + +/** + * V3.9 §D — the GOAL MANAGER service: the resident, in-process supervisor that OWNS running goals. + * + * This is the production seam that turns the built-but-unwired Goal Loop into a user-invocable mode. + * `startGoal` materializes the session's plan into the graded store doc, assembles the live + * `ControllerDeps` (via `makeGoalLoopWiring` — flag-gated), starts the driver as a BackgroundJob + * (a resident background Effect with cancellation), and tracks per-session control state so + * `pause` / `resume` / `stop` / `status` work while it runs. Each tick's status is published as a + * `goal.updated` event and mirrored into the session-state active-goal pointer so the UI stays live. + * + * Concurrency model (per the product decision — 服务内常驻后台任务): one goal per session at a time. + * The driver ticks in the background; the user's foreground conversation stays free. Pause is + * cooperative (the driver checks a flag before each tick and suspends without tearing down the loop); + * resume re-drives from the persisted run_context doc. + */ + +// The DocumentStore holding a session's goal docs. Co-located with the run graph under the agent data +// root, keyed by session id, so a restart re-opens the same store (the loop state is restart-recoverable). +const goalStoreRoot = (sessionID: string): string => + path.join(Global.Path.agent.data, "state", "goal", sessionID, "graph") + +// Per-session control state the driver ports read. Held in a SynchronizedRef so pause/stop from a route +// are observed by the running background driver without a lock. +type GoalControl = { + readonly goalId: string + readonly planDocId: string + jobId: string + paused: boolean + stopped: boolean +} + +export type StartGoalInput = { + readonly sessionID: string + /** Objective completion criteria (AND). Defaults to plan_complete + no_diagnostics when omitted. */ + readonly criteria?: readonly CompletionCriterion[] + /** Hard bounds; a goal with no bounds is rejected by the core (InvalidGoalError). */ + readonly limits?: Partial + readonly stallThreshold?: number + /** The Expert Panel question convened at a decision point (§D.7). Defaults to a review of the diff. */ + readonly panelQuestion?: PanelQuestionInput +} + +export type GoalSnapshot = { + readonly goalId: string + readonly planDocId: string + readonly phase: string + readonly running: boolean +} + +export interface Interface { + readonly start: (input: StartGoalInput) => Effect.Effect + readonly pause: (sessionID: string) => Effect.Effect + readonly resume: (sessionID: string) => Effect.Effect + readonly stop: (sessionID: string) => Effect.Effect + readonly status: (sessionID: string) => Effect.Effect +} + +export class Service extends Context.Service()("@deepagent-code/GoalManager") {} + +const DEFAULT_LIMITS: GoalLimits = { maxTicks: 50, maxTokens: 500_000, maxWallclockMs: 60 * 60 * 1000 } +const DEFAULT_CRITERIA: readonly CompletionCriterion[] = [{ kind: "plan_complete" }, { kind: "no_diagnostics" }] + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const sessions = yield* Session.Service + const agents = yield* Agent.Service + const sessionPrompt = yield* SessionPrompt.Service + const revert = yield* SessionRevert.Service + const events = yield* EventV2Bridge.Service + const background = yield* BackgroundJob.Service + const provider = yield* Provider.Service + const lsp = yield* LSP.Service + const flags = yield* RuntimeFlags.Service + + // Diagnostics accessor with LSP already provided, so the goal-loop wiring stays free of LSP in its + // requirement channel (liveDiagnostics needs LSP.Service; we satisfy it here at construction). + const diagnostics = () => liveDiagnostics().pipe(Effect.provideService(LSP.Service, lsp)) + + // Rollback port shared by start + resume (best-effort revert to the last message). + const rollback = liveRollback(revert, (sid) => + sessions + .messages({ sessionID: SessionID.make(sid) }) + .pipe( + Effect.map((msgs) => msgs.at(-1)?.info.id ?? null), + Effect.catchCause(() => Effect.succeed(null)), + ), + ) + + const defaultPanelQuestion = (): PanelQuestionInput => ({ + question: "Is the current change safe and correct enough to complete this goal?", + codeRefs: [], + lenses: ["correctness", "security", "architecture"], + }) + + // Per-session control state, observed by the running driver's ports. + const controls = yield* SynchronizedRef.make(new Map()) + + const getControl = (sessionID: string) => + SynchronizedRef.get(controls).pipe(Effect.map((m) => m.get(sessionID) ?? null)) + + const setControl = (sessionID: string, control: GoalControl | null) => + SynchronizedRef.update(controls, (m) => { + const next = new Map(m) + if (control) next.set(sessionID, control) + else next.delete(sessionID) + return next + }) + + const mutateControl = (sessionID: string, f: (c: GoalControl) => void) => + SynchronizedRef.update(controls, (m) => { + const c = m.get(sessionID) + if (c) f(c) + return m + }) + + // Publish a status → both the goal.updated event and the session-state active-goal pointer. + const publishStatus = (sessionID: string, status: GoalStatus) => + Effect.gen(function* () { + const phase = status.phase as string + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, phase as never) + yield* events + .publish(GoalEvent.Updated, { + sessionID: SessionID.make(sessionID), + goalId: status.goalId, + planDocId: status.planDocId, + phase, + ledger: { + ticks: status.ledger.ticks, + tokens: status.ledger.tokens, + cost: status.ledger.cost, + wallclockMs: status.ledger.wallclockMs, + }, + stallCount: status.stallCount, + gaps: status.gaps, + }) + .pipe(Effect.ignore) + }) + + const start: Interface["start"] = (input) => + Effect.gen(function* () { + const sessionID = input.sessionID + // The plan the user produced in plan mode lives in in-memory session-state — snapshot it into + // the graded store doc. A goal with no plan cannot be decided → the core rejects it at start. + const plan = AgentGateway.DeepAgentSessionState.getPlan(sessionID) as PlanDoc | null + const store = new DocumentStore(goalStoreRoot(sessionID)) + const planDocId = + plan != null + ? GoalDriver.materializePlanDoc({ store, sessionId: sessionID, plan }) + : GoalDriver.goalPlanScope(sessionID) // no plan → a scope string; startGoal will reject (no doc) + + const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orDie) + const model = yield* provider.defaultModel().pipe(Effect.orDie) + + const runTurn = makeTaskSubagentRunner({ + sessions, + agents, + sessionPrompt, + parentSessionID: SessionID.make(sessionID), + model: { providerID: model.providerID, modelID: model.modelID }, + }) + + const deps = yield* GoalLoopWiring.makeGoalLoopWiring({ + store, + parentSessionID: sessionID, + cwd: session.directory ?? process.cwd(), + runTurn, + panelQuestion: () => input.panelQuestion ?? defaultPanelQuestion(), + diagnostics, + rollback, + }).pipe(Effect.provideService(RuntimeFlags.Service, flags)) + // Flag OFF ⇒ wiring is null ⇒ the goal loop is unavailable. Reject clearly. + if (deps == null) { + return yield* Effect.fail( + new InvalidGoalError({ reason: "goal loop is disabled (experimentalGoalLoop flag off)" }), + ) + } + + const { handle } = yield* GoalDriver.startGoal({ + deps, + planDocId, + criteria: input.criteria ?? DEFAULT_CRITERIA, + limits: { ...DEFAULT_LIMITS, ...input.limits }, + stallThreshold: input.stallThreshold, + }) + + // Register the session-state pointer so the UI reflects the goal even before the first tick. + AgentGateway.DeepAgentSessionState.setActiveGoal(sessionID, { + goalId: handle.goalId, + planDocId: handle.planDocId, + phase: "running", + startedAt: new Date().toISOString(), + }) + + // The driver ports read the per-session control flags (pause/stop) live. + const ports: GoalDriverPorts = { + onStatus: (status) => publishStatus(sessionID, status), + shouldPause: () => getControl(sessionID).pipe(Effect.map((c) => c?.paused ?? false)), + shouldStop: () => getControl(sessionID).pipe(Effect.map((c) => c?.stopped ?? false)), + } + + // Start the driver as a resident background task. onFinish clears the pointer / control state. + const job = yield* background.start({ + type: "goal-loop", + title: `goal ${handle.goalId}`, + metadata: { sessionID, goalId: handle.goalId }, + run: GoalDriver.runToCompletion({ deps, handle, ports }).pipe( + Effect.tap((outcome) => + Effect.sync(() => { + // A terminal outcome clears the running pointer to its terminal phase; a paused exit + // leaves the pointer for a later resume. + if (outcome !== "continue") { + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, outcome as never) + } + }), + ), + Effect.map((outcome) => `goal ${handle.goalId}: ${outcome}`), + Effect.catchCause(() => Effect.succeed(`goal ${handle.goalId}: driver defect`)), + ), + }) + + yield* setControl(sessionID, { + goalId: handle.goalId, + planDocId: handle.planDocId, + jobId: job.id, + paused: false, + stopped: false, + }) + + return { goalId: handle.goalId, planDocId: handle.planDocId, phase: "running", running: true } + }) + + const pause: Interface["pause"] = (sessionID) => + Effect.gen(function* () { + const c = yield* getControl(sessionID) + if (!c) return false + yield* mutateControl(sessionID, (ctrl) => (ctrl.paused = true)) + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, "paused") + return true + }) + + const resume: Interface["resume"] = (sessionID) => + Effect.gen(function* () { + const c = yield* getControl(sessionID) + if (!c || c.stopped) return false + yield* mutateControl(sessionID, (ctrl) => (ctrl.paused = false)) + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, "running") + // Re-drive: the persisted run_context doc resumes exactly where it paused. A fresh store handle + // over the same root re-reads the loop state. + const store = new DocumentStore(goalStoreRoot(sessionID)) + const model = yield* provider.defaultModel().pipe(Effect.orDie) + const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orDie) + const runTurn = makeTaskSubagentRunner({ + sessions, + agents, + sessionPrompt, + parentSessionID: SessionID.make(sessionID), + model: { providerID: model.providerID, modelID: model.modelID }, + }) + const deps = yield* GoalLoopWiring.makeGoalLoopWiring({ + store, + parentSessionID: sessionID, + cwd: session.directory ?? process.cwd(), + runTurn, + panelQuestion: defaultPanelQuestion, + diagnostics, + rollback, + }).pipe(Effect.provideService(RuntimeFlags.Service, flags)) + if (deps == null) return false + const handle = { goalId: c.goalId, planDocId: c.planDocId, sessionId: sessionID } + const ports: GoalDriverPorts = { + onStatus: (status) => publishStatus(sessionID, status), + shouldPause: () => getControl(sessionID).pipe(Effect.map((ctrl) => ctrl?.paused ?? false)), + shouldStop: () => getControl(sessionID).pipe(Effect.map((ctrl) => ctrl?.stopped ?? false)), + } + const job = yield* background.start({ + type: "goal-loop", + title: `goal ${c.goalId} (resumed)`, + metadata: { sessionID, goalId: c.goalId }, + run: GoalDriver.runToCompletion({ deps, handle, ports }).pipe( + Effect.tap((outcome) => + Effect.sync(() => { + if (outcome !== "continue") + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, outcome as never) + }), + ), + Effect.map((outcome) => `goal ${c.goalId}: ${outcome}`), + Effect.catchCause(() => Effect.succeed(`goal ${c.goalId}: driver defect`)), + ), + }) + yield* mutateControl(sessionID, (ctrl) => (ctrl.jobId = job.id)) + return true + }) + + const stop: Interface["stop"] = (sessionID) => + Effect.gen(function* () { + const c = yield* getControl(sessionID) + if (!c) return false + yield* mutateControl(sessionID, (ctrl) => (ctrl.stopped = true)) + yield* background.cancel(c.jobId).pipe(Effect.ignore) + AgentGateway.DeepAgentSessionState.setActiveGoalPhase(sessionID, "stopped") + yield* setControl(sessionID, null) + return true + }) + + const status: Interface["status"] = (sessionID) => + Effect.gen(function* () { + const ptr = AgentGateway.DeepAgentSessionState.getActiveGoal(sessionID) + if (!ptr) return null + const c = yield* getControl(sessionID) + return { + goalId: ptr.goalId, + planDocId: ptr.planDocId, + phase: ptr.phase, + running: c != null && !c.paused && !c.stopped, + } + }) + + // Reference `flags` so an unused-var lint stays quiet; the real gate is makeGoalLoopWiring returning + // null when experimentalGoalLoop is off (checked in start/resume). + void flags + + return Service.of({ start, pause, resume, stop, status }) + }), +) + +export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(BackgroundJob.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), + ), +) + +export * as GoalManager from "./goal-manager" From 27d5a61469afb92b41cb2eaecb16e4ad1ac7358d Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 09:20:26 +0800 Subject: [PATCH 3/6] feat(v3.9): register `goal` primary agent (P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add goal as the third primary agent alongside build/plan — the mode a user selects to define a bounded, objectively-decidable goal and produce the plan the Goal Loop drives. Same working permission ruleset as build (setup reads + plans; autonomous execution runs in goal-worker child sessions). Ships with a goal-mode system prompt that steers toward a decidable finish line, explicit bounds, and a concrete plan. Gated on experimentalGoalLoop: the mode is registered only when the flag is on, so it never appears in the switcher without a backend that can start a goal (the goal.start route independently fail-closes too). RuntimeFlags added to the Agent service + defaultLayer. Tests: goal agent present when flag on, absent when off (build/plan unaffected). Full agent suite green (72). Co-Authored-By: Claude Opus 4.8 --- packages/deepagent-code/src/agent/agent.ts | 31 +++++++++ .../src/agent/prompt/goal-mode.txt | 13 ++++ .../test/agent/goal-mode-agent.test.ts | 66 +++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 packages/deepagent-code/src/agent/prompt/goal-mode.txt create mode 100644 packages/deepagent-code/test/agent/goal-mode-agent.test.ts diff --git a/packages/deepagent-code/src/agent/agent.ts b/packages/deepagent-code/src/agent/agent.ts index 8adb96a3..7660539f 100644 --- a/packages/deepagent-code/src/agent/agent.ts +++ b/packages/deepagent-code/src/agent/agent.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@deepagent-code/core/v1/permission" import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" import { serviceUse } from "@deepagent-code/core/effect/service-use" import { Provider } from "@/provider/provider" @@ -14,6 +15,7 @@ import PROMPT_EXPLORE from "./prompt/explore.txt" import PROMPT_RESEARCHER from "./prompt/researcher.txt" import PROMPT_REVIEWER from "./prompt/reviewer.txt" import PROMPT_GOAL_WORKER from "./prompt/goal-worker.txt" +import PROMPT_GOAL_MODE from "./prompt/goal-mode.txt" import { PLAN_WRITE_OWN_GOAL } from "./subagent-permissions" import PROMPT_SUMMARY from "./prompt/summary.txt" import PROMPT_TITLE from "./prompt/title.txt" @@ -128,6 +130,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const skill = yield* Skill.Service const provider = yield* Provider.Service + const flags = yield* RuntimeFlags.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { @@ -203,6 +206,33 @@ export const layer = Layer.effect( mode: "primary", native: true, }, + // V3.9 §D: GOAL mode — the third primary agent (alongside build/plan). Selecting it is the + // entry point for defining a bounded, objectively-decidable goal and producing the plan the + // Goal Loop drives autonomously. Same working permission ruleset as build (goal setup reads + // and plans; the actual autonomous execution runs in goal-worker child sessions). Only + // registered when experimentalGoalLoop is on, so the mode never appears without a backend + // that can start a goal (the goal.start route also fail-closes when the flag is off). + ...(flags.experimentalGoalLoop + ? { + goal: { + name: "goal", + description: + "Goal mode (V3.9 §D). Define a bounded, objectively-decidable goal and produce a plan; the supervised Goal Loop then drives it to completion autonomously (plan→execute→verify per tick).", + permission: Permission.merge( + defaults, + Permission.fromConfig({ + question: "allow", + plan_enter: "allow", + }), + user, + ), + prompt: PROMPT_GOAL_MODE, + options: {}, + mode: "primary" as const, + native: true, + }, + } + : {}), general: { name: "general", description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`, @@ -587,6 +617,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), ) export * as Agent from "./agent" diff --git a/packages/deepagent-code/src/agent/prompt/goal-mode.txt b/packages/deepagent-code/src/agent/prompt/goal-mode.txt new file mode 100644 index 00000000..3d126e60 --- /dev/null +++ b/packages/deepagent-code/src/agent/prompt/goal-mode.txt @@ -0,0 +1,13 @@ +You are in GOAL mode. In this mode you help the user define a long-running, autonomously-pursued objective and produce the plan that the Goal Loop will drive to completion. + +Goal mode is the setup phase for an autonomous run. You are NOT executing the whole goal yourself in this turn — you are turning the user's intent into a bounded, objectively-decidable goal plus a concrete plan. Once the user starts the goal, a supervised background loop advances one plan step per tick, grades progress against objective criteria (tests, diagnostics, reviewer, expert panel, plan completion), and stops on completion, budget exhaustion, no-progress, or a decision that needs a human. + +Your job in this mode: +- Clarify the objective until it is DECIDABLE. A goal must have a clear finish line you can check without opinion — e.g. "these tests pass", "no diagnostics above warning", "the reviewer finds nothing high-severity", "every plan step is done". If the user's request is vague ("make it better"), ask what "done" means, or propose concrete acceptance criteria and confirm them. +- Establish BOUNDS. A goal runs autonomously, so it must be bounded: a step budget, a token budget, a wallclock limit. Confirm the user is comfortable with the scope, or propose sensible limits. +- Produce a PLAN with the plan tool: an ordered list of steps, each with a title and, where possible, an acceptance criterion. The plan you write here is exactly what the Goal Loop consumes — the loop reads it, executes the active step, and writes evidence back. Keep steps small enough that one step is one coherent unit of progress. +- Surface RISK. If completing the goal will require destructive or hard-to-reverse actions, or touches production, say so and let the user decide before they start the run. + +Do not start executing the plan end-to-end in this turn. Get the goal and plan right, then let the user start the goal loop. If the user asks you to just do it now instead of running it as a goal, switch to normal execution. + +Ground everything in the actual codebase — read before you plan. A plan built on assumptions produces a goal loop that thrashes. diff --git a/packages/deepagent-code/test/agent/goal-mode-agent.test.ts b/packages/deepagent-code/test/agent/goal-mode-agent.test.ts new file mode 100644 index 00000000..9742b5ef --- /dev/null +++ b/packages/deepagent-code/test/agent/goal-mode-agent.test.ts @@ -0,0 +1,66 @@ +import { expect } from "bun:test" +import { FSUtil } from "@deepagent-code/core/fs-util" +import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config/config" +import { Env } from "../../src/env" +import { RuntimeFlags } from "../../src/effect/runtime-flags" +import { EventV2Bridge } from "../../src/event-v2-bridge" +import { Plugin } from "../../src/plugin" +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" +import { ProviderTest } from "../fake/provider" +import { SkillTest } from "../fake/skill" +import { testEffect } from "../lib/effect" + +// V3.9 §D: the `goal` primary agent is registered ONLY when experimentalGoalLoop is on, so goal mode +// never appears in the mode switcher without a backend that can start a goal. + +const provider = ProviderTest.fake() +const configLayer = Config.layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), + Layer.provide(NpmTest.noop), + Layer.provide(FetchHttpClient.layer), +) + +const agentLayerWithFlags = (overrides: Parameters[0]) => { + const pluginLayer = Plugin.layer.pipe( + Layer.provide(EventV2Bridge.defaultLayer), + Layer.provide(configLayer), + Layer.provide(RuntimeFlags.layer(overrides)), + ) + return Agent.layer.pipe( + Layer.provide(configLayer), + Layer.provide(AuthTest.empty), + Layer.provide(SkillTest.empty), + Layer.provide(provider.layer), + Layer.provide(pluginLayer), + Layer.provide(RuntimeFlags.layer(overrides)), + ) +} + +const whenOn = testEffect(agentLayerWithFlags({ experimentalGoalLoop: true, disableDefaultPlugins: true })) +whenOn.instance("goal primary agent IS registered when experimentalGoalLoop is on", () => + Effect.gen(function* () { + const agents = yield* Agent.use.list() + const goal = agents.find((a) => a.name === "goal") + expect(goal).toBeDefined() + expect(goal?.mode).toBe("primary") + }), +) + +const whenOff = testEffect(agentLayerWithFlags({ experimentalGoalLoop: false, disableDefaultPlugins: true })) +whenOff.instance("goal primary agent is NOT registered when experimentalGoalLoop is off", () => + Effect.gen(function* () { + const agents = yield* Agent.use.list() + expect(agents.find((a) => a.name === "goal")).toBeUndefined() + // build + plan remain unconditionally. + expect(agents.find((a) => a.name === "build")).toBeDefined() + expect(agents.find((a) => a.name === "plan")).toBeDefined() + }), +) From 82b4cfa14038aa29b9b14c74769ea31b30d7e64b Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 09:41:50 +0800 Subject: [PATCH 4/6] feat(v3.9): Expert Panel button + Goal status bar UI (P5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SolidJS surface for the two engines. Data plane: - panel-goal.api.ts: raw-request client for /deepagent/panel/{consult,arm} and /goal/{start,pause,resume,stop,status} (by path, no SDK regen). - goal.updated event → session_goal store (mirrors session_plan): types, reducer case, server-sync setter/store, bootstrap type. View plane: - PanelButton (composer toolbar): per-conversation armed toggle seeded from the expertPanelDefault setting; OFF→ON convenes a panel on the current context and shows the verdict, then stays quiet; re-press re-convenes; a small × disarms. - PanelVerdictDialog: decision + confidence + evidence + preserved dissent. - GoalStatusBar (composer dock): live phase + tick/token ledger with pause/resume/stop; dismiss on terminal phase. Reads session_goal. - Settings: "Arm expert panel by default" toggle (general). Gating: the panel button appears only when the `goal` primary agent is present (client-side proxy for the experimental surface being on); the goal mode itself auto-appears in the agent switcher. Backend routes independently fail-close. Tests: goal.updated reducer routing + panel/goal route contract (all method/url/body locked). app/deepagent-code/core all typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../components/deepagent/goal-status-bar.tsx | 110 ++++++++++++++ .../src/components/deepagent/panel-button.tsx | 99 +++++++++++++ .../deepagent/panel-goal-contract.test.ts | 100 +++++++++++++ .../components/deepagent/panel-goal.api.ts | 137 ++++++++++++++++++ .../deepagent/panel-verdict-dialog.tsx | 66 +++++++++ packages/app/src/components/prompt-input.tsx | 9 ++ .../src/components/settings-v2/general.tsx | 12 ++ .../app/src/context/global-sync/bootstrap.ts | 3 + .../context/global-sync/event-reducer.test.ts | 56 ++++++- .../src/context/global-sync/event-reducer.ts | 26 +++- packages/app/src/context/global-sync/types.ts | 13 ++ packages/app/src/context/server-sync.tsx | 30 +++- packages/app/src/context/settings.tsx | 11 ++ packages/app/src/i18n/en.ts | 3 + .../composer/session-composer-region.tsx | 4 + 15 files changed, 675 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/components/deepagent/goal-status-bar.tsx create mode 100644 packages/app/src/components/deepagent/panel-button.tsx create mode 100644 packages/app/src/components/deepagent/panel-goal-contract.test.ts create mode 100644 packages/app/src/components/deepagent/panel-goal.api.ts create mode 100644 packages/app/src/components/deepagent/panel-verdict-dialog.tsx diff --git a/packages/app/src/components/deepagent/goal-status-bar.tsx b/packages/app/src/components/deepagent/goal-status-bar.tsx new file mode 100644 index 00000000..f8838a6d --- /dev/null +++ b/packages/app/src/components/deepagent/goal-status-bar.tsx @@ -0,0 +1,110 @@ +import { Show, createMemo, createSignal } from "solid-js" +import { Button } from "@deepagent-code/ui/button" +import { Icon } from "@deepagent-code/ui/icon" +import { useServerSync } from "@/context/server-sync" +import { useSDK } from "@/context/sdk" +import { pauseGoal, resumeGoal, stopGoal, type PanelGoalClient } from "./panel-goal.api" + +/** + * V3.9 §D — the Goal status bar. Renders above the composer when a goal is running for this session + * (Codex thread-goal style): the phase, a live token/tick budget readout, and pause/resume/stop + * controls. Reads the persistent session_goal store fed by the goal.updated event, so it stays visible + * while the background loop ticks and after a terminal phase (until the user starts a new goal). + */ + +const PHASE_LABEL: Record = { + running: "Running", + paused: "Paused", + done: "Complete", + needs_human: "Needs you", + rolled_back: "Rolled back", + stopped: "Stopped", +} + +const PHASE_ICON: Record[0]["name"]> = { + running: "status-active", + paused: "circle-ban-sign", + done: "circle-check", + needs_human: "circle-x", + rolled_back: "arrow-undo-down", + stopped: "circle-x", +} + +const isTerminal = (phase: string) => + phase === "done" || phase === "rolled_back" || phase === "stopped" || phase === "needs_human" + +export function GoalStatusBar(props: { sessionID: string }) { + const serverSync = useServerSync() + const sdk = useSDK() + const [busy, setBusy] = createSignal(false) + + const goal = createMemo(() => (props.sessionID ? serverSync.data.session_goal[props.sessionID] : undefined)) + const client = () => sdk.client as unknown as PanelGoalClient + + const running = () => goal()?.phase === "running" + const paused = () => goal()?.phase === "paused" + const terminal = () => { + const g = goal() + return g ? isTerminal(g.phase) : false + } + + const withBusy = (fn: () => Promise) => async () => { + if (busy()) return + setBusy(true) + try { + await fn() + } finally { + setBusy(false) + } + } + + const onPause = withBusy(() => pauseGoal(client(), props.sessionID)) + const onResume = withBusy(() => resumeGoal(client(), props.sessionID)) + const onStop = withBusy(() => stopGoal(client(), props.sessionID)) + const onDismiss = () => serverSync.goal.set(props.sessionID, undefined) + + const tokens = () => goal()?.ledger.tokens ?? 0 + const ticks = () => goal()?.ledger.ticks ?? 0 + + return ( + + {(g) => ( +
+ + {PHASE_LABEL[g().phase] ?? g().phase} + + {ticks()} {ticks() === 1 ? "tick" : "ticks"} · {tokens().toLocaleString()} tokens + + 0}> + — {g().gaps[0]} + +
+ + + + + + + + + + + + +
+
+ )} +
+ ) +} diff --git a/packages/app/src/components/deepagent/panel-button.tsx b/packages/app/src/components/deepagent/panel-button.tsx new file mode 100644 index 00000000..711c6854 --- /dev/null +++ b/packages/app/src/components/deepagent/panel-button.tsx @@ -0,0 +1,99 @@ +import { Show, createSignal } from "solid-js" +import { Button } from "@deepagent-code/ui/button" +import { Icon } from "@deepagent-code/ui/icon" +import { Tooltip } from "@deepagent-code/ui/tooltip" +import { useDialog } from "@deepagent-code/ui/context/dialog" +import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" +import { armPanel, consultPanel, type PanelGoalClient } from "./panel-goal.api" +import { PanelVerdictDialog } from "./panel-verdict-dialog" + +/** + * V3.9 §C — the Expert Panel toggle button for the composer toolbar. + * + * Activation semantics (per the product spec): + * - Armed state is per-conversation, seeded from the global `expertPanelDefault` setting. + * - OFF → ON (user arms mid-conversation): immediately convene a panel on the CURRENT context and + * show the verdict, then go quiet ("等待唤醒") — no per-turn re-runs. + * - While ON, pressing again re-convenes on demand. + * - ON → OFF: disarm (no consult). + * The button reflects armed state; a spinner-ish disabled state covers the in-flight consult. + */ +export function PanelButton(props: { sessionID: string }) { + const sdk = useSDK() + const settings = useSettings() + const dialog = useDialog() + // Seed from the global default; per-conversation toggle overrides it locally + server-side. + const [armed, setArmed] = createSignal(settings.general.expertPanelDefault()) + const [busy, setBusy] = createSignal(false) + + const client = () => sdk.client as unknown as PanelGoalClient + + const consultNow = async () => { + const verdict = await consultPanel(client(), { sessionID: props.sessionID }) + if (verdict) dialog.show(() => ) + } + + const onClick = async () => { + if (busy() || !props.sessionID) return + setBusy(true) + try { + if (!armed()) { + // OFF → ON: arm, then convene once on the current context. + await armPanel(client(), props.sessionID, true) + setArmed(true) + await consultNow() + } else { + // Already armed: a press re-convenes on demand (stays armed). + await consultNow() + } + } finally { + setBusy(false) + } + } + + const onDisarm = async (e: MouseEvent) => { + e.stopPropagation() + if (busy() || !props.sessionID) return + setBusy(true) + try { + await armPanel(client(), props.sessionID, false) + setArmed(false) + } finally { + setBusy(false) + } + } + + return ( + +
+ + + + +
+
+ ) +} diff --git a/packages/app/src/components/deepagent/panel-goal-contract.test.ts b/packages/app/src/components/deepagent/panel-goal-contract.test.ts new file mode 100644 index 00000000..91353626 --- /dev/null +++ b/packages/app/src/components/deepagent/panel-goal-contract.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test" +import { + consultPanel, + armPanel, + startGoal, + pauseGoal, + resumeGoal, + stopGoal, + goalStatus, +} from "./panel-goal.api" + +// V3.9 §C/§D route contract: the Expert Panel + Goal Loop UI talks to the raw-request escape-hatch +// routes (NOT the generated SDK). These lock the exact method/url/body so a backend rename of +// /deepagent/panel/* or /deepagent/goal/* breaks CI here instead of shipping a dead UI. Mirrors the +// backend group schemas in server/routes/instance/httpapi/groups/deepagent.ts. +type Recorded = { method: string; url: string; body?: unknown; headers?: Record } + +function client(calls: Recorded[], data: unknown) { + return { + client: { + request: async (options: Recorded): Promise<{ data?: TData }> => { + calls.push(options) + return { data: data as TData } + }, + }, + } +} + +const JSON_HEADERS = { "Content-Type": "application/json" } + +describe("Expert Panel route contract (§C)", () => { + test("consultPanel POSTs /deepagent/panel/consult with the frozen question", async () => { + const calls: Recorded[] = [] + const verdict = { decision: "approve" as const, confidence: 0.9, rounds: 1, evidence: [], dissent: [] } + const result = await consultPanel(client(calls, verdict), { + sessionID: "ses_1", + question: "safe?", + lenses: ["security"], + policy: "security", + }) + expect(calls).toEqual([ + { + method: "POST", + url: "/deepagent/panel/consult", + body: { sessionID: "ses_1", question: "safe?", lenses: ["security"], policy: "security" }, + headers: JSON_HEADERS, + }, + ]) + expect(result).toEqual(verdict) + }) + + test("armPanel POSTs /deepagent/panel/arm and returns the effective armed state", async () => { + const calls: Recorded[] = [] + const armed = await armPanel(client(calls, { sessionID: "ses_1", armed: true }), "ses_1", true) + expect(calls).toEqual([ + { method: "POST", url: "/deepagent/panel/arm", body: { sessionID: "ses_1", armed: true }, headers: JSON_HEADERS }, + ]) + expect(armed).toBe(true) + }) +}) + +describe("Goal Loop route contract (§D)", () => { + test("startGoal POSTs /deepagent/goal/start and returns the snapshot", async () => { + const calls: Recorded[] = [] + const snap = { goalId: "goal_1", planDocId: "plan_1", phase: "running", running: true } + const result = await startGoal(client(calls, snap), { sessionID: "ses_1" }) + expect(calls).toEqual([ + { method: "POST", url: "/deepagent/goal/start", body: { sessionID: "ses_1" }, headers: JSON_HEADERS }, + ]) + expect(result).toEqual(snap) + }) + + test("pause/resume/stop POST the matching lifecycle route with { sessionID }", async () => { + for (const [fn, action] of [ + [pauseGoal, "pause"], + [resumeGoal, "resume"], + [stopGoal, "stop"], + ] as const) { + const calls: Recorded[] = [] + const ok = await fn(client(calls, { ok: true }), "ses_1") + expect(calls).toEqual([ + { method: "POST", url: `/deepagent/goal/${action}`, body: { sessionID: "ses_1" }, headers: JSON_HEADERS }, + ]) + expect(ok).toBe(true) + } + }) + + test("goalStatus GETs /deepagent/goal/status with the sessionID query and unwraps goal", async () => { + const calls: Recorded[] = [] + const snap = { goalId: "goal_1", planDocId: "plan_1", phase: "paused", running: false } + const result = await goalStatus(client(calls, { goal: snap }), "ses 1") + expect(calls).toEqual([{ method: "GET", url: "/deepagent/goal/status?sessionID=ses%201" }]) + expect(result).toEqual(snap) + }) + + test("goalStatus tolerates a null goal", async () => { + const calls: Recorded[] = [] + expect(await goalStatus(client(calls, { goal: null }), "ses_1")).toBeNull() + }) +}) diff --git a/packages/app/src/components/deepagent/panel-goal.api.ts b/packages/app/src/components/deepagent/panel-goal.api.ts new file mode 100644 index 00000000..56a0e295 --- /dev/null +++ b/packages/app/src/components/deepagent/panel-goal.api.ts @@ -0,0 +1,137 @@ +// Pure HTTP client functions + types for the V3.9 Expert Panel (§C) + Goal Loop (§D) UI. Split from +// any .tsx so it carries NO UI imports (the route-contract test imports THIS module). Mirrors the +// review dialog's raw-request pattern (client.client.request by path; POST bodies via `body`). + +export type PanelLens = "correctness" | "security" | "performance" | "architecture" | "repro" + +export type PanelFinding = { + severity: string + category: string + file?: string | null + line?: number | null + summary: string + failureScenario: string + confidence: number +} +export type PanelDissent = { + lens: string + verdict: string + confidence: number + findings: PanelFinding[] +} +export type PanelVerdict = { + decision: "approve" | "revise" | "block" | "needs_human" + confidence: number + rounds: number + evidence: string[] + dissent: PanelDissent[] +} + +export type GoalSnapshot = { + goalId: string + planDocId: string + phase: string + running: boolean +} + +type RawSdkClient = { + client: { + request(options: { + method: string + url: string + body?: unknown + headers?: Record + }): Promise<{ data?: TData }> + } +} + +export type PanelGoalClient = RawSdkClient + +const JSON_HEADERS = { "Content-Type": "application/json" } + +// ── Expert Panel (§C) ──────────────────────────────────────────────────────── + +/** Convene the Expert Panel on the current session context; returns the deterministic verdict. */ +export const consultPanel = async ( + client: PanelGoalClient, + input: { + sessionID: string + question?: string + codeRefs?: string[] + lenses?: PanelLens[] + maxRounds?: number + policy?: "default" | "security" + }, +): Promise => { + const response = await client.client.request({ + method: "POST", + url: "/deepagent/panel/consult", + body: input, + headers: JSON_HEADERS, + }) + return response.data +} + +/** Set the per-session panel armed flag (the button toggle). */ +export const armPanel = async ( + client: PanelGoalClient, + sessionID: string, + armed: boolean, +): Promise => { + const response = await client.client.request<{ sessionID: string; armed: boolean }>({ + method: "POST", + url: "/deepagent/panel/arm", + body: { sessionID, armed }, + headers: JSON_HEADERS, + }) + return response.data?.armed ?? armed +} + +// ── Goal Loop (§D) ─────────────────────────────────────────────────────────── + +export const startGoal = async ( + client: PanelGoalClient, + input: { + sessionID: string + criteria?: { kind: string; commands?: string[]; maxSeverity?: string; severityAtMost?: string }[] + limits?: { maxTicks?: number; maxTokens?: number; maxWallclockMs?: number; maxCost?: number } + stallThreshold?: number + }, +): Promise => { + const response = await client.client.request({ + method: "POST", + url: "/deepagent/goal/start", + body: input, + headers: JSON_HEADERS, + }) + return response.data +} + +const goalMutate = async ( + client: PanelGoalClient, + action: "pause" | "resume" | "stop", + sessionID: string, +): Promise => { + const response = await client.client.request<{ ok: boolean }>({ + method: "POST", + url: `/deepagent/goal/${action}`, + body: { sessionID }, + headers: JSON_HEADERS, + }) + return response.data?.ok ?? false +} + +export const pauseGoal = (client: PanelGoalClient, sessionID: string) => goalMutate(client, "pause", sessionID) +export const resumeGoal = (client: PanelGoalClient, sessionID: string) => goalMutate(client, "resume", sessionID) +export const stopGoal = (client: PanelGoalClient, sessionID: string) => goalMutate(client, "stop", sessionID) + +export const goalStatus = async ( + client: PanelGoalClient, + sessionID: string, +): Promise => { + const response = await client.client.request<{ goal: GoalSnapshot | null }>({ + method: "GET", + url: `/deepagent/goal/status?sessionID=${encodeURIComponent(sessionID)}`, + }) + return response.data?.goal ?? null +} diff --git a/packages/app/src/components/deepagent/panel-verdict-dialog.tsx b/packages/app/src/components/deepagent/panel-verdict-dialog.tsx new file mode 100644 index 00000000..f8b0253d --- /dev/null +++ b/packages/app/src/components/deepagent/panel-verdict-dialog.tsx @@ -0,0 +1,66 @@ +import { For, Show } from "solid-js" +import { Dialog } from "@deepagent-code/ui/v2/dialog-v2" +import type { PanelVerdict } from "./panel-goal.api" + +/** + * V3.9 §C — renders the Expert Panel verdict from a standalone consult. Shows the decision, the + * arbiter's confidence + round count, the grounding evidence, and any preserved dissent (§C.8 不丢信息). + */ + +const DECISION_LABEL: Record = { + approve: "Approve", + revise: "Revise", + block: "Block", + needs_human: "Needs a human", +} + +export function PanelVerdictDialog(props: { verdict: PanelVerdict }) { + const v = () => props.verdict + return ( + +
+
+ {DECISION_LABEL[v().decision]} + + {(v().confidence * 100).toFixed(0)}% confidence · {v().rounds} {v().rounds === 1 ? "round" : "rounds"} + +
+ + 0}> +
+ Evidence +
    + + {(e) =>
  • {e}
  • } +
    +
+
+
+ + 0}> +
+ Dissent (overruled) + + {(d) => ( +
+ + {d.lens} — {d.verdict} ({(d.confidence * 100).toFixed(0)}%) + + + {(f) => ( +
+ {f.summary} + — {f.file}{f.line != null ? `:${f.line}` : ""} +
{f.failureScenario}
+
+ )} +
+
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a2d21b1d..5321259b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -54,6 +54,7 @@ import { usePlatform } from "@/context/platform" import { serverAttachmentFile } from "./prompt-input/server-attachment" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" +import { PanelButton } from "@/components/deepagent/panel-button" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" @@ -691,6 +692,11 @@ export const PromptInput: Component = (props) => { .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) + // V3.9 §C/§D: the `goal` primary agent is registered only when the goal-loop flag is on. Use its + // presence as the client-side signal that the DeepAgent experimental surface (panel + goal) is + // enabled, so the panel button only appears on servers that can actually convene a panel. (A future + // dedicated capability endpoint would let the two flags be gated independently in the UI.) + const deepagentExperimentalOn = createMemo(() => agentNames().includes("goal")) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return @@ -1885,6 +1891,9 @@ export const PromptInput: Component = (props) => { + + +
{ />
+ + +
+ settings.general.setExpertPanelDefault(checked)} + /> +
+
) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index fe66bb35..6d0f32c5 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -28,6 +28,9 @@ type GlobalStore = { session_plan: { [sessionID: string]: import("./types").SessionPlan } + session_goal: { + [sessionID: string]: import("./types").SessionGoal + } provider: NormalizedProviderListResponse provider_auth: ProviderAuthResponse config: Config diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index b896ab31..160ace11 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -3,7 +3,7 @@ import type { Message, Part, PermissionRequest, Project, QuestionRequest, Sessio import { createRoot } from "solid-js" import { isServer } from "solid-js/web" import { createStore, reconcile, unwrap } from "solid-js/store" -import type { SessionPlan, State } from "./types" +import type { SessionGoal, SessionPlan, State } from "./types" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer" const rootSession = (input: { id: string; parentID?: string; archived?: number }) => @@ -748,3 +748,57 @@ describe("plan.updated reconcile (session_plan)", () => { // the plan system: the backend no longer emits `todo.updated` (both todowrite tool writers were // removed) and the reducer no longer handles it. The plan panel's live-update coverage lives in the // `plan.updated reconcile (session_plan)` describe block below. + +// V3.9 §D: the goal.updated event feeds the live Goal status bar via a session_goal store (analogous +// to session_plan). Verifies the reducer routes the payload to setSessionGoal and that consecutive +// ticks advance the phase + ledger. +describe("goal.updated reducer (session_goal)", () => { + const goalEvent = (sessionID: string, phase: string, tokens: number) => ({ + type: "goal.updated", + properties: { + sessionID, + goalId: "goal_1", + planDocId: "plan_1", + phase, + ledger: { ticks: tokens / 10, tokens, cost: 0, wallclockMs: 1000 }, + stallCount: 0, + gaps: phase === "needs_human" ? ["reviewer_clean unmet"] : [], + }, + }) + + const dispatch = (setSessionGoal: (sid: string, g: SessionGoal | undefined) => void, event: unknown) => { + const [store, setStore] = createStore(baseState()) + applyDirectoryEvent({ + event: event as never, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + setSessionGoal, + }) + } + + test("routes goal.updated to setSessionGoal and advances phase + ledger", () => { + createRoot((dispose) => { + const [goalStore, setGoalStore] = createStore<{ g: Record }>({ g: {} }) + const setSessionGoal = (sid: string, goal: SessionGoal | undefined) => { + if (!goal) return + setGoalStore("g", sid, reconcile(goal) as never) + } + + dispatch(setSessionGoal, goalEvent("ses_1", "running", 10)) + expect(goalStore.g.ses_1.phase).toBe("running") + expect(goalStore.g.ses_1.ledger.tokens).toBe(10) + + dispatch(setSessionGoal, goalEvent("ses_1", "running", 120)) + expect(goalStore.g.ses_1.ledger.tokens).toBe(120) + + dispatch(setSessionGoal, goalEvent("ses_1", "needs_human", 200)) + expect(goalStore.g.ses_1.phase).toBe("needs_human") + expect(goalStore.g.ses_1.gaps).toEqual(["reviewer_clean unmet"]) + + dispose() + }) + }) +}) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 107bc593..a41e9c2b 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -10,7 +10,7 @@ import type { SessionStatus, SnapshotFileDiff, } from "@deepagent-code/sdk/v2/client" -import type { State, VcsCache, SessionPlan } from "./types" +import type { State, VcsCache, SessionPlan, SessionGoal } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" import { diffs as list, message as clean } from "@/utils/diffs" @@ -85,6 +85,7 @@ export function applyDirectoryEvent(input: { loadLsp: () => void vcsCache?: VcsCache setSessionPlan?: (sessionID: string, plan: SessionPlan | undefined) => void + setSessionGoal?: (sessionID: string, goal: SessionGoal | undefined) => void retainedLimit?: number }) { const event = input.event @@ -187,6 +188,29 @@ export function applyDirectoryEvent(input: { }) break } + case "goal.updated": { + // V3.9 §D: the live Goal Loop status from the driver. Stored under a distinct session_goal key + // (like session_plan) so the status bar persists while the session is idle between ticks. A + // terminal phase does NOT clear it — the UI shows the final state until the user dismisses/restarts. + const props = event.properties as { + sessionID: string + goalId: string + planDocId: string + phase: string + ledger: SessionGoal["ledger"] + stallCount: number + gaps: string[] + } + input.setSessionGoal?.(props.sessionID, { + goalId: props.goalId, + planDocId: props.planDocId, + phase: props.phase, + ledger: props.ledger, + stallCount: props.stallCount, + gaps: props.gaps, + }) + break + } case "session.status": { const props = event.properties as { sessionID: string; status: SessionStatus } input.setStore("session_status", props.sessionID, reconcile(props.status)) diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 858b9dfe..0260548c 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -49,6 +49,19 @@ export type SessionPlan = { total: number } +// V3.9 §D: live Goal Loop status mirrored from the backend goal.updated event. Declared here (not in +// server-sync) so both the reducer and the sync context import it without a cycle. Mirrors the +// GoalManager snapshot + budget ledger the status bar renders. +export type SessionGoal = { + goalId: string + planDocId: string + // running | paused | done | needs_human | rolled_back | stopped + phase: string + ledger: { ticks: number; tokens: number; cost: number; wallclockMs: number } + stallCount: number + gaps: string[] +} + export type State = { status: "loading" | "partial" | "complete" agent: Agent[] diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b5ae1dac..423d73de 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -45,9 +45,9 @@ import { retry } from "@deepagent-code/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { persisted } from "@/utils/persist" import { toggleMcp } from "./global-sync/mcp" -import type { SessionPlan, SessionPlanStep } from "./global-sync/types" +import type { SessionPlan, SessionPlanStep, SessionGoal } from "./global-sync/types" -export type { SessionPlan, SessionPlanStep } +export type { SessionPlan, SessionPlanStep, SessionGoal } // 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 @@ -72,6 +72,11 @@ type GlobalStore = { session_plan: { [sessionID: string]: SessionPlan } + // V3.9 §D: the live Goal Loop status per session, pushed by the goal.updated event. Persistent like + // session_plan so the status bar survives the session going idle between background ticks. + session_goal: { + [sessionID: string]: SessionGoal + } provider: NormalizedProviderListResponse provider_auth: ProviderAuthResponse config: Config @@ -145,6 +150,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { }, project: [], session_plan: {}, + session_goal: {}, provider_auth: {}, get path() { const EMPTY: Path = { @@ -270,6 +276,22 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { setGlobalStore("session_plan", sessionID, reconcile(plan, { key: "step_id" })) } + // V3.9 §D: set/clear the live goal status for a session (mirrors setSessionPlan). The goal object is + // a single record per session; reconcile keeps field-level updates minimal-diff. + const setSessionGoal = (sessionID: string, goal: SessionGoal | undefined) => { + if (!sessionID) return + if (!goal) { + setGlobalStore( + "session_goal", + produce((draft) => { + delete draft[sessionID] + }), + ) + return + } + setGlobalStore("session_goal", sessionID, reconcile(goal)) + } + const paused = () => untrack(() => globalStore.reload) !== undefined const queue = createRefreshQueue({ @@ -487,6 +509,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { setStore, push: queue.push, setSessionPlan, + setSessionGoal, retainedLimit: sessionMeta.get(key)?.limit, vcsCache: children.vcsCache.get(key), loadLsp: () => { @@ -637,6 +660,9 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) { plan: { set: setSessionPlan, }, + goal: { + set: setSessionGoal, + }, mcp: { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 7e170946..4874b77f 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -27,6 +27,9 @@ export interface Settings { shellToolPartsExpanded: boolean editToolPartsExpanded: boolean showSessionProgressBar: boolean + // V3.9 §C: whether new conversations start with the Expert Panel armed. Client-side default that + // the panel button seeds from; the per-session armed state is tracked server-side. + expertPanelDefault: boolean } appearance: { fontSize: number @@ -104,6 +107,7 @@ const defaultSettings: Settings = { shellToolPartsExpanded: false, editToolPartsExpanded: false, showSessionProgressBar: true, + expertPanelDefault: false, }, appearance: { fontSize: 14, @@ -201,6 +205,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowSessionProgressBar(value: boolean) { setStore("general", "showSessionProgressBar", value) }, + expertPanelDefault: withFallback( + () => store.general?.expertPanelDefault, + defaultSettings.general.expertPanelDefault, + ), + setExpertPanelDefault(value: boolean) { + setStore("general", "expertPanelDefault", value) + }, }, appearance: { fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 9b090664..c287dd7f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1115,6 +1115,9 @@ export const dict = { "settings.general.row.showSessionProgressBar.title": "Show session progress bar", "settings.general.row.showSessionProgressBar.description": "Display the animated progress bar at the top of the session when the agent is working", + "settings.general.row.expertPanelDefault.title": "Arm expert panel by default", + "settings.general.row.expertPanelDefault.description": + "Start new conversations with the expert panel armed, so its button convenes a review of the current context on demand", "settings.general.row.newLayoutDesigns.title": "New layout and designs", "settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI", "settings.general.row.pinchZoom.title": "Pinch to zoom", diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 7d6b92f7..beac62b5 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -15,6 +15,7 @@ import { SessionFollowupDock } from "@/pages/session/composer/session-followup-d import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { GoalStatusBar } from "@/components/deepagent/goal-status-bar" import type { FollowupDraft } from "@/components/prompt-input/submit" import { createResizeObserver } from "@solid-primitives/resize-observer" @@ -248,6 +249,9 @@ export function SessionComposerRegion(props: { "margin-top": `${-lift()}px`, }} > + + + Date: Fri, 10 Jul 2026 11:17:49 +0800 Subject: [PATCH 5/6] feat(v3.9): independent capability gating + CLI slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Panel (§C) and Goal Loop (§D) are now gated independently in both UIs, driveable from the CLI, and their loose ends closed — replacing the earlier goal-agent proxy. Capabilities (backend): /global/capabilities.features advertises expertPanel + goalLoop from the env-backed RuntimeFlags, so UI availability == route availability. Same source the routes fail-close on. GUI: prompt-input fetches capabilities and shows the panel button iff expertPanel is on (was: proxied on the goal agent's presence). Goal mode self-gates — the `goal` primary agent only registers when goalLoop is on. The two flags are now fully independent in the UI. CLI (TUI): /panel convenes the expert panel on the current conversation and toasts the verdict; /goal starts the goal loop (the text after the command seeds a plan when the session has none, else the loop drives the existing plan). Each gated on its Flag (added EXPERT_PANEL + GOAL_LOOP to core/flag), hidden when its flag is off. Raw /deepagent routes (no SDK regen). Follow-ups closed: - panelArmed is now null-by-default (= not explicitly toggled). New /deepagent/panel/status resolves the EFFECTIVE armed state (explicit toggle, else the server's expertPanelDefault setting), and the button seeds from it — so the default is server-authoritative, not a client guess. setPanelArmed writes an explicit choice; resolvePanelArmed does the fallback. - GoalManager.start accepts an optional objective and seeds a minimal single-step plan from it when the session has no plan, so /goal works without a prior plan mode pass. Tests: capabilities advertise the flags; fetchCapabilities + fetchPanelStatus contract; effective-armed resolution; objective-seeded plan is a valid goal carrier. Server harnesses provide RuntimeFlags. All four packages typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../src/components/deepagent/panel-button.tsx | 22 ++++-- .../deepagent/panel-goal-contract.test.ts | 33 ++++++++ .../components/deepagent/panel-goal.api.ts | 40 +++++++++- packages/app/src/components/prompt-input.tsx | 17 ++-- packages/core/src/deepagent/session-state.ts | 45 ++++++----- packages/core/src/flag/flag.ts | 8 ++ .../session-state-panel-goal.test.ts | 32 ++++---- .../instance/httpapi/groups/deepagent.ts | 24 ++++++ .../routes/instance/httpapi/groups/global.ts | 3 + .../instance/httpapi/handlers/deepagent.ts | 24 +++++- .../instance/httpapi/handlers/global.ts | 7 ++ .../src/session/goal-manager.ts | 33 +++++++- .../test/server/httpapi-control-plane.test.ts | 2 + .../test/server/httpapi-global.test.ts | 14 ++++ .../test/session/goal-driver.test.ts | 37 +++++++++ packages/tui/src/component/prompt/index.tsx | 77 +++++++++++++++++++ 16 files changed, 365 insertions(+), 53 deletions(-) diff --git a/packages/app/src/components/deepagent/panel-button.tsx b/packages/app/src/components/deepagent/panel-button.tsx index 711c6854..282b5b14 100644 --- a/packages/app/src/components/deepagent/panel-button.tsx +++ b/packages/app/src/components/deepagent/panel-button.tsx @@ -1,11 +1,10 @@ -import { Show, createSignal } from "solid-js" +import { Show, createSignal, createResource } from "solid-js" import { Button } from "@deepagent-code/ui/button" import { Icon } from "@deepagent-code/ui/icon" import { Tooltip } from "@deepagent-code/ui/tooltip" import { useDialog } from "@deepagent-code/ui/context/dialog" import { useSDK } from "@/context/sdk" -import { useSettings } from "@/context/settings" -import { armPanel, consultPanel, type PanelGoalClient } from "./panel-goal.api" +import { armPanel, consultPanel, fetchPanelStatus, type PanelGoalClient } from "./panel-goal.api" import { PanelVerdictDialog } from "./panel-verdict-dialog" /** @@ -21,14 +20,21 @@ import { PanelVerdictDialog } from "./panel-verdict-dialog" */ export function PanelButton(props: { sessionID: string }) { const sdk = useSDK() - const settings = useSettings() const dialog = useDialog() - // Seed from the global default; per-conversation toggle overrides it locally + server-side. - const [armed, setArmed] = createSignal(settings.general.expertPanelDefault()) const [busy, setBusy] = createSignal(false) + const [armedOverride, setArmedOverride] = createSignal(undefined) const client = () => sdk.client as unknown as PanelGoalClient + // Seed the armed state from the SERVER's effective status (explicit toggle, else global default), + // so the button reflects the server-configured default rather than a client-side guess. A local + // override wins once the user toggles this session. + const [status] = createResource( + () => props.sessionID || undefined, + (sessionID) => fetchPanelStatus(client(), sessionID), + ) + const armed = () => armedOverride() ?? status()?.armed ?? false + const consultNow = async () => { const verdict = await consultPanel(client(), { sessionID: props.sessionID }) if (verdict) dialog.show(() => ) @@ -41,7 +47,7 @@ export function PanelButton(props: { sessionID: string }) { if (!armed()) { // OFF → ON: arm, then convene once on the current context. await armPanel(client(), props.sessionID, true) - setArmed(true) + setArmedOverride(true) await consultNow() } else { // Already armed: a press re-convenes on demand (stays armed). @@ -58,7 +64,7 @@ export function PanelButton(props: { sessionID: string }) { setBusy(true) try { await armPanel(client(), props.sessionID, false) - setArmed(false) + setArmedOverride(false) } finally { setBusy(false) } diff --git a/packages/app/src/components/deepagent/panel-goal-contract.test.ts b/packages/app/src/components/deepagent/panel-goal-contract.test.ts index 91353626..f1458e6f 100644 --- a/packages/app/src/components/deepagent/panel-goal-contract.test.ts +++ b/packages/app/src/components/deepagent/panel-goal-contract.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test" import { consultPanel, armPanel, + fetchPanelStatus, startGoal, pauseGoal, resumeGoal, stopGoal, goalStatus, + fetchCapabilities, } from "./panel-goal.api" // V3.9 §C/§D route contract: the Expert Panel + Goal Loop UI talks to the raw-request escape-hatch @@ -57,6 +59,18 @@ describe("Expert Panel route contract (§C)", () => { ]) expect(armed).toBe(true) }) + + test("fetchPanelStatus GETs /deepagent/panel/status and reports armed + explicit", async () => { + const calls: Recorded[] = [] + const status = await fetchPanelStatus(client(calls, { armed: true, explicit: false }), "ses 1") + expect(calls).toEqual([{ method: "GET", url: "/deepagent/panel/status?sessionID=ses%201" }]) + expect(status).toEqual({ armed: true, explicit: false }) + }) + + test("fetchPanelStatus tolerates a missing body (disarmed, not explicit)", async () => { + const calls: Recorded[] = [] + expect(await fetchPanelStatus(client(calls, {}), "ses_1")).toEqual({ armed: false, explicit: false }) + }) }) describe("Goal Loop route contract (§D)", () => { @@ -98,3 +112,22 @@ describe("Goal Loop route contract (§D)", () => { expect(await goalStatus(client(calls, { goal: null }), "ses_1")).toBeNull() }) }) + +describe("capabilities gating", () => { + test("fetchCapabilities GETs /global/capabilities and reads the feature flags", async () => { + const calls: Recorded[] = [] + const caps = await fetchCapabilities(client(calls, { features: { expertPanel: true, goalLoop: false } })) + expect(calls).toEqual([{ method: "GET", url: "/global/capabilities" }]) + expect(caps).toEqual({ expertPanel: true, goalLoop: false }) + }) + + test("fetchCapabilities treats a server that omits the fields as disabled", async () => { + const calls: Recorded[] = [] + expect(await fetchCapabilities(client(calls, { features: {} }))).toEqual({ + expertPanel: false, + goalLoop: false, + }) + // and a server with no features object at all + expect(await fetchCapabilities(client(calls, {}))).toEqual({ expertPanel: false, goalLoop: false }) + }) +}) diff --git a/packages/app/src/components/deepagent/panel-goal.api.ts b/packages/app/src/components/deepagent/panel-goal.api.ts index 56a0e295..af4e1416 100644 --- a/packages/app/src/components/deepagent/panel-goal.api.ts +++ b/packages/app/src/components/deepagent/panel-goal.api.ts @@ -47,6 +47,28 @@ type RawSdkClient = { export type PanelGoalClient = RawSdkClient +/** Which V3.9 experimental subsystems this server has enabled (from /global/capabilities.features). */ +export type DeepAgentCapabilities = { + expertPanel: boolean + goalLoop: boolean +} + +/** + * Read the server's experimental capabilities so the UI can independently gate the panel button and + * goal mode. Fetched via the raw path (no SDK regen); tolerant of an older server that omits the + * fields (treated as disabled). + */ +export const fetchCapabilities = async (client: PanelGoalClient): Promise => { + const response = await client.client.request<{ features?: Partial }>({ + method: "GET", + url: "/global/capabilities", + }) + return { + expertPanel: response.data?.features?.expertPanel ?? false, + goalLoop: response.data?.features?.goalLoop ?? false, + } +} + const JSON_HEADERS = { "Content-Type": "application/json" } // ── Expert Panel (§C) ──────────────────────────────────────────────────────── @@ -72,7 +94,7 @@ export const consultPanel = async ( return response.data } -/** Set the per-session panel armed flag (the button toggle). */ +/** Set the per-session panel armed flag (the button toggle). Returns the effective armed state. */ export const armPanel = async ( client: PanelGoalClient, sessionID: string, @@ -87,6 +109,22 @@ export const armPanel = async ( return response.data?.armed ?? armed } +/** + * Resolve the EFFECTIVE armed state for a session: the explicit per-session toggle if set, else the + * server's global expertPanelDefault. Lets the button seed from the server default without the client + * guessing (the client setting is only a hint; the server is authoritative). + */ +export const fetchPanelStatus = async ( + client: PanelGoalClient, + sessionID: string, +): Promise<{ armed: boolean; explicit: boolean }> => { + const response = await client.client.request<{ armed: boolean; explicit: boolean }>({ + method: "GET", + url: `/deepagent/panel/status?sessionID=${encodeURIComponent(sessionID)}`, + }) + return { armed: response.data?.armed ?? false, explicit: response.data?.explicit ?? false } +} + // ── Goal Loop (§D) ─────────────────────────────────────────────────────────── export const startGoal = async ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5321259b..580fd9a3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -55,6 +55,7 @@ import { serverAttachmentFile } from "./prompt-input/server-attachment" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { PanelButton } from "@/components/deepagent/panel-button" +import { fetchCapabilities } from "@/components/deepagent/panel-goal.api" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments } from "./prompt-input/attachments" import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files" @@ -692,11 +693,15 @@ export const PromptInput: Component = (props) => { .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) - // V3.9 §C/§D: the `goal` primary agent is registered only when the goal-loop flag is on. Use its - // presence as the client-side signal that the DeepAgent experimental surface (panel + goal) is - // enabled, so the panel button only appears on servers that can actually convene a panel. (A future - // dedicated capability endpoint would let the two flags be gated independently in the UI.) - const deepagentExperimentalOn = createMemo(() => agentNames().includes("goal")) + // V3.9 §C/§D: gate the panel button on the server's advertised capabilities (/global/capabilities), + // so the Expert Panel (§C) and Goal Loop (§D) flags are honoured INDEPENDENTLY — the panel button + // appears iff expertPanel is enabled, regardless of goal mode. (goal mode gates itself: the `goal` + // primary agent is only registered when goalLoop is on, so it self-appears in the switcher.) + const [capabilities] = createResource( + () => (params.id ? "capabilities" : undefined), + () => fetchCapabilities(sdk.client as never), + ) + const panelAvailable = createMemo(() => capabilities()?.expertPanel === true) const handleAtSelect = (option: AtOption | undefined) => { if (!option) return @@ -1891,7 +1896,7 @@ export const PromptInput: Component = (props) => { - + diff --git a/packages/core/src/deepagent/session-state.ts b/packages/core/src/deepagent/session-state.ts index a52cf643..b8fa9c98 100644 --- a/packages/core/src/deepagent/session-state.ts +++ b/packages/core/src/deepagent/session-state.ts @@ -54,11 +54,12 @@ export type SessionRunState = { // update. The SEMANTIC primary trigger for the progress nudge ("a step probably just finished"). // Reset with the counter on a real status change. validationPassedSinceReport: boolean - // V3.9 §C: whether this conversation has the Expert Panel "armed". Seeded from the global - // `expertPanelDefault` setting when the session is created; toggled per-conversation from the chat - // dialog. Armed means the user may convene a panel (button press) and — when a goal loop is running - // — the loop may convene the panel at high-risk decision points. Quiet until woken (§C activation). - panelArmed: boolean + // V3.9 §C: whether this conversation has EXPLICITLY toggled the Expert Panel "armed" state from the + // chat dialog. `null` = never toggled → the effective armed state falls back to the global + // `expertPanelDefault` setting (resolved server-side, so the UI reflects the server default without a + // client round-trip guess). Armed means the user may convene a panel (button press) and — when a goal + // loop is running — the loop may convene the panel at high-risk decision points (§C activation). + panelArmed: boolean | null // V3.9 §D: a lightweight pointer to the goal currently driven for this session (the authoritative // loop state lives in the DocumentStore run_context doc — this is only enough to find/resume it and // reflect its phase in the UI). Null when no goal is running. @@ -109,7 +110,7 @@ export const getOrCreate = (sessionId: string, mode: AgentMode): SessionRunState plan: null, mutationsSinceReport: 0, validationPassedSinceReport: false, - panelArmed: false, + panelArmed: null, activeGoal: null, createdAt: new Date().toISOString(), completedAt: null, @@ -197,16 +198,10 @@ export const setPlan = (sessionId: string, plan: PlanDoc): void => { export const getPlan = (sessionId: string): PlanDoc | null => sessions.get(sessionId)?.plan ?? null // V3.9 §C — Expert Panel per-session arming. -// Seed the armed flag once at session creation from the global default (only if the session exists and -// has not been explicitly toggled). Returns the effective armed state. -export const seedPanelArmed = (sessionId: string, globalDefault: boolean): boolean => { - const state = sessions.get(sessionId) - if (!state) return globalDefault - state.panelArmed = globalDefault - saveToDisk() - return state.panelArmed -} - +// The raw per-session toggle (null = never explicitly toggled). setPanelArmed writes an explicit +// user choice; resolvePanelArmed resolves the EFFECTIVE state, falling back to the global default when +// the session has no explicit choice. This keeps the global `expertPanelDefault` setting authoritative +// for new conversations while an explicit toggle always wins. export const setPanelArmed = (sessionId: string, armed: boolean): void => { const state = sessions.get(sessionId) if (!state) return @@ -214,6 +209,19 @@ export const setPanelArmed = (sessionId: string, armed: boolean): void => { saveToDisk() } +/** The raw explicit toggle, or null when the session has never toggled it. */ +export const panelArmedChoice = (sessionId: string): boolean | null => sessions.get(sessionId)?.panelArmed ?? null + +/** + * Effective armed state: the explicit per-session choice if set, else the supplied global default. Pass + * the resolved `expertPanelDefault` setting so the fallback reflects the server's configured default. + */ +export const resolvePanelArmed = (sessionId: string, globalDefault: boolean): boolean => { + const choice = sessions.get(sessionId)?.panelArmed + return choice ?? globalDefault +} + +/** Back-compat: effective armed state with a hard `false` fallback (no global default available). */ export const isPanelArmed = (sessionId: string): boolean => sessions.get(sessionId)?.panelArmed ?? false // V3.9 §D — active-goal pointer. The GoalLoop status doc in the DocumentStore is authoritative; this @@ -356,8 +364,9 @@ function normalizeState(state: SessionRunState): SessionRunState { // Backfill: sessions persisted before U10 have no counter on disk. mutationsSinceReport: state.mutationsSinceReport ?? 0, validationPassedSinceReport: state.validationPassedSinceReport ?? false, - // Backfill: sessions persisted before V3.9 §C/§D have neither slot on disk. - panelArmed: state.panelArmed ?? false, + // Backfill: sessions persisted before V3.9 §C/§D have neither slot on disk. panelArmed backfills to + // null (= not explicitly toggled → follows the global default), NOT false. + panelArmed: state.panelArmed ?? null, activeGoal: state.activeGoal ?? null, } sessions.set(state.sessionId, normalized) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 330238ba..9c209fd4 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -47,6 +47,14 @@ export const Flag = { DEEPAGENT_CODE_WORKSPACE_ID: process.env["DEEPAGENT_CODE_WORKSPACE_ID"], DEEPAGENT_CODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_WORKSPACES"), DEEPAGENT_CODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_SESSION_SWITCHER"), + // V3.9 §C/§D — mirror the server RuntimeFlags so the TUI can gate the /panel and /goal slash + // commands (evaluated at access time so the CLI/tests can set the env at runtime). + get DEEPAGENT_CODE_EXPERIMENTAL_EXPERT_PANEL() { + return enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_EXPERT_PANEL") + }, + get DEEPAGENT_CODE_EXPERIMENTAL_GOAL_LOOP() { + return enabledByExperimental("DEEPAGENT_CODE_EXPERIMENTAL_GOAL_LOOP") + }, // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/test/deepagent/session-state-panel-goal.test.ts b/packages/core/test/deepagent/session-state-panel-goal.test.ts index ad81eeba..43435f4e 100644 --- a/packages/core/test/deepagent/session-state-panel-goal.test.ts +++ b/packages/core/test/deepagent/session-state-panel-goal.test.ts @@ -13,28 +13,30 @@ describe("session-state panel arming (§C)", () => { SessionState.configure(mkdtempSync(path.join(tmpdir(), "panel-arm-"))) }) - test("a new session starts disarmed", () => { + test("a new session has no explicit choice → follows the global default", () => { SessionState.getOrCreate("panel-s1", "high") - expect(SessionState.isPanelArmed("panel-s1")).toBe(false) + expect(SessionState.panelArmedChoice("panel-s1")).toBeNull() + // effective state falls back to the supplied global default (both directions) + expect(SessionState.resolvePanelArmed("panel-s1", false)).toBe(false) + expect(SessionState.resolvePanelArmed("panel-s1", true)).toBe(true) }) - test("seedPanelArmed applies the global default", () => { + test("an explicit toggle overrides the global default", () => { SessionState.getOrCreate("panel-s2", "high") - expect(SessionState.seedPanelArmed("panel-s2", true)).toBe(true) - expect(SessionState.isPanelArmed("panel-s2")).toBe(true) - }) + SessionState.setPanelArmed("panel-s2", false) + // explicit false wins even when the global default is true + expect(SessionState.panelArmedChoice("panel-s2")).toBe(false) + expect(SessionState.resolvePanelArmed("panel-s2", true)).toBe(false) - test("setPanelArmed overrides the seeded default per conversation", () => { - SessionState.getOrCreate("panel-s3", "high") - SessionState.seedPanelArmed("panel-s3", true) - SessionState.setPanelArmed("panel-s3", false) - expect(SessionState.isPanelArmed("panel-s3")).toBe(false) + SessionState.setPanelArmed("panel-s2", true) + expect(SessionState.resolvePanelArmed("panel-s2", false)).toBe(true) }) - test("arming an unknown session is a no-op, isPanelArmed reads false", () => { - expect(SessionState.isPanelArmed("panel-missing")).toBe(false) + test("arming an unknown session is a no-op", () => { + expect(SessionState.panelArmedChoice("panel-missing")).toBeNull() SessionState.setPanelArmed("panel-missing", true) // must not throw / create state - expect(SessionState.isPanelArmed("panel-missing")).toBe(false) + expect(SessionState.panelArmedChoice("panel-missing")).toBeNull() + expect(SessionState.resolvePanelArmed("panel-missing", false)).toBe(false) }) }) @@ -99,7 +101,7 @@ describe("session-state active-goal pointer (§D)", () => { }) // getOrCreate on an existing session runs normalizeState — the slots must be preserved. SessionState.getOrCreate("goal-s5", "high") - expect(SessionState.isPanelArmed("goal-s5")).toBe(true) + expect(SessionState.panelArmedChoice("goal-s5")).toBe(true) expect(SessionState.getActiveGoal("goal-s5")?.goalId).toBe("goal_x") }) }) 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 016ad144..a1e8ae51 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 @@ -274,9 +274,19 @@ export const DeepAgentPanelArmInput = Schema.Struct({ }) export const DeepAgentPanelArmResult = Schema.Struct({ sessionID: Schema.String, armed: Schema.Boolean }) +/** GET /deepagent/panel/status — the EFFECTIVE armed state (explicit toggle, else global default). */ +export const DeepAgentPanelStatusResult = Schema.Struct({ + sessionID: Schema.String, + armed: Schema.Boolean, + // Whether `armed` came from an explicit per-session toggle (true) or the global default (false). + explicit: Schema.Boolean, +}) + /** POST /deepagent/goal/start */ export const DeepAgentGoalStartInput = Schema.Struct({ sessionID: Schema.String, + // Optional free-text objective (CLI `/goal `); seeds a plan when the session has none. + objective: Schema.optional(Schema.String), criteria: Schema.optional( Schema.Array( Schema.Struct({ @@ -515,6 +525,20 @@ export const DeepAgentApi = HttpApi.make("deepagent").add( }), ), ) + .add( + HttpApiEndpoint.get("panelStatus", `${root}/panel/status`, { + query: Schema.Struct({ ...WorkspaceRoutingQueryFields, sessionID: Schema.String }), + success: described(DeepAgentPanelStatusResult, "Effective panel armed state (explicit toggle or global default)"), + error: DeepAgentPromotionError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "deepagent.panel.status", + summary: "Resolve the effective Expert Panel armed state", + description: + "V3.9 §C: returns the explicit per-session toggle if set, else the global expertPanelDefault — so the UI seeds the button from the server's default without guessing.", + }), + ), + ) .add( HttpApiEndpoint.post("goalStart", `${root}/goal/start`, { query: WorkspaceRoutingQuery, diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts index 1cc8fc61..2638602d 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/groups/global.ts @@ -28,6 +28,9 @@ const GlobalCapabilities = Schema.Struct({ sessions: Schema.Boolean, pty: Schema.Boolean, workspaces: Schema.Boolean, + // V3.9 §C/§D — independently-gated experimental subsystems the client gates UI on. + expertPanel: Schema.Boolean, + goalLoop: Schema.Boolean, }), }) 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 eb289006..bfedf421 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 @@ -12,6 +12,7 @@ import { InstanceHttpApi } from "../api" import { DeepAgentPromotionError } from "../groups/deepagent" import { WorkspaceRouteContext } from "../middleware/workspace-routing" import { RuntimeFlags } from "@/effect/runtime-flags" +import { SettingsStore } from "@/settings/store" import { Session } from "@/session/session" import { Agent } from "@/agent/agent" import { SessionPrompt } from "@/session/prompt" @@ -483,10 +484,29 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen return toVerdictResult(verdict) }) + // The global Expert Panel default (§C): the effective armed state falls back to this when a session + // has never explicitly toggled. Read from the first-party SettingsStore (expertPanelDefault). + const expertPanelDefault = () => + Effect.promise(() => SettingsStore.read()).pipe( + Effect.map((s) => s.deepagent?.expertPanelDefault ?? false), + ) + const panelArm = Effect.fn("DeepAgentHttpApi.panelArm")(function* (ctx) { const { sessionID, armed } = ctx.payload AgentGateway.DeepAgentSessionState.setPanelArmed(sessionID, armed) - return { sessionID, armed: AgentGateway.DeepAgentSessionState.isPanelArmed(sessionID) } + const globalDefault = yield* expertPanelDefault() + return { sessionID, armed: AgentGateway.DeepAgentSessionState.resolvePanelArmed(sessionID, globalDefault) } + }) + + const panelStatus = Effect.fn("DeepAgentHttpApi.panelStatus")(function* (ctx) { + const sessionID = ctx.urlParams.sessionID + const globalDefault = yield* expertPanelDefault() + const choice = AgentGateway.DeepAgentSessionState.panelArmedChoice(sessionID) + return { + sessionID, + armed: choice ?? globalDefault, + explicit: choice != null, + } }) // ── V3.9 §D Goal Loop lifecycle ───────────────────────────────────────── @@ -511,6 +531,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen const snapshot = yield* goals .start({ sessionID: ctx.payload.sessionID, + ...(ctx.payload.objective != null ? { objective: ctx.payload.objective } : {}), ...(criteria ? { criteria } : {}), ...(ctx.payload.limits ? { limits: ctx.payload.limits } : {}), ...(ctx.payload.stallThreshold != null ? { stallThreshold: ctx.payload.stallThreshold } : {}), @@ -549,6 +570,7 @@ export const deepagentHandlers = HttpApiBuilder.group(InstanceHttpApi, "deepagen .handle("envFactsModify", envFactsModify) .handle("panelConsult", panelConsult) .handle("panelArm", panelArm) + .handle("panelStatus", panelStatus) .handle("goalStart", goalStart) .handle("goalPause", goalPause) .handle("goalResume", goalResume) diff --git a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts index b4ed4933..bf6ba7a0 100644 --- a/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/deepagent-code/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,4 +1,5 @@ import { Config } from "@/config/config" +import { RuntimeFlags } from "@/effect/runtime-flags" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@deepagent-code/core/event" @@ -79,6 +80,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl const installation = yield* Installation.Service const bridge = yield* EffectBridge.make() const { db } = yield* Database.Service + const flags = yield* RuntimeFlags.Service const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -93,6 +95,11 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl sessions: true, pty: true, workspaces: true, + // V3.9 §C/§D: advertise the independently-gated experimental subsystems so the client can + // gate their UI (panel button / goal mode) WITHOUT a proxy signal. Sourced from the same + // env-backed RuntimeFlags the routes fail-close on, so UI availability == route availability. + expertPanel: flags.experimentalExpertPanel, + goalLoop: flags.experimentalGoalLoop, }, } }) diff --git a/packages/deepagent-code/src/session/goal-manager.ts b/packages/deepagent-code/src/session/goal-manager.ts index a953fc04..327b6b5a 100644 --- a/packages/deepagent-code/src/session/goal-manager.ts +++ b/packages/deepagent-code/src/session/goal-manager.ts @@ -3,7 +3,7 @@ import path from "node:path" import { Global } from "@deepagent-code/core/global" import { AgentGateway } from "@deepagent-code/core/agent-gateway" import { DocumentStore } from "@deepagent-code/core/deepagent/document-store" -import type { PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" +import { createPlanDoc, type PlanDoc } from "@deepagent-code/core/deepagent/plan-controller" import type { GoalStatus, GoalLimits, CompletionCriterion } from "@deepagent-code/core/deepagent/goal-loop" import { InvalidGoalError } from "@deepagent-code/core/deepagent/goal-loop" import { RuntimeFlags } from "../effect/runtime-flags" @@ -59,6 +59,12 @@ type GoalControl = { export type StartGoalInput = { readonly sessionID: string + /** + * An optional free-text objective (e.g. from the CLI `/goal `). When the session has no + * plan yet, this seeds a minimal single-step plan so the goal can start; the goal-worker refines it + * on the first tick. Ignored when a plan already exists (the existing plan is the goal carrier). + */ + readonly objective?: string /** Objective completion criteria (AND). Defaults to plan_complete + no_diagnostics when omitted. */ readonly criteria?: readonly CompletionCriterion[] /** Hard bounds; a goal with no bounds is rejected by the core (InvalidGoalError). */ @@ -169,13 +175,32 @@ export const layer = Layer.effect( Effect.gen(function* () { const sessionID = input.sessionID // The plan the user produced in plan mode lives in in-memory session-state — snapshot it into - // the graded store doc. A goal with no plan cannot be decided → the core rejects it at start. - const plan = AgentGateway.DeepAgentSessionState.getPlan(sessionID) as PlanDoc | null + // the graded store doc (the goal carrier). When there is no plan yet but the caller supplied a + // free-text objective (CLI `/goal `), seed a minimal single-step plan from it so the + // goal can start; the goal-worker refines the plan on its first tick. With neither a plan nor an + // objective, the core rejects the start (a goal must be objectively decidable). + const existing = AgentGateway.DeepAgentSessionState.getPlan(sessionID) as PlanDoc | null + const objective = input.objective?.trim() + const plan = + existing ?? + (objective + ? createPlanDoc(sessionID, objective, [ + { + step_id: "step_1", + title: objective, + status: "active", + acceptance: null, + assigned_agent: null, + evidence: [], + note: null, + }, + ]) + : null) const store = new DocumentStore(goalStoreRoot(sessionID)) const planDocId = plan != null ? GoalDriver.materializePlanDoc({ store, sessionId: sessionID, plan }) - : GoalDriver.goalPlanScope(sessionID) // no plan → a scope string; startGoal will reject (no doc) + : GoalDriver.goalPlanScope(sessionID) // no plan + no objective → startGoal rejects (no doc) const session = yield* sessions.get(SessionID.make(sessionID)).pipe(Effect.orDie) const model = yield* provider.defaultModel().pipe(Effect.orDie) diff --git a/packages/deepagent-code/test/server/httpapi-control-plane.test.ts b/packages/deepagent-code/test/server/httpapi-control-plane.test.ts index 80aad13a..ac9f809f 100644 --- a/packages/deepagent-code/test/server/httpapi-control-plane.test.ts +++ b/packages/deepagent-code/test/server/httpapi-control-plane.test.ts @@ -11,6 +11,7 @@ import { Config } from "../../src/config/config" import { Database } from "@deepagent-code/core/database/database" import { Installation } from "../../src/installation" import { ServerAuth } from "../../src/server/auth" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api" import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control" import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane" @@ -47,6 +48,7 @@ const apiLayer = HttpRouter.serve( ), Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "deepagent-code" })), Layer.provide(Database.layerFromPath(":memory:")), + Layer.provide(RuntimeFlags.layer({})), ) const it = testEffect(apiLayer) diff --git a/packages/deepagent-code/test/server/httpapi-global.test.ts b/packages/deepagent-code/test/server/httpapi-global.test.ts index 48d3d337..e9a7d51a 100644 --- a/packages/deepagent-code/test/server/httpapi-global.test.ts +++ b/packages/deepagent-code/test/server/httpapi-global.test.ts @@ -9,6 +9,7 @@ import { Database } from "@deepagent-code/core/database/database" import { Installation } from "../../src/installation" import { MoveSession } from "@deepagent-code/core/control-plane/move-session" import { ServerAuth } from "../../src/server/auth" +import { RuntimeFlags } from "../../src/effect/runtime-flags" import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control" @@ -41,6 +42,7 @@ const apiLayer = HttpRouter.serve( ), Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "deepagent-code" })), Layer.provide(Database.layerFromPath(":memory:")), + Layer.provide(RuntimeFlags.layer({ experimentalExpertPanel: true, experimentalGoalLoop: true })), ) const it = testEffect(apiLayer) @@ -65,4 +67,16 @@ describe("global HttpApi", () => { expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" }) }), ) + + // V3.9 §C/§D: capabilities advertise the independently-gated experimental subsystems from + // RuntimeFlags (both true in this harness), so the client can gate the panel button + goal mode. + it.live("capabilities advertise expertPanel + goalLoop from RuntimeFlags", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(GlobalPaths.capabilities) + expect(response.status).toBe(200) + const body = (yield* response.json) as { features: Record } + expect(body.features.expertPanel).toBe(true) + expect(body.features.goalLoop).toBe(true) + }), + ) }) diff --git a/packages/deepagent-code/test/session/goal-driver.test.ts b/packages/deepagent-code/test/session/goal-driver.test.ts index b00252a1..21f3f8a1 100644 --- a/packages/deepagent-code/test/session/goal-driver.test.ts +++ b/packages/deepagent-code/test/session/goal-driver.test.ts @@ -91,6 +91,43 @@ describe("materializePlanDoc", () => { expect(id2).toBe(id1) expect(store.get(id2)!.version).toBe(v1) }) + + test("an objective-seeded single-step plan is a valid goal carrier (CLI /goal )", async () => { + // Mirrors GoalManager's seed path: no prior plan + a free-text objective → one active step. + const seeded = createPlanDoc(SESSION, "migrate the payment module", [ + { + step_id: "step_1", + title: "migrate the payment module", + status: "active", + acceptance: null, + assigned_agent: null, + evidence: [], + note: null, + }, + ]) + const planDocId = materializePlanDoc({ store, sessionId: SESSION, plan: seeded }) + const parsed = JSON.parse(store.get(planDocId)!.body) as PlanDoc + expect(parsed.goal).toBe("migrate the payment module") + expect(parsed.active_step_id).toBe("step_1") + + // The goal starts from it and drives to done once the (stub) executor marks the step done. + const deps = controllerDeps({ + executor: () => + Effect.sync(() => { + store.update(planDocId, JSON.stringify(plan([step("step_1", "done")]))) + return { tokensUsed: 5 } + }), + }) + const { handle } = await Effect.runPromise( + startGoal({ + deps, + planDocId, + criteria: [{ kind: "plan_complete" }], + limits: { maxTicks: 10, maxTokens: 10_000, maxWallclockMs: 10_000 }, + }), + ) + expect(await Effect.runPromise(runToCompletion({ deps, handle }))).toBe("done") + }) }) describe("startGoal + runToCompletion", () => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index dbdad25b..899e2094 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -155,6 +155,14 @@ export function Prompt(props: PromptProps) { const terminalEnvironment = useTuiTerminalEnvironment() const clipboard = useClipboard() const sdk = useSDK() + // V3.9 §C/§D: raw-path request escape hatch for the /panel and /goal slash commands. These routes + // (/deepagent/panel/*, /deepagent/goal/*) are served by path and are NOT in the typed generated SDK, + // so we reach the low-level HTTP client (sdk.client.client) that exposes request(). Cast is scoped + // to this helper. + const rawRequest = (options: { method: string; url: string; body?: unknown; headers?: Record }) => + (sdk.client as unknown as { client: { request(o: typeof options): Promise<{ data?: D }> } }).client.request( + options, + ) const editor = useEditorContext() const route = useRoute() const project = useProject() @@ -543,6 +551,75 @@ export function Prompt(props: PromptProps) { workspace.open() }, }, + { + title: "Expert panel", + desc: "Convene the expert panel (会诊) on the current conversation", + name: "deepagent.panel", + category: "Session", + slashName: "panel", + enabled: Flag.DEEPAGENT_CODE_EXPERIMENTAL_EXPERT_PANEL, + run: async () => { + const sessionID = props.sessionID + if (!sessionID) return + toast.show({ variant: "info", message: "Convening expert panel…", duration: 3000 }) + try { + const res = await rawRequest<{ decision: string; confidence: number; rounds: number }>({ + method: "POST", + url: "/deepagent/panel/consult", + body: { sessionID }, + headers: { "Content-Type": "application/json" }, + }) + const v = res.data + toast.show({ + variant: v?.decision === "block" ? "warning" : "success", + message: v ? `Panel verdict: ${v.decision} (${Math.round(v.confidence * 100)}%, ${v.rounds}r)` : "Panel returned no verdict", + duration: 6000, + }) + } catch { + toast.show({ variant: "warning", message: "Expert panel failed", duration: 3000 }) + } + }, + }, + { + title: "Start goal", + desc: "Drive the current plan to completion as an autonomous goal", + name: "deepagent.goal", + category: "Session", + slashName: "goal", + enabled: Flag.DEEPAGENT_CODE_EXPERIMENTAL_GOAL_LOOP, + run: async () => { + const sessionID = props.sessionID + if (!sessionID) return + // `/goal ` — the text after the command word is the objective. When present it + // seeds a plan server-side (used when the session has no plan yet); otherwise the goal loop + // drives the session's existing plan. + const objective = store.prompt.input.replace(/^\/goal\b\s*/, "").trim() + try { + const res = await rawRequest<{ goalId: string; phase: string }>({ + method: "POST", + url: "/deepagent/goal/start", + body: { sessionID, ...(objective ? { objective } : {}) }, + headers: { "Content-Type": "application/json" }, + }) + // Clear the composer once the objective has been consumed by the goal. + if (objective) { + input.setText("") + setStore("prompt", { input: "", parts: [] }) + } + toast.show({ + variant: "success", + message: res.data ? `Goal started (${res.data.phase})` : "Goal started", + duration: 4000, + }) + } catch { + toast.show({ + variant: "warning", + message: "Could not start goal — describe an objective (/goal ...) or make a plan first", + duration: 4000, + }) + } + }, + }, { title: "Move session", desc: "Move the session to another project directory", From 0d690856fae3446ec22652d90ec87039962d793d Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Fri, 10 Jul 2026 12:27:43 +0800 Subject: [PATCH 6/6] fix(app): composer approval control + settings cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four settings/composer UX fixes. 1. Share URL i18n: the Sharing section (section title + shareUrl title/description/placeholder) was English-only, so it showed untranslated. Added zh + zht translations, and backfilled all 15 non-English locales so the settings-key parity test passes (it was already red on these keys + the earlier expertPanelDefault keys). 2. Approval control moved to the composer: removed the auto-accept row from settings and added an ApprovalControl next to the build/plan agent selector, modeled on Codex's approval selector — a two-option picker ("Request approval" default, "Auto-approve" when armed) whose button label reflects the current mode. Directory-scoped, backed by the existing permission context (isAutoAcceptingDirectory / toggleAutoAcceptDirectory). 3. Servers tab: the duplicate "Add server" + "Connect to server" buttons are merged into one "Add server" menu with two items (Add HTTP server / Connect to server), matching the single-entry status popover. 4. Import: the run button is now a full-width bar button under the options (with Cancel beside it while running) instead of a small right-aligned row control. Tests: i18n parity green; settings-UX test updated to assert the approval control moved to the composer. App suite 540 pass, typecheck clean. Co-Authored-By: Claude Opus 4.8 --- .../components/deepagent-settings-ux.test.ts | 18 +++++- .../components/deepagent/approval-control.tsx | 56 +++++++++++++++++++ packages/app/src/components/prompt-input.tsx | 6 ++ .../src/components/settings-v2/general.tsx | 38 ------------- .../components/settings-v2/import-history.tsx | 26 +++++---- .../src/components/settings-v2/servers.tsx | 24 ++++++-- .../components/settings-v2/settings-v2.css | 13 +++++ packages/app/src/i18n/ar.ts | 6 ++ packages/app/src/i18n/br.ts | 6 ++ packages/app/src/i18n/bs.ts | 6 ++ packages/app/src/i18n/da.ts | 6 ++ packages/app/src/i18n/de.ts | 6 ++ packages/app/src/i18n/en.ts | 3 + packages/app/src/i18n/es.ts | 6 ++ packages/app/src/i18n/fr.ts | 6 ++ packages/app/src/i18n/ja.ts | 6 ++ packages/app/src/i18n/ko.ts | 6 ++ packages/app/src/i18n/no.ts | 6 ++ packages/app/src/i18n/pl.ts | 6 ++ packages/app/src/i18n/ru.ts | 6 ++ packages/app/src/i18n/th.ts | 6 ++ packages/app/src/i18n/tr.ts | 6 ++ packages/app/src/i18n/uk.ts | 6 ++ packages/app/src/i18n/zh.ts | 9 +++ packages/app/src/i18n/zht.ts | 10 ++++ 25 files changed, 235 insertions(+), 58 deletions(-) create mode 100644 packages/app/src/components/deepagent/approval-control.tsx diff --git a/packages/app/src/components/deepagent-settings-ux.test.ts b/packages/app/src/components/deepagent-settings-ux.test.ts index 870cab56..8c2d60b5 100644 --- a/packages/app/src/components/deepagent-settings-ux.test.ts +++ b/packages/app/src/components/deepagent-settings-ux.test.ts @@ -23,9 +23,21 @@ describe("DeepAgent settings UX", () => { expect(v2.indexOf('data-action="settings-deepagent-prompt-mode"')).toBeLessThan( v2.indexOf('data-action="settings-deepagent-intelligence-model"'), ) - expect(v2.indexOf('data-action="settings-deepagent-intelligence-model"')).toBeLessThan( - v2.indexOf('data-action="settings-auto-accept-permissions"'), - ) + }) + + test("moves the permission approval control out of settings into the composer toolbar", async () => { + const v2 = await readFile(path.join(here, "settings-v2/general.tsx"), "utf8") + const composer = await readFile(path.join(here, "prompt-input.tsx"), "utf8") + const control = await readFile(path.join(here, "deepagent/approval-control.tsx"), "utf8") + + // The auto-accept toggle no longer lives in settings… + expect(v2).not.toContain('data-action="settings-auto-accept-permissions"') + // …it is a composer control next to the agent selector, backed by directory-level auto-accept. + expect(composer).toContain("ApprovalControl") + expect(control).toContain('"data-action": "prompt-approval"') + expect(control).toContain("toggleAutoAcceptDirectory") + expect(control).toContain("composer.approval.request") + expect(control).toContain("composer.approval.auto") }) test("routes the legacy settings dialog import to the unified settings page", async () => { diff --git a/packages/app/src/components/deepagent/approval-control.tsx b/packages/app/src/components/deepagent/approval-control.tsx new file mode 100644 index 00000000..5654f8ba --- /dev/null +++ b/packages/app/src/components/deepagent/approval-control.tsx @@ -0,0 +1,56 @@ +import { createMemo, type JSX } from "solid-js" +import { Select } from "@deepagent-code/ui/select" +import { useLanguage } from "@/context/language" +import { usePermission } from "@/context/permission" + +/** + * Approval-mode control for the composer toolbar, next to the agent (build/plan) selector. + * + * Mirrors Codex's approval selector UX, simplified to two options: the button shows the CURRENT mode + * ("Request approval" by default, "Auto-approve" when armed); clicking opens a small picker to switch. + * The mode is DIRECTORY-scoped (persists across sessions in the same workspace), backed by the existing + * permission context (isAutoAcceptingDirectory / toggleAutoAcceptDirectory) — the same state the old + * settings toggle drove, now surfaced where the user acts. + */ + +type ApprovalMode = "request" | "auto" + +export function ApprovalControl(props: { directory: string; triggerStyle?: JSX.CSSProperties; onAfter?: () => void }) { + const language = useLanguage() + const permission = usePermission() + + const auto = createMemo(() => (props.directory ? permission.isAutoAcceptingDirectory(props.directory) : false)) + const current = createMemo(() => (auto() ? "auto" : "request")) + + const options: ApprovalMode[] = ["request", "auto"] + const label = (mode: ApprovalMode) => + mode === "auto" + ? language.t("composer.approval.auto") + : language.t("composer.approval.request") + + const onSelect = (mode: ApprovalMode | undefined) => { + if (!mode || !props.directory) return + const isAuto = mode === "auto" + if (isAuto === auto()) return + // toggleAutoAcceptDirectory flips the directory-level state; only call it when the target differs. + permission.toggleAutoAcceptDirectory(props.directory) + props.onAfter?.() + } + + return ( +