Skip to content

Commit 78c12ee

Browse files
authored
Merge pull request #313 from LeXwDeX/fix/memory-service-unreachable
fix(server): wire Memory.node into the delivered httpapi app graph
2 parents 16f9add + e17930f commit 78c12ee

3 files changed

Lines changed: 138 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ const table = sqliteTable("session", {
182182

183183
## Extending the Codebase (二次开发)
184184

185-
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.
185+
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.
186186

187187
- 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.
188188
- `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,22 +205,28 @@ Invariants for extending the SolidJS/opentui TUI. The DAG inspector (`src/featur
205205

206206
## V2 Session Core
207207

208-
- 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.
209-
- 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.
210-
- 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.
211-
- 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.
212-
- 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.
213-
- 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.
214-
- 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.
215-
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
216-
- 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.
208+
_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._
217209

218210
## DAG Configuration Repository
219211

220212
The authoritative repository for curated DAG workflow YAML and configuration-owned block or prompt assets is [`LeXwDeX/opencode-dag-config`](https://github.com/LeXwDeX/opencode-dag-config). Inspect and update that repository when a task changes reference workflows, composable block configurations, or their embedded worker prompts; configuration-only changes do not belong in this runtime repository.
221213

222214
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.
223215

216+
## DAG command family
217+
218+
- 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).
219+
- 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`).
220+
- `dag.jsonc` supplies DAG node model tiers: `advanced` for `required: true` and review nodes, `standard` otherwise. Never pin `model` inside saved workflow specs.
221+
222+
## Project memory
223+
224+
- 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).
225+
226+
## Release notes
227+
228+
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.
229+
224230
## Agent skills
225231

226232
### Issue tracker

packages/opencode/src/server/routes/instance/httpapi/server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Installation } from "@/installation"
2020
import { LSP } from "@/lsp/lsp"
2121
import { MCP } from "@/mcp"
2222
import { McpAuth } from "@/mcp/auth"
23+
import { Memory } from "@/memory/memory"
2324
import { Permission } from "@/permission"
2425
import { Plugin } from "@/plugin"
2526
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
@@ -207,7 +208,11 @@ type RouteRequirements =
207208
| HttpRouter.Request<"Requires", unknown>
208209
| HttpRouter.Request<"GlobalRequires", never>
209210

210-
const app = LayerNode.group([
211+
// Exported so wiring regression tests can build the exact node graph the
212+
// server provides to request handlers (LayerNode.buildLayer(app)) and probe
213+
// its ambient service context — a hand-copied node list would not catch a
214+
// missing member.
215+
export const app = LayerNode.group([
211216
Npm.node,
212217
FSUtil.node,
213218
Database.node,
@@ -284,6 +289,14 @@ const app = LayerNode.group([
284289
// context, so it must be present in this graph or server-driven sessions
285290
// would silently skip rewake.
286291
HookRewakeLive.node,
292+
// Memory: same failure class as SettingsHook above — /memory and
293+
// memory_search resolve Memory.Service via serviceOption from the ambient
294+
// request context (session/prompt.ts, tool/memory-search.ts), and listing
295+
// Memory.node only in per-consumer dependency arrays (SessionPrompt,
296+
// SystemPrompt, SessionCompaction, InstanceBootstrap) never surfaced it
297+
// here, so live sessions silently degraded to "Memory remains off" /
298+
// "Memory search is unavailable for this session" (issue #311).
299+
Memory.node,
287300
])
288301

289302
export function createRoutes(
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { describe, expect } from "bun:test"
2+
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
3+
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
4+
import { SessionV1 } from "@opencode-ai/core/v1/session"
5+
import { Effect, Layer, Option } from "effect"
6+
import { Memory } from "@/memory/memory"
7+
import { Session } from "@/session/session"
8+
import { SessionPrompt } from "@/session/prompt"
9+
import { HttpApiApp } from "@/server/routes/instance/httpapi/server"
10+
import { testEffect } from "../lib/effect"
11+
12+
// Issue #311 regression: /memory answered "Memory remains off" and
13+
// memory_search answered "unavailable for this session" in live TUI sessions
14+
// because Memory.node was missing from the server app group. LayerNode nodes
15+
// built via Layer.provide do not re-export their dependency services, so
16+
// listing Memory.node only in per-consumer dependency arrays (SessionPrompt,
17+
// SystemPrompt, Compaction, bootstrap) never surfaced Memory.Service in the
18+
// request-time ambient context. These tests build the exact node graph the
19+
// server provides to route handlers and assert the service is present there.
20+
21+
const appLayer = LayerNode.buildLayer(HttpApiApp.app)
22+
23+
const appIt = testEffect(Layer.mergeAll(appLayer, CrossSpawnSpawner.defaultLayer))
24+
25+
const cfg = {
26+
provider: {
27+
test: {
28+
name: "Test",
29+
id: "test",
30+
env: [],
31+
npm: "@ai-sdk/openai-compatible",
32+
models: {
33+
"test-model": {
34+
id: "test-model",
35+
name: "Test Model",
36+
attachment: false,
37+
reasoning: false,
38+
temperature: false,
39+
tool_call: true,
40+
release_date: "2025-01-01",
41+
limit: { context: 100000, output: 10000 },
42+
cost: { input: 0, output: 0 },
43+
options: {},
44+
},
45+
},
46+
options: {
47+
apiKey: "test-key",
48+
baseURL: "http://localhost:1/v1",
49+
},
50+
},
51+
},
52+
}
53+
54+
describe("server app graph memory wiring", () => {
55+
appIt.instance(
56+
"exposes Memory.Service in the ambient context live sessions run in",
57+
() =>
58+
Effect.gen(function* () {
59+
const memory = yield* Effect.serviceOption(Memory.Service)
60+
expect(Option.isSome(memory)).toBe(true)
61+
}),
62+
{ git: true },
63+
)
64+
65+
const setEnabledCalls: boolean[] = []
66+
// Same node graph, with Memory.node swapped for a recorder: proves the
67+
// /memory command branch resolves Memory.Service from the app-graph output
68+
// (not from a per-consumer dependency scope) and reaches setEnabled.
69+
const spyIt = testEffect(
70+
Layer.mergeAll(
71+
LayerNode.buildLayer(HttpApiApp.app, {
72+
replacements: [
73+
LayerNode.replace(
74+
Memory.node,
75+
Layer.mock(Memory.Service, {
76+
setEnabled: (enabled) =>
77+
Effect.sync(() => {
78+
setEnabledCalls.push(enabled)
79+
return enabled ? ("Memory on" as const) : ("Memory off" as const)
80+
}),
81+
}),
82+
),
83+
],
84+
}),
85+
CrossSpawnSpawner.defaultLayer,
86+
),
87+
)
88+
89+
spyIt.instance(
90+
"routes the /memory command through the app graph to Memory.setEnabled",
91+
() =>
92+
Effect.gen(function* () {
93+
setEnabledCalls.length = 0
94+
const sessions = yield* Session.Service
95+
const chat = yield* sessions.create({ title: "memory wiring" })
96+
const prompt = yield* SessionPrompt.Service
97+
98+
const result = yield* prompt.command({ sessionID: chat.id, command: "memory", arguments: "on" })
99+
100+
const texts = result.parts
101+
.filter((part): part is SessionV1.TextPart => part.type === "text")
102+
.map((part) => part.text)
103+
expect(texts).toEqual(["/memory on", "Memory on"])
104+
expect(setEnabledCalls).toEqual([true])
105+
}),
106+
{ git: true, config: cfg },
107+
)
108+
})

0 commit comments

Comments
 (0)