mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:56:18 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cc81a497c | ||
|
|
10786cb60c |
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 })],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user