diff --git a/AGENTS.md b/AGENTS.md index 6a95435adf..720f15959e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,7 +182,7 @@ const table = sqliteTable("session", { ## Extending the Codebase (二次开发) -Guiding invariants for adding services, HTTP API routes, or features. The build pipeline will not catch violations of these — only an understanding of the architecture will. Read the surrounding modules first (the Todo module is the reference for a lightweight, self-contained service) before wiring new dependencies. +Guiding invariants for adding services, HTTP API routes, or features. The build pipeline will not catch violations of these — only an understanding of the architecture will. Read the surrounding modules first (`src/memory` and `src/config` are good references for lightweight, self-contained services) before wiring new dependencies. - Keep each `X.defaultLayer` self-contained. It must `Layer.provide` every dependency its layer body `yield*`s at construction. `Layer.provideMerge(self, layer)` builds `layer` in isolation — the context accumulated by `self` is not fed to it — and `Layer.mergeAll` does not cross-provide siblings. A layer that quietly assumes an ambient service will construct in one entry point and crash in another, surfacing as a runtime crash or a blank/unresponsive TUI rather than a build error. - `LayerNode` (`.node` exports, `LayerNode.buildLayer`) is a second, parallel composition system, separate from `defaultLayer`/`AppLayer`. The same self-containment rule applies per node, but the two systems don't share wiring. When adding a service that other services should see, find every consumer's `.node` list (not just its `defaultLayer`) and add the new service's node there. @@ -205,15 +205,7 @@ Invariants for extending the SolidJS/opentui TUI. The DAG inspector (`src/featur ## V2 Session Core -- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries. -- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry. -- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. -- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. -- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. -- Keep EventV2 replay owner claims separate from clustered Session execution ownership. -- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. +_This section was removed: the `SessionV2`/`SessionExecution`/`SessionRunner`/`SessionRunCoordinator` vocabulary it described no longer exists in the codebase. The current session runtime lives in `packages/opencode/src/session/` (`prompt.ts`, `processor.ts`, `compaction.ts`); read `src/session/CONTEXT.md`-adjacent module docs there before extending it._ ## DAG Configuration Repository @@ -221,6 +213,20 @@ The authoritative repository for curated DAG workflow YAML and configuration-own This repository owns the DAG schema, compiler, validator, runtime, and release integration. Changes that cross the boundary land runtime support first, then update the config repository's `runtime-compat.json` to the merged full runtime commit SHA and pass its template-validation CI. +## DAG command family + +- Built-in commands ship compiled into the binary: `/dag-flow` (resident orchestration router), `/dag-init` (platform handshake → writes `.opencode/dag-init.json`), `/dag-auto` (six-block ultra-flow driver), `/dag-template-update` (template refresh without git). User command files shadow built-ins by name; register new built-ins through `packages/core/src/plugin/command.ts` + `packages/opencode/src/command/index.ts` (`Default` registry). +- Templates come from `opencode-dag-config`: 7 domains × `full`/`lite` plus cross-domain routes (`ultra-flow-route`, `release-route`). Precedence: project `.opencode/workflows/` > global config dir > builtin snapshot (the release pipeline compiles the config repo into the binary via `DAG_TEMPLATES_DIR`). +- `dag.jsonc` supplies DAG node model tiers: `advanced` for `required: true` and review nodes, `standard` otherwise. Never pin `model` inside saved workflow specs. + +## Project memory + +- Memory is fail-closed inert until the project is initialized: running `/init` stamps `project.time_initialized`, which `/memory on` and `memory_search` require. `/memory on` silently answering "Memory remains off" means the project never ran `/init` (or has no real git identity). + +## Release notes + +Releases follow `.github/RELEASE_NOTES_TEMPLATE.md`: keep section order and emoji headers, omit empty sections, fill the test summary from the CI gates, and end with the `previous_tag...current_tag` changelog link. + ## Agent skills ### Issue tracker diff --git a/packages/core/src/plugin/command/dag-init.txt b/packages/core/src/plugin/command/dag-init.txt index 0055b49a6d..46e58de173 100644 --- a/packages/core/src/plugin/command/dag-init.txt +++ b/packages/core/src/plugin/command/dag-init.txt @@ -15,12 +15,17 @@ Arguments (optional): $ARGUMENTS - Not a git repo, or no `origin` → STOP: "`/dag-*` requires a git remote." - Host is `github.com` → platform `github`, CLI `gh`. - Host is `gitlab.com` → platform `gitlab`, CLI `glab`. - - Any other host → probe whether it is a self-hosted GitLab: - `curl -sSf https:///api/v4/version` (or `glab api version` against - that host once authed). Responds as GitLab → platform `gitlab` - (self-hosted), CLI `glab` pinned to that host. Otherwise → STOP: - "unsupported platform: only GitHub and GitLab (self-hosted included) - are supported." Bitbucket/Gitea/other remotes are rejected here. + - Any other host → decide whether it is a self-hosted GitLab. GitLab's + `/api/v4/version` endpoint REQUIRES authentication by API design, so a + healthy instance answers a bare request with 401 — that 401 is POSITIVE + evidence (the endpoint exists and answers), not a failure. Probe order: + `curl -sSf https:///api/v4/version`; a version JSON response OR a + 401/GitLab-shaped error → classify as GitLab; connection refused or a + non-GitLab answer → STOP: "unsupported platform: only GitHub and GitLab + (self-hosted included) are supported." Bitbucket/Gitea/other remotes are + rejected here. Once classified, re-verify with `glab api version` after + step 2 auth passes. Platform `gitlab` (self-hosted), CLI `glab` pinned + to that host. - When `origin` and a different `upstream` exist and point at different repositories, ask the user ONCE which remote `/dag-auto` should bind to and record the choice; otherwise bind `origin`. diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index eb33b78dfe..cfd96f32d7 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -61,6 +61,8 @@ describe("CommandPlugin.Plugin", () => { }) expect(CommandPlugin.DagInitContent).toContain("$ARGUMENTS") expect(CommandPlugin.DagInitContent).toContain("unsupported platform: only GitHub and GitLab") + expect(CommandPlugin.DagInitContent).toContain("401 is POSITIVE") + expect(CommandPlugin.DagInitContent).toContain("re-verify with `glab api version`") expect(CommandPlugin.DagInitContent).toContain(".opencode/dag-init.json") expect(CommandPlugin.DagInitContent).toContain("merge_policy") expect(yield* command.get("dag-auto")).toMatchObject({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c635acf6b0..accb6318c4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -20,6 +20,7 @@ import { Installation } from "@/installation" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { McpAuth } from "@/mcp/auth" +import { Memory } from "@/memory/memory" import { Permission } from "@/permission" import { Plugin } from "@/plugin" import { PluginPtyEnvironment } from "@/plugin/pty-environment" @@ -207,7 +208,11 @@ type RouteRequirements = | HttpRouter.Request<"Requires", unknown> | HttpRouter.Request<"GlobalRequires", never> -const app = LayerNode.group([ +// Exported so wiring regression tests can build the exact node graph the +// server provides to request handlers (LayerNode.buildLayer(app)) and probe +// its ambient service context — a hand-copied node list would not catch a +// missing member. +export const app = LayerNode.group([ Npm.node, FSUtil.node, Database.node, @@ -284,6 +289,14 @@ const app = LayerNode.group([ // context, so it must be present in this graph or server-driven sessions // would silently skip rewake. HookRewakeLive.node, + // Memory: same failure class as SettingsHook above — /memory and + // memory_search resolve Memory.Service via serviceOption from the ambient + // request context (session/prompt.ts, tool/memory-search.ts), and listing + // Memory.node only in per-consumer dependency arrays (SessionPrompt, + // SystemPrompt, SessionCompaction, InstanceBootstrap) never surfaced it + // here, so live sessions silently degraded to "Memory remains off" / + // "Memory search is unavailable for this session" (issue #311). + Memory.node, ]) export function createRoutes( diff --git a/packages/opencode/test/server/httpapi-memory-wiring.test.ts b/packages/opencode/test/server/httpapi-memory-wiring.test.ts new file mode 100644 index 0000000000..a00d5fdaa9 --- /dev/null +++ b/packages/opencode/test/server/httpapi-memory-wiring.test.ts @@ -0,0 +1,108 @@ +import { describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { Effect, Layer, Option } from "effect" +import { Memory } from "@/memory/memory" +import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" +import { HttpApiApp } from "@/server/routes/instance/httpapi/server" +import { testEffect } from "../lib/effect" + +// Issue #311 regression: /memory answered "Memory remains off" and +// memory_search answered "unavailable for this session" in live TUI sessions +// because Memory.node was missing from the server app group. LayerNode nodes +// built via Layer.provide do not re-export their dependency services, so +// listing Memory.node only in per-consumer dependency arrays (SessionPrompt, +// SystemPrompt, Compaction, bootstrap) never surfaced Memory.Service in the +// request-time ambient context. These tests build the exact node graph the +// server provides to route handlers and assert the service is present there. + +const appLayer = LayerNode.buildLayer(HttpApiApp.app) + +const appIt = testEffect(Layer.mergeAll(appLayer, CrossSpawnSpawner.defaultLayer)) + +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} + +describe("server app graph memory wiring", () => { + appIt.instance( + "exposes Memory.Service in the ambient context live sessions run in", + () => + Effect.gen(function* () { + const memory = yield* Effect.serviceOption(Memory.Service) + expect(Option.isSome(memory)).toBe(true) + }), + { git: true }, + ) + + const setEnabledCalls: boolean[] = [] + // Same node graph, with Memory.node swapped for a recorder: proves the + // /memory command branch resolves Memory.Service from the app-graph output + // (not from a per-consumer dependency scope) and reaches setEnabled. + const spyIt = testEffect( + Layer.mergeAll( + LayerNode.buildLayer(HttpApiApp.app, { + replacements: [ + LayerNode.replace( + Memory.node, + Layer.mock(Memory.Service, { + setEnabled: (enabled) => + Effect.sync(() => { + setEnabledCalls.push(enabled) + return enabled ? ("Memory on" as const) : ("Memory off" as const) + }), + }), + ), + ], + }), + CrossSpawnSpawner.defaultLayer, + ), + ) + + spyIt.instance( + "routes the /memory command through the app graph to Memory.setEnabled", + () => + Effect.gen(function* () { + setEnabledCalls.length = 0 + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "memory wiring" }) + const prompt = yield* SessionPrompt.Service + + const result = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "on" }) + + const texts = result.parts + .filter((part): part is SessionV1.TextPart => part.type === "text") + .map((part) => part.text) + expect(texts).toEqual(["/memory on", "Memory on"]) + expect(setEnabledCalls).toEqual([true]) + }), + { git: true, config: cfg }, + ) +})