Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -205,22 +205,28 @@ 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

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.

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
Expand Down
17 changes: 11 additions & 6 deletions packages/core/src/plugin/command/dag-init.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>/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://<host>/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`.
Expand Down
2 changes: 2 additions & 0 deletions packages/core/test/plugin/command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
108 changes: 108 additions & 0 deletions packages/opencode/test/server/httpapi-memory-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
})
Loading