Sfoglia il codice sorgente

fix(core): respect v2 default agents (#30969)

Kit Langton 3 mesi fa
parent
commit
02a5ae6585

+ 27 - 0
packages/core/src/agent.ts

@@ -44,11 +44,13 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
 
 
 type Data = {
 type Data = {
   agents: Map<ID, Info>
   agents: Map<ID, Info>
+  default?: ID
 }
 }
 
 
 export type Editor = {
 export type Editor = {
   list: () => readonly Info[]
   list: () => readonly Info[]
   get: (id: ID) => Info | undefined
   get: (id: ID) => Info | undefined
+  default: (id: ID | undefined) => void
   update: (id: ID, fn: (agent: Draft<Info>) => void) => void
   update: (id: ID, fn: (agent: Draft<Info>) => void) => void
   remove: (id: ID) => void
   remove: (id: ID) => void
 }
 }
@@ -57,6 +59,8 @@ export interface Interface {
   readonly transform: State.Interface<Data, Editor>["transform"]
   readonly transform: State.Interface<Data, Editor>["transform"]
   readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
   readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
   readonly get: (id: ID) => Effect.Effect<Info | undefined>
   readonly get: (id: ID) => Effect.Effect<Info | undefined>
+  readonly default: () => Effect.Effect<Info | undefined>
+  readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
   readonly all: () => Effect.Effect<Info[]>
   readonly all: () => Effect.Effect<Info[]>
 }
 }
 
 
@@ -72,6 +76,9 @@ export const layer = Layer.effect(
       editor: (draft) => ({
       editor: (draft) => ({
         list: () => Array.fromIterable(draft.agents.values()) as Info[],
         list: () => Array.fromIterable(draft.agents.values()) as Info[],
         get: (id) => draft.agents.get(id),
         get: (id) => draft.agents.get(id),
+        default: (id) => {
+          draft.default = id
+        },
         update: (id, fn) => {
         update: (id, fn) => {
           const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
           const current = draft.agents.get(id) ?? castDraft(Info.empty(id))
           if (!draft.agents.has(id)) draft.agents.set(id, current)
           if (!draft.agents.has(id)) draft.agents.set(id, current)
@@ -83,6 +90,19 @@ export const layer = Layer.effect(
         },
         },
       }),
       }),
     })
     })
+    const selectable = (agent: Info | undefined) =>
+      agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+    const selectedDefault = () => {
+      const data = state.get()
+      const configured = data.default ? selectable(data.agents.get(data.default)) : undefined
+      if (configured) return configured
+      const build = selectable(data.agents.get(ID.make("build")))
+      if (build) return build
+      for (const agent of data.agents.values()) {
+        const fallback = selectable(agent)
+        if (fallback) return fallback
+      }
+    }
 
 
     return Service.of({
     return Service.of({
       transform: state.transform,
       transform: state.transform,
@@ -93,6 +113,13 @@ export const layer = Layer.effect(
       get: Effect.fn("AgentV2.get")(function* (id) {
       get: Effect.fn("AgentV2.get")(function* (id) {
         return state.get().agents.get(id)
         return state.get().agents.get(id)
       }),
       }),
+      default: Effect.fn("AgentV2.default")(function* () {
+        return selectedDefault()
+      }),
+      resolve: Effect.fn("AgentV2.resolve")(function* (id) {
+        if (id !== undefined) return state.get().agents.get(ID.make(id))
+        return selectedDefault()
+      }),
       all: Effect.fn("AgentV2.all")(function* () {
       all: Effect.fn("AgentV2.all")(function* () {
         return Array.fromIterable(state.get().agents.values())
         return Array.fromIterable(state.get().agents.values())
       }),
       }),

+ 9 - 14
packages/core/src/config.ts

@@ -35,6 +35,9 @@ export class Info extends Schema.Class<Info>("Config.Info")({
   model: Schema.String.pipe(Schema.optional).annotate({
   model: Schema.String.pipe(Schema.optional).annotate({
     description: "Default model to use when no session or agent model is selected",
     description: "Default model to use when no session or agent model is selected",
   }),
   }),
+  default_agent: Schema.String.pipe(Schema.optional).annotate({
+    description: "Default primary agent to use when no session agent is selected",
+  }),
   autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
   autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
     .pipe(Schema.optional)
     .pipe(Schema.optional)
     .annotate({
     .annotate({
@@ -130,6 +133,9 @@ export const layer = Layer.effect(
     const location = yield* Location.Service
     const location = yield* Location.Service
     const policy = yield* Policy.Service
     const policy = yield* Policy.Service
     const names = ["config.json", "opencode.json", "opencode.jsonc"]
     const names = ["config.json", "opencode.json", "opencode.jsonc"]
+    const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
+    const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
+    const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
 
 
     const loadFile = Effect.fnUntraced(function* (filepath: string) {
     const loadFile = Effect.fnUntraced(function* (filepath: string) {
       const text = yield* fs.readFileStringSafe(filepath)
       const text = yield* fs.readFileStringSafe(filepath)
@@ -139,21 +145,10 @@ export const layer = Layer.effect(
       const input: unknown = parse(text, errors, { allowTrailingComma: true })
       const input: unknown = parse(text, errors, { allowTrailingComma: true })
       if (errors.length) return
       if (errors.length) return
 
 
-      const decoded = ConfigMigrateV1.isV1(input)
-        ? Option.map(
-            Schema.decodeUnknownOption(ConfigV1.Info)(input, {
-              errors: "all",
-              onExcessProperty: "ignore",
-              propertyOrder: "original",
-            }),
-            ConfigMigrateV1.migrate,
-          )
-        : Option.some(input)
       const info = Option.getOrUndefined(
       const info = Option.getOrUndefined(
-        Option.flatMap(
-          decoded,
-          Schema.decodeUnknownOption(Info, { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" }),
-        ),
+        ConfigMigrateV1.isV1(input)
+          ? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
+          : decodeInfo(input),
       )
       )
       if (!info) return
       if (!info) return
       return new Document({ type: "document", path: filepath, info })
       return new Document({ type: "document", path: filepath, info })

+ 2 - 0
packages/core/src/config/plugin/agent.ts

@@ -58,6 +58,8 @@ export const Plugin = PluginV2.define({
 
 
     yield* agent.update((editor) => {
     yield* agent.update((editor) => {
       const global = documents.flatMap((document) => document.info.permissions ?? [])
       const global = documents.flatMap((document) => document.info.permissions ?? [])
+      const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info.default_agent
+      if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
       for (const current of editor.list()) {
       for (const current of editor.list()) {
         editor.update(current.id, (agent) => agent.permissions.push(...global))
         editor.update(current.id, (agent) => agent.permissions.push(...global))
       }
       }

+ 2 - 1
packages/core/src/permission.ts

@@ -16,6 +16,7 @@ export { Effect, Rule, Ruleset } from "./permission/schema"
 type Effect = PermissionSchema.Effect
 type Effect = PermissionSchema.Effect
 type Rule = PermissionSchema.Rule
 type Rule = PermissionSchema.Rule
 type Ruleset = PermissionSchema.Ruleset
 type Ruleset = PermissionSchema.Ruleset
+const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
 
 
 export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
 export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
   Schema.brand("PermissionV2.ID"),
   Schema.brand("PermissionV2.ID"),
@@ -161,7 +162,7 @@ export const layer = Layer.effect(
     const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
     const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
       const session = yield* sessions.get(sessionID)
       const session = yield* sessions.get(sessionID)
       if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
       if (!session) return yield* new SessionV2.NotFoundError({ sessionID })
-      return (yield* agents.get(AgentV2.ID.make(session.agent ?? "build")))?.permissions ?? []
+      return (yield* agents.resolve(session.agent))?.permissions ?? missingAgentPermissions
     })
     })
 
 
     function denied(input: AssertInput, rules: Ruleset) {
     function denied(input: AssertInput, rules: Ruleset) {

+ 3 - 0
packages/core/src/plugin/agent.ts

@@ -9,6 +9,8 @@ import { PermissionV2 } from "../permission"
 import { PluginV2 } from "../plugin"
 import { PluginV2 } from "../plugin"
 
 
 const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
 const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
+const BUILD_SYSTEM =
+  "You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
 
 
 const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
 const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
 
 
@@ -123,6 +125,7 @@ export const Plugin = PluginV2.define({
     yield* agent.update((editor) => {
     yield* agent.update((editor) => {
       editor.update(AgentV2.ID.make("build"), (item) => {
       editor.update(AgentV2.ID.make("build"), (item) => {
         item.description = "The default agent. Executes tools based on configured permissions."
         item.description = "The default agent. Executes tools based on configured permissions."
+        item.system ??= BUILD_SYSTEM
         item.mode = "primary"
         item.mode = "primary"
         item.permissions.push(
         item.permissions.push(
           ...PermissionV2.merge(defaults, [
           ...PermissionV2.merge(defaults, [

+ 5 - 2
packages/core/src/session/runner/llm.ts

@@ -16,6 +16,7 @@ import { SessionInput } from "../input"
 import { QuestionV2 } from "../../question"
 import { QuestionV2 } from "../../question"
 import { SystemContextRegistry } from "../../system-context-registry"
 import { SystemContextRegistry } from "../../system-context-registry"
 import { SessionContextEpoch } from "../context-epoch"
 import { SessionContextEpoch } from "../context-epoch"
+import { AgentV2 } from "../../agent"
 
 
 /**
 /**
  * Runs one durable coding-agent Session until it settles.
  * Runs one durable coding-agent Session until it settles.
@@ -84,6 +85,7 @@ export const layer = Layer.effect(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const events = yield* EventV2.Service
     const events = yield* EventV2.Service
     const llm = yield* LLMClient.Service
     const llm = yield* LLMClient.Service
+    const agents = yield* AgentV2.Service
     const tools = yield* ToolRegistry.Service
     const tools = yield* ToolRegistry.Service
     const models = yield* SessionRunnerModel.Service
     const models = yield* SessionRunnerModel.Service
     const store = yield* SessionStore.Service
     const store = yield* SessionStore.Service
@@ -134,6 +136,7 @@ export const layer = Layer.effect(
       const session = yield* getSession(sessionID)
       const session = yield* getSession(sessionID)
       const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
       const initialized = yield* SessionContextEpoch.initialize(db, systemContext, session.id, session.location)
       const model = yield* models.resolve(session)
       const model = yield* models.resolve(session)
+      const agent = yield* agents.resolve(session.agent)
       const toolFibers = yield* FiberSet.make<void, never>()
       const toolFibers = yield* FiberSet.make<void, never>()
       let needsContinuation = false
       let needsContinuation = false
       if (promotion) {
       if (promotion) {
@@ -149,13 +152,13 @@ export const layer = Layer.effect(
       const context = yield* store.runnerContext(session.id, system.baselineSeq)
       const context = yield* store.runnerContext(session.id, system.baselineSeq)
       const request = LLM.request({
       const request = LLM.request({
         model,
         model,
-        system: system.baseline.length > 0 ? [SystemPart.make(system.baseline)] : [],
+        system: [agent?.system, system.baseline].filter((part): part is string => part !== undefined && part.length > 0).map(SystemPart.make),
         messages: toLLMMessages(context, model),
         messages: toLLMMessages(context, model),
         tools: yield* tools.definitions(),
         tools: yield* tools.definitions(),
       })
       })
       const publisher = createLLMEventPublisher(events, {
       const publisher = createLLMEventPublisher(events, {
         sessionID: session.id,
         sessionID: session.id,
-        agent: session.agent ?? "build",
+        agent: agent?.id ?? "build",
         model: {
         model: {
           id: ModelV2.ID.make(model.id),
           id: ModelV2.ID.make(model.id),
           providerID: ProviderV2.ID.make(model.provider),
           providerID: ProviderV2.ID.make(model.provider),

+ 1 - 1
packages/core/src/v1/config/migrate.ts

@@ -18,7 +18,6 @@ const keys = new Set([
   "disabled_providers",
   "disabled_providers",
   "enabled_providers",
   "enabled_providers",
   "small_model",
   "small_model",
-  "default_agent",
   "mode",
   "mode",
   "agent",
   "agent",
   "provider",
   "provider",
@@ -38,6 +37,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
     $schema: info.$schema,
     $schema: info.$schema,
     shell: info.shell,
     shell: info.shell,
     model: info.model,
     model: info.model,
+    default_agent: info.default_agent,
     autoupdate: info.autoupdate,
     autoupdate: info.autoupdate,
     share: info.share ?? (info.autoshare ? "auto" : undefined),
     share: info.share ?? (info.autoshare ? "auto" : undefined),
     enterprise: info.enterprise,
     enterprise: info.enterprise,

+ 4 - 0
packages/core/test/config/config.test.ts

@@ -244,6 +244,7 @@ describe("Config", () => {
               JSON.stringify({
               JSON.stringify({
                 shell: "/bin/bash",
                 shell: "/bin/bash",
                 model: "anthropic/claude",
                 model: "anthropic/claude",
+                default_agent: "reviewer",
                 autoupdate: "notify",
                 autoupdate: "notify",
                 share: "disabled",
                 share: "disabled",
                 enterprise: { url: "https://share.example.com" },
                 enterprise: { url: "https://share.example.com" },
@@ -328,6 +329,7 @@ describe("Config", () => {
             expect(documents).toHaveLength(1)
             expect(documents).toHaveLength(1)
             expect(documents[0]?.info.shell).toBe("/bin/bash")
             expect(documents[0]?.info.shell).toBe("/bin/bash")
             expect(documents[0]?.info.model).toBe("anthropic/claude")
             expect(documents[0]?.info.model).toBe("anthropic/claude")
+            expect(documents[0]?.info.default_agent).toBe("reviewer")
             expect(documents[0]?.info.autoupdate).toBe("notify")
             expect(documents[0]?.info.autoupdate).toBe("notify")
             expect(documents[0]?.info.share).toBe("disabled")
             expect(documents[0]?.info.share).toBe("disabled")
             expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
             expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
@@ -427,6 +429,7 @@ describe("Config", () => {
               path.join(tmp.path, "opencode.json"),
               path.join(tmp.path, "opencode.json"),
               JSON.stringify({
               JSON.stringify({
                 shell: "/bin/zsh",
                 shell: "/bin/zsh",
+                default_agent: "reviewer",
                 snapshot: false,
                 snapshot: false,
                 autoshare: true,
                 autoshare: true,
                 permission: {
                 permission: {
@@ -487,6 +490,7 @@ describe("Config", () => {
             expect(documents).toHaveLength(1)
             expect(documents).toHaveLength(1)
             expect(documents[0]?.info).toBeInstanceOf(Config.Info)
             expect(documents[0]?.info).toBeInstanceOf(Config.Info)
             expect(documents[0]?.info.shell).toBe("/bin/zsh")
             expect(documents[0]?.info.shell).toBe("/bin/zsh")
+            expect(documents[0]?.info.default_agent).toBe("reviewer")
             expect(documents[0]?.info.snapshots).toBe(false)
             expect(documents[0]?.info.snapshots).toBe(false)
             expect(documents[0]?.info.share).toBe("auto")
             expect(documents[0]?.info.share).toBe("auto")
             expect(documents[0]?.info.permissions).toEqual([
             expect(documents[0]?.info.permissions).toEqual([

+ 22 - 0
packages/core/test/permission.test.ts

@@ -165,6 +165,28 @@ describe("PermissionV2", () => {
     }),
     }),
   )
   )
 
 
+  it.effect("denies omitted-agent permissions when no primary default agent exists", () =>
+    Effect.gen(function* () {
+      yield* setup()
+      const { db } = yield* Database.Service
+      yield* db
+        .update(SessionTable)
+        .set({ agent: null })
+        .where(eq(SessionTable.id, SessionV2.ID.make("ses_test")))
+        .run()
+        .pipe(Effect.orDie)
+      const agents = yield* AgentV2.Service
+      yield* agents.update((editor) => {
+        editor.remove(AgentV2.ID.make("test"))
+        editor.remove(AgentV2.ID.make("build"))
+      })
+
+      const service = yield* PermissionV2.Service
+      expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
+      expect(yield* service.list()).toEqual([])
+    }),
+  )
+
   it.effect("evaluates bash with the normal configured-rule semantics", () =>
   it.effect("evaluates bash with the normal configured-rule semantics", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       yield* setup([{ action: "*", resource: "*", effect: "allow" }])
       yield* setup([{ action: "*", resource: "*", effect: "allow" }])

+ 4 - 0
packages/core/test/session-runner-recorded.test.ts

@@ -6,6 +6,7 @@ import { Database } from "@opencode-ai/core/database/database"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventV2 } from "@opencode-ai/core/event"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { PermissionV2 } from "@opencode-ai/core/permission"
 import { PermissionV2 } from "@opencode-ai/core/permission"
+import { AgentV2 } from "@opencode-ai/core/agent"
 import { Project } from "@opencode-ai/core/project"
 import { Project } from "@opencode-ai/core/project"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -48,6 +49,7 @@ const permission = Layer.succeed(
   }),
   }),
 )
 )
 const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
 const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
+const agents = AgentV2.layer
 const model = OpenAIChat.route
 const model = OpenAIChat.route
   .with({
   .with({
     endpoint: { baseURL: "https://api.openai.com/v1" },
     endpoint: { baseURL: "https://api.openai.com/v1" },
@@ -65,6 +67,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
   Layer.provide(registry),
   Layer.provide(registry),
   Layer.provide(models),
   Layer.provide(models),
   Layer.provide(systemContext),
   Layer.provide(systemContext),
+  Layer.provide(agents),
 )
 )
 const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
 const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
 const execution = Layer.effect(
 const execution = Layer.effect(
@@ -89,6 +92,7 @@ const it = testEffect(
     executor,
     executor,
     client,
     client,
     permission,
     permission,
+    agents,
     registry,
     registry,
     models,
     models,
     systemContext,
     systemContext,

+ 76 - 0
packages/core/test/session-runner.test.ts

@@ -32,6 +32,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ToolRegistry } from "@opencode-ai/core/tool/registry"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
 import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
+import { AgentV2 } from "@opencode-ai/core/agent"
 import { NativeTool } from "@opencode-ai/core/tool/native"
 import { NativeTool } from "@opencode-ai/core/tool/native"
 import {
 import {
   SessionContextEpochTable,
   SessionContextEpochTable,
@@ -107,6 +108,7 @@ const permission = Layer.succeed(
 )
 )
 const applications = ApplicationTools.layer
 const applications = ApplicationTools.layer
 const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
 const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
+const agents = AgentV2.layer
 const echo = Layer.effectDiscard(
 const echo = Layer.effectDiscard(
   ToolRegistry.Service.use((registry) =>
   ToolRegistry.Service.use((registry) =>
     registry.contribute((editor) => {
     registry.contribute((editor) => {
@@ -189,6 +191,7 @@ const runner = SessionRunnerLLM.layer.pipe(
   Layer.provide(registry),
   Layer.provide(registry),
   Layer.provide(models),
   Layer.provide(models),
   Layer.provide(systemContext),
   Layer.provide(systemContext),
+  Layer.provide(agents),
 )
 )
 const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
 const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
 const execution = Layer.effect(
 const execution = Layer.effect(
@@ -214,6 +217,7 @@ const it = testEffect(
     client,
     client,
     permission,
     permission,
     applications,
     applications,
+    agents,
     registry,
     registry,
     echo,
     echo,
     models,
     models,
@@ -724,6 +728,78 @@ describe("SessionRunnerLLM", () => {
     }),
     }),
   )
   )
 
 
+  it.effect("includes the effective default agent system before durable context", () =>
+    Effect.gen(function* () {
+      yield* setup
+      const agent = yield* AgentV2.Service
+      yield* agent.update((editor) =>
+        editor.update(AgentV2.ID.make("build"), (agent) => {
+          agent.system = "Build agent instructions"
+          agent.mode = "primary"
+        }),
+      )
+      const session = yield* SessionV2.Service
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
+
+      requests.length = 0
+      response = fragmentFixture("text", "text-build", ["Done"]).completeEvents
+      yield* session.resume(sessionID)
+
+      expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"])
+    }),
+  )
+
+  it.effect("uses the configured default agent system for omitted-agent sessions", () =>
+    Effect.gen(function* () {
+      yield* setup
+      const agent = yield* AgentV2.Service
+      yield* agent.update((editor) => {
+        editor.update(AgentV2.ID.make("build"), (agent) => {
+          agent.system = "Build agent instructions"
+          agent.mode = "primary"
+        })
+        editor.update(AgentV2.ID.make("reviewer"), (agent) => {
+          agent.system = "Reviewer instructions"
+          agent.mode = "primary"
+        })
+        editor.default(AgentV2.ID.make("reviewer"))
+      })
+      const session = yield* SessionV2.Service
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
+
+      requests.length = 0
+      response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents
+      yield* session.resume(sessionID)
+
+      expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
+      expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
+    }),
+  )
+
+  it.effect("uses an explicitly selected non-build agent system", () =>
+    Effect.gen(function* () {
+      yield* setup
+      const { db } = yield* Database.Service
+      const agent = yield* AgentV2.Service
+      yield* agent.update((editor) =>
+        editor.update(AgentV2.ID.make("reviewer"), (agent) => {
+          agent.system = "Reviewer instructions"
+          agent.mode = "primary"
+        }),
+      )
+      yield* db.update(SessionTable).set({ agent: "reviewer" }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
+      const session = yield* SessionV2.Service
+      yield* session.prompt({ sessionID, prompt: new Prompt({ text: "First" }), resume: false })
+
+      requests.length = 0
+      response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents
+      yield* session.resume(sessionID)
+
+      expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"])
+      expect((yield* session.messages({ sessionID }))[0]).toMatchObject({ type: "assistant", agent: "reviewer" })
+    }),
+  )
+
   it.effect("admits removed context as a chronological System message", () =>
   it.effect("admits removed context as a chronological System message", () =>
     Effect.gen(function* () {
     Effect.gen(function* () {
       yield* setup
       yield* setup