From 839fa10cae4090f23895af2f58b75918e7828aaf Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 17 Aug 2026 12:09:23 +0800 Subject: [PATCH 1/3] fix(server): wire Memory.node into the delivered httpapi app graph (issue #311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory.node appeared in every consumer's dependency array, but LayerNode builds feed dependencies only to a node's own implementation body without re-export — the ambient context delivered to live sessions is the mergeAll of the app group's direct members at server.ts buildLayer call site, which lacked Memory.node. All five serviceOption(Memory.Service) guards therefore silently resolved None: /memory answered 'remains off' and memory_search 'Unabailable' despite a valid identity and init stamp. Add Memory.node to the app group and export the app graph so the new regression test builds the exact production delivery graph: one probe asserts Memory.Service is reachable inside it, one drives the real /memory command end-to-end. Reverting the wiring turns the test 0/2 with the literal symptom. --- .../server/routes/instance/httpapi/server.ts | 15 ++- .../test/server/httpapi-memory-wiring.test.ts | 108 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/server/httpapi-memory-wiring.test.ts 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 }, + ) +}) From e17930f73c676cb40eed6e6c62ef8e9384946d6e Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 17 Aug 2026 12:09:23 +0800 Subject: [PATCH 2/3] docs: reconcile AGENTS.md with landed DAG command family and memory gate Drop the retired V2 session vocabulary, point extenders at src/memory and src/config references, and record the /dag-* command family, template precedence, the /init memory-activation gate, and the release-notes template requirement. --- AGENTS.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) 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 From 499f892c205b98f26e546683ec189c8b836ad6a4 Mon Sep 17 00:00:00 2001 From: Lex Date: Mon, 17 Aug 2026 12:12:53 +0800 Subject: [PATCH 3/3] fix(core): encode GitLab version-endpoint auth requirement in dag-init probe GitLab's /api/v4/version requires authentication by API design, so a healthy self-hosted instance answers a bare curl with 401. The probe text listed the authenticated call as an alternative, so agents following it verbatim burned a failed round before discovering this (hit live against git.ycgame.com). Encode both facts: a 401 is positive evidence that the endpoint exists and answers, and glab api version is the re-verification step after auth passes. --- packages/core/src/plugin/command/dag-init.txt | 17 +++++++++++------ packages/core/test/plugin/command.test.ts | 2 ++ 2 files changed, 13 insertions(+), 6 deletions(-) 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({