Compare commits

..
24 changed files with 643 additions and 297 deletions
@@ -208,6 +208,56 @@ test("navigates from a running subagent card and hides background controls in th
await expect(page.getByText(/move running work to the background/i)).toHaveCount(0)
})
for (const name of ["shell", "subagent"] as const) {
test(`keeps the background shortcut available for a grouped running ${name}`, async ({ page }) => {
const message = assistant(false, true)
await setupTimeline(page, {
sessionMessages: [
user,
{
...message,
content: [
{
type: "tool",
id: "call_read",
name: "read",
state: {
status: "completed",
input: { path: "src/example.ts" },
content: [{ type: "text", text: "export const example = true" }],
metadata: {},
},
time: { created: 1, completed: 2 },
},
{
type: "tool",
id: "call_running",
name,
state: {
status: "running",
input:
name === "shell" ? { command: "echo checking" } : { agent: "general", description: "Inspect code" },
metadata: {},
},
time: { created: 3 },
},
],
},
],
})
const group = page.locator('[data-timeline-part-ids="call_read,call_running"]')
await expect(group).toBeVisible()
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
await expect(page.locator('[data-component="session-background-hint"]')).toBeVisible()
const request = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`,
)
await page.keyboard.press("Control+b")
await request
})
}
test("shows a badge for active background work", async ({ page }) => {
const childID = "ses_background_child"
await setupTimeline(page, {
@@ -44,7 +44,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
const group = page.locator(
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
)
const summary = group.getByRole("button", { name: "Used Shell, Explore, Patch" })
const summary = group.getByRole("button", { name: "Used Shell, Agent, Patch" })
await expect(summary).toHaveAttribute("aria-expanded", "false")
await expect(summary).toHaveCSS("height", "28px")
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
@@ -52,7 +52,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
await Promise.all([
@@ -77,7 +77,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await requested.promise
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
@@ -195,7 +195,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
async function openChildFromParent(page: Page) {
await page.goto(sessionHref(parentID))
await expectSessionTitle(page, parentTitle)
await page.getByRole("button", { name: "Used Explore" }).click()
await page.getByRole("button", { name: "Used Agent" }).click()
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
await expect(card).toBeVisible()
@@ -349,8 +349,7 @@ function MessageTimelineView(
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -469,13 +468,13 @@ function MessageTimelineView(
})
const backgroundHintPartID = createMemo(() => {
const blocking = new Set(props.background.blocking().map((task) => task.partID))
const row = projection
if (blocking.size === 0) return
return projection
.rows()
.findLast(
(row) => row._tag === "AssistantPart" && row.group.type === "part" && blocking.has(row.group.ref.partID),
.flatMap((row) =>
row._tag === "AssistantPart" ? (row.group.type === "part" ? [row.group.ref] : row.group.refs) : [],
)
if (row?._tag !== "AssistantPart" || row.group.type !== "part") return
return row.group.ref.partID
.findLast((ref) => blocking.has(ref.partID))?.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
+14 -10
View File
@@ -8,9 +8,10 @@ import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import type { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
@@ -69,14 +70,13 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
}
export type AutoInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly resolved: SessionRunnerModel.Resolved
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
@@ -86,6 +86,9 @@ export type ManualInput = {
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
/** Invoked after content planning, not when the caller captures the operation. */
readonly resolveModel: SessionContext.Interface["resolveModel"]
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type Plan = {
@@ -96,6 +99,7 @@ type Plan = {
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
readonly prepare: SessionModelRequest.Interface["prepare"]
}
export type Outcome =
@@ -278,7 +282,7 @@ const make = (dependencies: Dependencies) => {
})
: Effect.void,
)
const prepared = yield* dependencies.modelRequests.prepare({
const prepared = yield* plan.prepare({
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
transcript: { system: [], messages: [Message.user(plan.prompt)] },
contextHooks: false,
@@ -348,6 +352,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved: input.resolved,
prepare: input.prepare,
reason: "auto",
...content,
})
@@ -387,7 +392,7 @@ const make = (dependencies: Dependencies) => {
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* dependencies.models.resolve(input.session).pipe(
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
@@ -401,6 +406,7 @@ const make = (dependencies: Dependencies) => {
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
@@ -422,14 +428,12 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return make({ bus, llm, models, modelRequests })
return make({ bus, llm })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
deps: [Bus.node, llmClient],
})
+55 -4
View File
@@ -2,6 +2,7 @@ export * as SessionContext from "./context.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import { CodeModeInstructions } from "../codemode/instructions.js"
import { Database } from "../database/database.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -11,6 +12,7 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { Model } from "../model.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
@@ -19,6 +21,7 @@ import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { InstructionEntry } from "./instruction-entry.js"
import { SessionMessage } from "./message.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
@@ -42,14 +45,27 @@ export interface Loaded {
/**
* Resolves model-request state in two phases: `select` fixes the Session,
* agent, instruction sources, and tool snapshot; `load` adds the model and
* active history for that selection. This module does not build or execute the
* model request.
* active history for that selection. Auxiliary operations resolve only the
* capabilities they need; request preparation stays separate from selection.
*/
export interface Interface {
/** Selects the Session, agent, instructions, and tools used by subsequent work. */
readonly select: (sessionID: SessionSchema.ID) => Effect.Effect<Selection, AgentNotFoundError>
/** Resolves the model and active history for that selection. */
readonly load: (selection: Selection) => Effect.Effect<Loaded, SessionRunnerModel.Error>
readonly resolveModel: (
session: SessionSchema.Info,
) => Effect.Effect<SessionRunnerModel.Resolved, SessionRunnerModel.Error>
/** Selects auxiliary title capabilities without instruction or tool preflight. */
readonly selectTitle: (session: SessionSchema.Info) => Effect.Effect<
| {
readonly agent: Agent.Info
readonly primary: SessionRunnerModel.Resolved | undefined
readonly selected: SessionRunnerModel.Resolved
}
| undefined
>
readonly prepare: SessionModelRequest.Interface["prepare"]
}
/** Location-scoped model-context loader for durable Session Steps. */
@@ -60,6 +76,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const agents = yield* Agent.Service
const builtins = yield* InstructionBuiltIns.Service
const catalog = yield* Catalog.Service
const db = (yield* Database.Service).db
const discovery = yield* InstructionDiscovery.Service
const entries = yield* InstructionEntry.Service
@@ -67,12 +84,41 @@ const layer = Layer.effect(
const mcpInstructions = yield* McpInstructions.Service
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* PluginSupervisor.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
const registry = yield* Tool.Service
const resolveModel = (session: SessionSchema.Info) => models.resolve(session, catalog.model.available)
const selectTitle = Effect.fn("SessionContext.selectTitle")(function* (session: SessionSchema.Info) {
const agent = yield* agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* resolveModel(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* resolveModel({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
}).pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
return { agent, primary, selected }
})
const select = Effect.fn("SessionContext.select")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -112,7 +158,7 @@ const layer = Layer.effect(
})
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
const model = yield* models.resolve(selection.session)
const model = yield* resolveModel(selection.session)
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
return {
session: selection.session,
@@ -124,15 +170,19 @@ const layer = Layer.effect(
}
})
return Service.of({ select, load })
return Service.of({ select, load, resolveModel, selectTitle, prepare: modelRequests.prepare })
}),
)
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Agent.node,
Catalog.node,
Database.node,
InstructionBuiltIns.node,
InstructionDiscovery.node,
@@ -143,6 +193,7 @@ export const node = makeLocationNode({
PluginSupervisor.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
SkillInstructions.node,
Tool.node,
+3 -6
View File
@@ -9,7 +9,6 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
export const layer = Layer.effect(
SessionGenerate.Service,
@@ -17,13 +16,11 @@ export const layer = Layer.effect(
const context = yield* SessionContext.Service
const database = yield* Database.Service
const llm = yield* LLMClient.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
return SessionGenerate.Service.of({
generate: Effect.fn("SessionGenerate.generate")(function* (input) {
const selection = yield* context.select(input.sessionID)
const model = yield* models.resolve(selection.session)
const model = yield* context.resolveModel(selection.session)
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
const transcript = SessionModelRequest.baseTranscript({
agent: selection.agent.info,
@@ -32,7 +29,7 @@ export const layer = Layer.effect(
initial: history.initial,
messages: history.messages,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
transcript: {
system: transcript.system,
@@ -59,5 +56,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: SessionGenerate.Service,
layer,
deps: [SessionContext.node, Database.node, SessionModelRequest.node, SessionRunnerModel.node, llmClient],
deps: [SessionContext.node, Database.node, llmClient],
})
+9 -4
View File
@@ -36,7 +36,6 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -142,6 +141,8 @@ const layer = Layer.effect(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
resolveModel: context.resolveModel,
prepare: context.prepare,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
@@ -215,7 +216,12 @@ const layer = Layer.effect(
// Reuse boundary preparation once; retries refresh context without delivering more input.
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
initial = undefined
const compactionInput = { session: loaded.session, messages: loaded.messages, resolved: loaded.model }
const compactionInput = {
session: loaded.session,
messages: loaded.messages,
resolved: loaded.model,
prepare: context.prepare,
}
if (compaction.required(compactionInput)) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
@@ -230,7 +236,7 @@ const layer = Layer.effect(
initial: loaded.initial,
messages: loaded.messages,
})
const prepared = yield* modelRequests.prepare({
const prepared = yield* context.prepare({
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
transcript: {
system: transcript.system,
@@ -319,7 +325,6 @@ export const node = makeLocationNode({
Bus.node,
llmClient,
SessionContext.node,
SessionModelRequest.node,
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
+8 -6
View File
@@ -3,7 +3,6 @@ export * as SessionRunnerModel from "./model.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LanguageModel } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "../../catalog.js"
import { ModelResolver } from "../../model-resolver.js"
import { Capabilities, ID, Info, Ref, VariantID } from "../../model.js"
import { Provider } from "../../provider.js"
@@ -41,7 +40,11 @@ export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolve
export type Resolved = ModelResolver.Resolved
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
/** Availability is sampled lazily for each explicitly selected model resolution. */
readonly resolve: (
session: SessionSchema.Info,
available: () => Effect.Effect<ReadonlyArray<Info>>,
) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunnerModel") {}
@@ -70,17 +73,16 @@ export const resolved = (
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* catalog.model.available()).find(
const selected = (yield* available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
@@ -94,4 +96,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
+13 -55
View File
@@ -4,18 +4,16 @@ import { isDeepStrictEqual } from "node:util"
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import { Agent } from "../agent.js"
import { Catalog } from "../catalog.js"
import type { Agent } from "../agent.js"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { llmClient } from "../effect/app-node-platform.js"
import { Model } from "../model.js"
import { SessionContext } from "./context.js"
import { SessionEvent } from "./event.js"
import { SessionHistory } from "./history.js"
import { SessionModelRequest } from "./model-request.js"
import { SessionRunnerModel } from "./runner/model.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { SessionUsage } from "./usage.js"
import { SessionStore } from "./store.js"
@@ -30,10 +28,7 @@ type Dependencies = {
readonly llm: {
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
}
readonly agents: Agent.Interface
readonly catalog: Catalog.Interface
readonly models: SessionRunnerModel.Interface
readonly modelRequests: SessionModelRequest.Interface
readonly context: SessionContext.Interface
readonly store: SessionStore.Interface
}
@@ -72,7 +67,7 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
})
: Effect.void,
)
const prepared = yield* dependencies.modelRequests.prepare({
const prepared = yield* dependencies.context.prepare({
scope: { session: input.session, agentID: input.agent.id, model: input.model },
transcript: {
system: input.agent.system ? [SystemPart.make(input.agent.system)] : [],
@@ -106,9 +101,6 @@ const attempt = Effect.fn("SessionTitle.attempt")(function* (
.find((line) => line.length > 0)
})
/** Variant IDs that minimize reasoning output, in preference order. */
const MINIMAL_REASONING_VARIANTS = ["none", "minimal", "low"].map((id) => Model.VariantID.make(id))
const make = (dependencies: Dependencies) => {
const generate = Effect.fn("SessionTitle.generate")(function* (
db: Database.Interface["db"],
@@ -140,34 +132,12 @@ const make = (dependencies: Dependencies) => {
Effect.orElseSucceed(() => firstUser.text),
)
: firstUser.text
const agent = yield* dependencies.agents.get(Agent.ID.make("title"))
if (!agent) return
const primary = yield* dependencies.models.resolve(session).pipe(Effect.orElseSucceed(() => undefined))
const info = yield* Effect.gen(function* () {
if (agent.model) return yield* dependencies.catalog.model.get(agent.model.providerID, agent.model.id)
if (!primary) return
return yield* dependencies.catalog.model.small(primary.ref.providerID)
})
const variant =
agent.model?.variant ?? MINIMAL_REASONING_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
const preferred =
info &&
(yield* dependencies.models
.resolve({
...session,
model: Model.Ref.make({
providerID: info.providerID,
id: info.id,
...(variant ? { variant } : {}),
}),
})
.pipe(Effect.orElseSucceed(() => undefined)))
const selected = preferred ?? primary
if (!selected) return
const selection = yield* dependencies.context.selectTitle(session)
if (!selection) return
const title =
(yield* attempt(dependencies, { session, agent, text, model: selected })) ??
(primary && !isDeepStrictEqual(selected.ref, primary.ref)
? yield* attempt(dependencies, { session, agent, text, model: primary })
(yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.selected })) ??
(selection.primary && !isDeepStrictEqual(selection.selected.ref, selection.primary.ref)
? yield* attempt(dependencies, { session, agent: selection.agent, text, model: selection.primary })
: undefined)
if (!title) return
const expectedSequence = (yield* Bus.latestSequence(db, sessionID)) + 1
@@ -192,13 +162,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const context = yield* SessionContext.Service
const store = yield* SessionStore.Service
const database = yield* Database.Service
const title = make({ bus, llm, agents, catalog, models, modelRequests, store })
const title = make({ bus, llm, context, store })
return Service.of({
generate: (sessionID) => title.generate(database.db, sessionID),
})
@@ -208,14 +175,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
llmClient,
Agent.node,
Catalog.node,
SessionRunnerModel.node,
SessionModelRequest.node,
SessionStore.node,
Database.node,
],
deps: [Bus.node, llmClient, SessionContext.node, SessionStore.node, Database.node],
})
+5 -7
View File
@@ -9,6 +9,7 @@ import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
@@ -38,19 +39,13 @@ const config = Config.testLayer()
const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Config.node, Bus.node]), [
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[
SessionRunnerModel.node,
Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
}),
],
[Config.node, config],
]),
),
@@ -59,6 +54,7 @@ describe("ConfigCompactionPlugin.Plugin", () => {
it.live("merges settings and reloads changed config", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const modelRequests = yield* SessionModelRequest.Service
const config = yield* Config.Test
const bus = yield* Bus.Service
yield* config.setEntries([
@@ -85,6 +81,8 @@ describe("ConfigCompactionPlugin.Plugin", () => {
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
+15 -8
View File
@@ -797,8 +797,10 @@ describe("LocationServiceMap", () => {
}),
),
)
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
Session.Info.make({
id: Session.ID.make("ses_unavailable_model"),
projectID: Project.ID.global,
@@ -812,8 +814,9 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -837,8 +840,10 @@ describe("LocationServiceMap", () => {
["azure-cognitive-services", "azure"],
["google-vertex-anthropic", "google-vertex"],
] as const) {
const failure = yield* SessionRunnerModel.Service.use((models) =>
models.resolve(
const failure = yield* Effect.gen(function* () {
const catalog = yield* Catalog.Service
const models = yield* SessionRunnerModel.Service
return yield* models.resolve(
Session.Info.make({
id: Session.ID.make(`ses_removed_${providerID}`),
projectID: Project.ID.global,
@@ -852,8 +857,9 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
),
).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelUnavailableError",
@@ -905,6 +911,7 @@ describe("LocationServiceMap", () => {
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location,
}),
catalog.model.available,
)
}).pipe(Effect.provide(LocationServiceMap.Service.get(location)))
+11 -4
View File
@@ -10,6 +10,7 @@ import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionModelRequest } from "@opencode-ai/core/session/model-request"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -73,9 +74,6 @@ const resolved = SessionRunnerModel.resolved(model, {
cost,
limit: { context: 200_000, output: 32_000 },
})
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () => Effect.succeed(resolved),
})
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
@@ -85,11 +83,11 @@ const it = testEffect(
SessionStore.node,
PluginHooks.node,
SessionCompaction.node,
SessionModelRequest.node,
]),
[
[Bus.node, Bus.configured({ persist: true })],
[llmClient, client],
[SessionRunnerModel.node, models],
],
),
)
@@ -242,6 +240,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
time: { created: DateTime.makeUnsafe(0) },
}
const session = yield* insertSession(sessionID, { parent_id: parentID })
const modelRequests = yield* SessionModelRequest.Service
const delta = yield* bus
.subscribe(SessionEvent.Compaction.Delta)
@@ -250,6 +249,8 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [userMessage],
inputID: SessionMessage.ID.make("msg_manual_compaction"),
}),
@@ -303,9 +304,12 @@ it.effect("forked session compaction reuses the fork root prompt cache key", ()
fork_session_id: rootID,
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
})
const modelRequests = yield* SessionModelRequest.Service
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
@@ -336,9 +340,12 @@ it.effect("keeps session context hooks away from compaction requests", () =>
}),
)
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
const modelRequests = yield* SessionModelRequest.Service
expect(
yield* compaction.compactManual({
session,
resolveModel: () => Effect.succeed(resolved),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
+4
View File
@@ -24,6 +24,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionTitle } from "@opencode-ai/core/session/title"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { Location } from "@opencode-ai/core/location"
import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -127,6 +129,8 @@ const it = testEffect(
[llmClient, client],
[Catalog.node, catalog],
[SessionRunnerModel.node, models],
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
[PluginSupervisor.node, Layer.mock(PluginSupervisor.Service, { flush: Effect.void })],
],
),
)
+18 -20
View File
@@ -7,24 +7,22 @@ type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
const layer = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const make =
<R>(testLayer: Layer.Layer<R>) =>
<A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(testLayer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
const effect = <A, E>(name: string, body: Body<A, E, Scope>, options?: Parameters<typeof test>[2]) =>
test(
name,
() =>
Effect.gen(function* () {
const exit = yield* Effect.suspend(() => (typeof body === "function" ? body() : body)).pipe(
Effect.scoped,
Effect.provide(layer),
Effect.exit,
)
if (Exit.isFailure(exit)) {
yield* Effect.forEach(Cause.prettyErrors(exit.cause), Effect.logError, { discard: true })
}
return yield* exit
}).pipe(Effect.runPromise),
options,
)
export const it = { effect: make(layer), live: make(TestConsole.layer) }
export const it = { effect }
+180 -134
View File
@@ -20,7 +20,6 @@ import {
emitPromise,
generate,
GenerationError,
type Output,
} from "../src"
import { it } from "./effect"
import { Api as FixtureApi, Missing } from "./fixture"
@@ -33,21 +32,6 @@ function compile<Id extends string, Groups extends HttpApiGroup.Constraint>(sour
return emitEffect(compileContract(source))
}
async function emittedModule(output: Output) {
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const dispose = () => rm(directory, { recursive: true, force: true })
try {
// Finish each write before cleanup can run, even when a later write fails.
await Array.fromAsync(output.files, (file) => Bun.write(join(directory, file.path), file.content))
const module = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
return { module, [Symbol.asyncDispose]: dispose }
} catch (cause) {
await dispose()
throw cause
}
}
describe("HttpApiCodegen.generate", () => {
test("compiles one contract for Promise and Effect emitters", () => {
const contract = compileContract(
@@ -368,21 +352,27 @@ describe("HttpApiCodegen.generate", () => {
),
)
const output = emitPromise(compileContract(source))
await using emitted = await emittedModule(output)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const methods: Array<string> = []
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
methods.push(init?.method ?? "GET")
return Response.json("ok")
},
})
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
expect(await client.session.instructions.list()).toBe("ok")
expect(await client.session.instructions.put()).toBe("ok")
expect(await client.session.instructions.remove()).toBe("ok")
expect(methods).toEqual(["GET", "PUT", "DELETE"])
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects duplicate and leaf-namespace endpoint paths", () => {
@@ -835,19 +825,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ data: "hello" })
},
})
expect(await client.session.get({ sessionID: "a/b" })).toBe("hello")
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/session/a%2Fb")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("maps an emitted no-content response to undefined", async () => {
@@ -861,13 +858,20 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => new Response(null, { status: 204 }),
})
expect(await client.session.interrupt({ sessionID: "session" })).toBeUndefined()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("executes an emitted binary wildcard GET through fetch", async () => {
@@ -881,21 +885,28 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
@@ -912,22 +923,29 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
expect(await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" })).toBe(
"admitted",
)
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: "admitted" })
},
})
expect(
await client.session.prompt({ sessionID: "session", resume: true, traceID: "trace", prompt: "hello" }),
).toBe("admitted")
expect(request?.url).toBe("https://example.com/session/session?resume=true")
expect(request?.headers.get("traceID")).toBe("trace")
expect(await request?.json()).toEqual({ prompt: "hello" })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes an opaque union payload as the direct JSON body", async () => {
@@ -944,19 +962,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
await client.session.configure({ payload: { type: "local", command: ["opencode"] } })
expect(await request?.json()).toEqual({ type: "local", command: ["opencode"] })
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes explicit null query values", async () => {
@@ -970,19 +995,26 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let request: Request | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
await client.session.list({ parentID: null })
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
request = input instanceof Request ? input : new Request(input, init)
return Response.json({ data: [] })
},
})
expect(request?.url).toBe("https://example.com/session?parentID=null")
await client.session.list({ parentID: null })
expect(request?.url).toBe("https://example.com/session?parentID=null")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects with declared tagged errors and exports a type guard", async () => {
@@ -997,15 +1029,22 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(emitted.module.isMissing(error)).toBeTrue()
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async () => Response.json({ _tag: "Missing", message: "gone" }, { status: 404 }),
})
const error = await client.session.get({ sessionID: "missing" }).catch((cause: unknown) => cause)
expect(error).toEqual({ _tag: "Missing", message: "gone" })
expect(generated.isMissing(error)).toBeTrue()
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("iterates an emitted SSE stream lazily without reconnecting", async () => {
@@ -1021,35 +1060,42 @@ describe("HttpApiCodegen.generate", () => {
),
),
)
await using emitted = await emittedModule(output)
let requests = 0
let url: string | undefined
const client = emitted.module.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let requests = 0
let url: string | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
requests++
url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
const encoder = new TextEncoder()
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode('data: {"type":"ready","count":"1"}\r'))
controller.enqueue(encoder.encode("\n\r\n"))
controller.close()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
},
})
const events = client.session.subscribe({ after: 2 })
expect(requests).toBe(0)
const received = []
for await (const event of events) received.push(event)
expect(received).toEqual([{ type: "ready", count: "1" }])
expect(requests).toBe(1)
expect(url).toBe("https://example.com/event?after=2")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("preserves public group and endpoint identifiers exactly", () => {
@@ -1092,7 +1138,7 @@ describe("HttpApiCodegen.generate", () => {
for (const file of output.files) expect(() => transpiler.transformSync(file.content)).not.toThrow()
})
it.live("keeps the strict generated-consumer fixture current", () =>
it.effect("keeps the strict generated-consumer fixture current", () =>
Effect.gen(function* () {
const output = compile(FixtureApi)
const actual = yield* Effect.promise(() =>
@@ -1,5 +1,32 @@
import { expect, story } from "../../storybook/playwright/story"
for (const tool of ["shell", "execute", "subagent"]) {
for (const open of [false, true]) {
story(`keeps ${tool} inside an existing ${open ? "open" : "closed"} group through execution`, async ({ mount }) => {
const timeline = await mount("current-session-terminal-work--terminal-commands", {
args: { existingGroup: true, tool },
})
const group = timeline.locator('[data-component="collapsed-tool-group"]')
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle")
if (open) await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
await timeline.getByRole("button", { name: "Start tool", exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
const original = await group.elementHandle()
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
await expect(group.locator('[data-component="tag"]')).toHaveText("2")
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
if (open) await expect(group.locator('[data-timeline-part-id="tool_shell_lifecycle"]')).toBeVisible()
}
})
}
}
for (const expanded of [false, true]) {
// Moved from packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts
story(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ mount }) => {
@@ -0,0 +1,51 @@
import { expect, story } from "../../storybook/playwright/story"
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
await expect(group.getByRole("button", { name: "Used Shell, Read, Agent", exact: true })).toBeVisible()
await expect(group.locator('[data-component="tag"]')).toHaveText("4")
await expect(group.locator('[data-component="task-tool-title"]')).toHaveText(["General", "Explore"])
})
for (const width of [840, 390]) {
story(`keeps grouped cards inside their trigger bounds at ${width}px`, async ({ mount, page }) => {
await page.setViewportSize({ width, height: 600 })
const root = await mount("current-tool-group--mixed-tools")
const group = root.locator('[data-component="collapsed-tool-group"]')
const cards = group.locator('[data-component="task-tool-surface"]')
await expect(cards).toHaveCount(2)
await expect
.poll(() =>
cards.evaluateAll((nodes) =>
nodes.map((node) => {
const card = node.getBoundingClientRect()
const trigger = node.closest('[data-component="tool-trigger"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return (
card.height === 36 &&
card.top >= trigger.top &&
card.bottom <= trigger.bottom &&
card.top >= item.top &&
card.bottom <= item.bottom
)
}),
),
)
.toEqual([true, true])
const shell = group.locator('[data-timeline-part-id="group_shell"]')
await expect(shell.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await shell.getByRole("button").click()
await expect(shell.locator('[data-slot="bash-command"]')).toHaveText("printf 'group geometry'")
await expect(shell.locator('[data-slot="bash-result"]')).toHaveText("group geometry")
await expect
.poll(() =>
shell.evaluate((node) => {
const card = node.querySelector('[data-component="bash-output"]')!.getBoundingClientRect()
const item = node.closest('[data-slot="context-tool-group-item"]')!.getBoundingClientRect()
return card.top >= item.top && card.bottom <= item.bottom
}),
)
.toBe(true)
})
}
@@ -724,7 +724,9 @@
width: 100%;
}
> [data-component="tool-part-wrapper"] > [data-component="collapsible"] > [data-slot="collapsible-trigger"] {
> [data-component="tool-part-wrapper"]
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"]:not([data-hide-details="true"]) {
height: 28px;
}
+10 -2
View File
@@ -508,7 +508,9 @@ function groupContent(
items.forEach((item) => {
const type =
item.content.type === "tool" ? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen) : undefined
item.content.type === "tool"
? toolGroupType(item.content, shellToolDefaultOpen, editToolDefaultOpen, adjacent?.type === "context")
: undefined
if (type) {
if (adjacent?.type !== type) flush()
adjacent ??= { type, refs: [] }
@@ -526,7 +528,12 @@ function groupContent(
return groups
}
function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpanded: boolean, editExpanded: boolean) {
function toolGroupType(
content: Extract<Content, { type: "tool" }>,
shellExpanded: boolean,
editExpanded: boolean,
hasContextGroup: boolean,
) {
if (content.name === "question" || hasLoadedFiles(content)) return undefined
if (content.state.status === "error") {
if ((content.name === "shell" || content.name === "execute") && shellExpanded) return undefined
@@ -535,6 +542,7 @@ function toolGroupType(content: Extract<Content, { type: "tool" }>, shellExpande
return "context"
}
if (
!hasContextGroup &&
(content.state.status !== "completed" ||
("metadata" in content.state && content.state.metadata?.status === "running")) &&
(content.name === "shell" || content.name === "execute" || content.name === "subagent")
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistantTool, SessionMessageInfo } from "@opencode-ai/client/promise"
import { Timeline, TimelineRow } from "./projection"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
describe("current session timeline rows", () => {
test("derives turns and tagged rows from chronological current messages", () => {
@@ -724,7 +725,72 @@ describe("current session timeline rows", () => {
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...types])
})
test("keeps active and background work visible outside collapsed stacks", () => {
test.each(["shell", "execute", "subagent"])("keeps %s in an existing group throughout execution", (name) => {
const initial = createTimelineProjection({
sessionMessages: storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
status: { type: "busy" },
showReasoningSummaries: false,
})
const phases = [
{ status: "streaming" },
{ status: "running" },
{ status: "completed", metadata: { status: "running" } },
{ status: "completed" },
{ status: "error" },
] as const
phases.reduce((previousRows, phase, index) => {
const result = createTimelineProjection({
sessionMessages: [
...storyDocument([storyTool("earlier", "read", "completed", {})]).messages,
...storyDocument([
storyTool("active", name, phase.status, {}, "metadata" in phase ? { metadata: phase.metadata } : {}),
])
.messages.filter((message) => message.type === "assistant")
.map((message) => ({ ...message, id: "next-step" })),
],
status: { type: "busy" },
showReasoningSummaries: false,
previousRows,
})
const groups = result.rows.filter((row) => row._tag === "AssistantPart")
expect(groups).toHaveLength(1)
expect(groups[0].group).toMatchObject({
type: "context",
refs: [
{ messageID: "msg_tool_projection_assistant", partID: "earlier" },
{ messageID: "next-step", partID: "active" },
],
})
expect(TimelineRow.key(groups[0])).toBe(TimelineRow.key(initial.rows[1]))
if (index > 0) expect(groups[0]).toBe(previousRows.find((row) => row._tag === "AssistantPart")!)
return result.rows
}, initial.rows)
})
test.each([
{ name: "shell", expanded: true, types: ["context", "part"] },
{ name: "execute", expanded: true, types: ["context", "part"] },
{ name: "subagent", expanded: true, types: ["context"] },
{ name: "shell", separator: "text", types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: true, types: ["context", "part", "part"] },
{ name: "shell", separator: "reasoning", showReasoning: false, types: ["context"] },
] as const)("respects active tool grouping boundaries: %j", (profile) => {
const content = [
storyTool("earlier", "read", "completed", {}),
...(profile.separator ? [{ type: profile.separator, text: "Visible boundary" }] : []),
storyTool("active", profile.name, "running", {}),
]
const rows = Timeline.constructSessionMessageRows(
storyDocument(content).messages,
profile.showReasoning ?? false,
{ type: "busy" },
undefined,
profile.expanded ?? false,
).rows
expect(rows.flatMap((row) => (row._tag === "AssistantPart" ? [row.group.type] : []))).toEqual([...profile.types])
})
test("keeps active and background work standalone when no group precedes them", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
{
@@ -125,34 +125,63 @@ export const TestFailed = {
),
}
function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolean }) {
function InteractiveCommandStory(props: {
expanded?: boolean
streaming?: boolean
existingGroup?: boolean
tool?: "shell" | "execute" | "subagent"
}) {
const [state, setState] = createStore({
phase: props.streaming ? "streaming" : "completed",
started: !props.existingGroup,
lines: 3,
sibling: false,
busy: false,
})
const document = createMemo(() => {
const phase = state.phase as "streaming" | "input" | "running" | "completed"
const command = phase === "streaming" ? "" : "printf ready"
const content: SessionMessageAssistant["content"] = [
storyTool("tool_shell_lifecycle", "shell", phase === "input" ? "streaming" : phase, command ? { command } : {}, {
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
}),
...(props.existingGroup
? [storyTool("tool_context_lifecycle", "read", "completed", { filePath: "/workspace/README.md" })]
: []),
...(state.started
? [
storyTool(
"tool_shell_lifecycle",
props.tool ?? "shell",
phase === "input" ? "streaming" : phase,
phase === "streaming"
? {}
: props.tool === "execute"
? { code: 'console.log("ready")' }
: props.tool === "subagent"
? { description: "Inspect lifecycle", agent: "explore", prompt: "Inspect lifecycle" }
: { command: "printf ready" },
{
output:
phase === "running"
? "still running"
: Array.from({ length: state.lines }, (_, index) => `line ${index + 1}`).join("\n"),
...(phase === "streaming" ? { raw: "" } : {}),
},
),
]
: []),
...(state.sibling ? [{ type: "text" as const, text: "Sibling content" }] : []),
]
return {
...storyDocument(content, phase !== "completed"),
status: { type: phase !== "completed" || state.busy ? ("busy" as const) : ("idle" as const) },
...storyDocument(content, state.started && phase !== "completed"),
status: { type: (state.started && phase !== "completed") || state.busy ? ("busy" as const) : ("idle" as const) },
}
})
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<div class="flex gap-3">
<div class="flex flex-wrap gap-3">
{props.existingGroup && (
<button type="button" onClick={() => setState({ started: true, phase: "streaming" })}>
Start tool
</button>
)}
<button type="button" onClick={() => setState("phase", "input")}>
Complete input
</button>
@@ -182,16 +211,19 @@ function InteractiveCommandStory(props: { expanded?: boolean; streaming?: boolea
)
}
const RunACommand = {
args: { expanded: false, streaming: false },
render: (args: { expanded: boolean; streaming: boolean }) => <InteractiveCommandStory {...args} />,
}
export const TerminalCommands = {
args: { scenario: "command", expanded: false, streaming: false },
argTypes: { scenario: { control: "select", options: ["command", "collapsed"] } },
render: (args: { scenario: string; expanded: boolean; streaming: boolean }) =>
args.scenario === "collapsed" ? CollapsedShell.render() : RunACommand.render(args),
args: { scenario: "command", expanded: false, streaming: false, existingGroup: false, tool: "shell" },
argTypes: {
scenario: { control: "select", options: ["command", "collapsed"] },
tool: { control: "select", options: ["shell", "execute", "subagent"] },
},
render: (args: {
scenario: string
expanded: boolean
streaming: boolean
existingGroup: boolean
tool: "shell" | "execute" | "subagent"
}) => (args.scenario === "collapsed" ? CollapsedShell.render() : <InteractiveCommandStory {...args} />),
}
export const FixedAndPassed = {
@@ -0,0 +1,35 @@
import { createSignal } from "solid-js"
import { CurrentSessionProviders } from "../storybook/current-session-story"
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
import { CurrentContextToolGroup } from "./tool-renderer"
export default {
title: "OpenCode/Work/Tool group",
id: "current-tool-group",
component: CurrentContextToolGroup,
}
export const MixedTools = {
render: () => {
const [open, setOpen] = createSignal(true)
const tools = [
storyTool(
"group_shell",
"shell",
"completed",
{ command: "printf 'group geometry'" },
{ output: "group geometry" },
),
storyTool("group_read", "read", "completed", { path: "src/group.ts" }),
storyTool("group_general", "subagent", "completed", { agent: "general", description: "Inspect grouped tools" }),
storyTool("group_explore", "subagent", "completed", { agent: "explore", description: "Check card geometry" }),
]
return (
<section style={{ width: "100%", "max-width": "720px", padding: "24px" }}>
<CurrentSessionProviders document={storyDocument(tools)}>
<CurrentContextToolGroup tools={tools} busy={false} open={open()} onOpenChange={setOpen} />
</CurrentSessionProviders>
</section>
)
},
}
@@ -487,8 +487,7 @@ export function CurrentContextToolGroup(props: {
props.tools.map((tool) => {
const input = currentToolInput(tool)
if (tool.name === "skill") return i18n.t("ui.tool.skill")
if (tool.name === "subagent" && typeof input.agent === "string" && input.agent)
return input.agent[0]!.toUpperCase() + input.agent.slice(1)
if (tool.name === "subagent") return i18n.t("ui.tool.agent.default")
return getToolInfo(tool.name, input, currentToolMetadata(tool)).title
}),
),