mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 128d07421d | |||
| 98310eb310 | |||
| 58a27e7555 |
@@ -105,16 +105,4 @@ describe("extractPromptFromMessage", () => {
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "model text" })
|
||||
})
|
||||
|
||||
test("restores command invocation text", () => {
|
||||
const message = {
|
||||
id: "msg_1",
|
||||
type: "user",
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(extractPromptFromMessage(message)[0]).toMatchObject({ type: "text", content: "/command input" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,9 +44,7 @@ export function extractPromptFromMessage(
|
||||
message: SessionMessageUser,
|
||||
opts?: { directory?: string; attachmentName?: string },
|
||||
): Prompt {
|
||||
const text = message.command
|
||||
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
|
||||
: (readPromptPresentation(message.metadata)?.displayText ?? message.text)
|
||||
const text = readPromptPresentation(message.metadata)?.displayText ?? message.text
|
||||
const directory = opts?.directory
|
||||
const attachmentName = opts?.attachmentName ?? "attachment"
|
||||
const toRelative = (path: string) => {
|
||||
|
||||
@@ -50,18 +50,6 @@ describe("session message presentation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("projects command invocation text", () => {
|
||||
const message = {
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
time: { created: 1 },
|
||||
} satisfies SessionMessageUser
|
||||
|
||||
expect(presentUserParts("ses_1", message)[0]).toMatchObject({ type: "text", text: "/command input" })
|
||||
})
|
||||
|
||||
test("projects current assistant content for existing DOM tools", () => {
|
||||
const message = {
|
||||
id: "msg_assistant",
|
||||
|
||||
@@ -57,9 +57,7 @@ export function presentUserMessage(
|
||||
|
||||
export function presentUserParts(sessionID: string, message: SessionMessageUser): Part[] {
|
||||
const presentation = readPromptPresentation(message.metadata)
|
||||
const text = message.command
|
||||
? `/${message.command.name}${message.command.arguments ? ` ${message.command.arguments}` : ""}`
|
||||
: (presentation?.displayText ?? message.text)
|
||||
const text = presentation?.displayText ?? message.text
|
||||
return [
|
||||
...(text ? [textPart(sessionID, message.id, 0, text)] : []),
|
||||
...(message.files ?? []).map(
|
||||
|
||||
@@ -30,8 +30,6 @@ export type FileDiffInfo = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PromptCommandInvocation = { name: string; arguments: string }
|
||||
|
||||
export type PromptBase64 = string
|
||||
|
||||
export type PromptFileSource = { type: "inline" } | { type: "uri"; uri: string }
|
||||
@@ -1686,7 +1684,6 @@ export type SessionMessageUser = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -1695,7 +1692,6 @@ export type SessionMessageUser = {
|
||||
|
||||
export type SessionInboxUserPayload = {
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -1704,7 +1700,6 @@ export type SessionInboxUserPayload = {
|
||||
|
||||
export type SessionInboxUserPayload1 = {
|
||||
text: string
|
||||
command?: PromptCommandInvocation
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
skills?: Array<PromptSkillAttachment>
|
||||
@@ -2557,7 +2552,6 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
@@ -2827,7 +2821,6 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
@@ -3097,7 +3090,6 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly text: string
|
||||
readonly command?: { readonly name: string; readonly arguments: string }
|
||||
readonly files?: ReadonlyArray<{
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
|
||||
@@ -222,7 +222,6 @@ export interface Interface {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
text: string
|
||||
command?: Prompt["command"]
|
||||
files?: PromptInput.Prompt["files"]
|
||||
agents?: PromptInput.Prompt["agents"]
|
||||
skills?: PromptInput.Prompt["skills"]
|
||||
@@ -587,7 +586,11 @@ const layer = Layer.effect(
|
||||
return yield* Image.Service
|
||||
}).pipe(Effect.provide(locations.get(session.location)))
|
||||
const skills = Skill.Service.pipe(Effect.provide(locations.get(session.location)))
|
||||
const prompt = yield* resolvePrompt(input, image, skills).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const prompt = yield* resolvePrompt(
|
||||
{ text: input.text, files: input.files, agents: input.agents, skills: input.skills },
|
||||
image,
|
||||
skills,
|
||||
).pipe(Effect.provideService(FSUtil.Service, fs))
|
||||
const messageID = input.id ?? SessionMessage.ID.create()
|
||||
const admittedInput = SessionInbox.Item.make({
|
||||
type: "user",
|
||||
@@ -654,7 +657,6 @@ const layer = Layer.effect(
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
text: evaluated.text,
|
||||
command: { name: input.command, arguments: input.arguments ?? "" },
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
skills: input.skills,
|
||||
@@ -962,7 +964,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
|
||||
}
|
||||
|
||||
const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
input: PromptInput.Prompt & Pick<Prompt, "command">,
|
||||
input: PromptInput.Prompt,
|
||||
image: Effect.Effect<Image.Interface>,
|
||||
skills: Effect.Effect<Skill.Interface>,
|
||||
) {
|
||||
@@ -985,13 +987,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
|
||||
})
|
||||
})
|
||||
})
|
||||
return Prompt.fromUserMessage({
|
||||
text: input.text,
|
||||
command: input.command,
|
||||
agents: input.agents,
|
||||
files,
|
||||
skills: selected?.length ? selected : undefined,
|
||||
})
|
||||
return Prompt.make({ text: input.text, agents: input.agents, files, skills: selected?.length ? selected : undefined })
|
||||
})
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
@@ -20,7 +20,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
@@ -527,14 +526,17 @@ const layer = Layer.effectDiscard(
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
input.type === "user"
|
||||
? {
|
||||
...Prompt.fromUserMessage(input.payload),
|
||||
id: input.id,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
time: { created: DateTime.makeUnsafe(event.created) },
|
||||
}
|
||||
input.type === "user"
|
||||
? {
|
||||
id: input.id,
|
||||
type: "user",
|
||||
metadata: input.payload.metadata,
|
||||
text: input.payload.text,
|
||||
files: input.payload.files,
|
||||
agents: input.payload.agents,
|
||||
skills: input.payload.skills,
|
||||
time: { created: DateTime.makeUnsafe(event.created) },
|
||||
}
|
||||
: {
|
||||
id: input.id,
|
||||
type: "synthetic",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Effect, JsonSchema, Schema, SchemaAST } from "effect"
|
||||
|
||||
export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
name: effectiveName(tool),
|
||||
@@ -31,23 +31,58 @@ export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool
|
||||
}
|
||||
})
|
||||
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
const decodeInput = (schema: Tool.ValueSchema<any>, value: unknown) =>
|
||||
attemptDecodeInput(schema, value).pipe(
|
||||
Effect.catchTag("Tool.Error", (error) => {
|
||||
// JSON Schema derived from Effect schemas advertises `X | null` for optional
|
||||
// fields because JSON cannot express undefined, so callers legitimately send
|
||||
// null to mean "omitted". Retry with null properties removed: schemas that
|
||||
// genuinely accept null succeed on the first attempt, and the original error
|
||||
// is reported when the retry cannot help.
|
||||
const stripped = withoutNullProperties(value)
|
||||
if (stripped === value) return error
|
||||
return attemptDecodeInput(schema, stripped).pipe(Effect.catchTag("Tool.Error", () => error))
|
||||
}),
|
||||
)
|
||||
|
||||
// Removes null-valued object properties recursively. Array elements are positional
|
||||
// and stay untouched. Returns the input reference when nothing changed.
|
||||
const withoutNullProperties = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(withoutNullProperties)
|
||||
return items.some((item, index) => item !== value[index]) ? items : value
|
||||
}
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
const entries = Object.entries(value).flatMap(([key, item]) =>
|
||||
item === null ? [] : [[key, withoutNullProperties(item)] as const],
|
||||
)
|
||||
const changed =
|
||||
entries.length !== Object.keys(value).length ||
|
||||
entries.some(([key, item]) => (value as Record<string, unknown>)[key] !== item)
|
||||
return changed ? Object.fromEntries(entries) : value
|
||||
}
|
||||
|
||||
const attemptDecodeInput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema)) {
|
||||
if (isForeignSchema(schema)) return foreignSchemaPassthrough(value)
|
||||
return Schema.decodeUnknownEffect(schema)(value).pipe(
|
||||
Effect.mapError((error) => new Tool.Error({ message: `Invalid tool input: ${error.message}` })),
|
||||
)
|
||||
}
|
||||
if (isStandardSchema(schema)) return validateStandard(schema, value, "Invalid tool input")
|
||||
return Effect.succeed(value)
|
||||
}
|
||||
|
||||
const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
if (Schema.isSchema(schema))
|
||||
if (Schema.isSchema(schema)) {
|
||||
if (isForeignSchema(schema)) return foreignSchemaPassthrough(value)
|
||||
return Schema.encodeEffect(schema)(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (isStandardSchema(schema))
|
||||
return validateStandard(schema, value, "Tool returned an invalid value for its output schema")
|
||||
return Schema.decodeUnknownEffect(Schema.Json)(value).pipe(
|
||||
@@ -57,6 +92,23 @@ const encodeOutput = (schema: Tool.ValueSchema<any>, value: unknown) => {
|
||||
)
|
||||
}
|
||||
|
||||
// A schema created by a different copy of `effect` (for example one loaded from a
|
||||
// plugin's own node_modules) still satisfies `Schema.isSchema` because the type
|
||||
// identifier is a shared string, but it cannot be interpreted by this instance:
|
||||
// schema parsing relies on per-instance sentinels and class identity, so checks
|
||||
// false-fail on valid values and branded types die as defects. AST classes are plain
|
||||
// classes, so an instanceof test against this instance's AST base distinguishes the
|
||||
// two reliably.
|
||||
const isForeignSchema = (schema: Schema.Top) => !(schema.ast instanceof SchemaAST.Base)
|
||||
|
||||
// Current @opencode-ai/plugin versions convert plugin schemas to Standard Schema
|
||||
// wrappers before registration, keeping validation in the authoring instance. For
|
||||
// plugins built against older versions, skip validation rather than misvalidate.
|
||||
const foreignSchemaPassthrough = (value: unknown) =>
|
||||
Effect.logWarning(
|
||||
"Tool schema was created by a different `effect` module instance; skipping validation. Update the plugin's @opencode-ai/plugin dependency to restore validation.",
|
||||
).pipe(Effect.as(value))
|
||||
|
||||
const isStandardSchema = (
|
||||
schema: Tool.ValueSchema<any>,
|
||||
): schema is StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any> =>
|
||||
@@ -78,11 +130,19 @@ const validateStandard = (
|
||||
: pending
|
||||
if (result.issues)
|
||||
return yield* new Tool.Error({
|
||||
message: `${prefix}: ${result.issues.map((issue) => issue.message).join(", ")}`,
|
||||
message: `${prefix}: ${result.issues.map(standardIssueText).join(", ")}`,
|
||||
})
|
||||
return result.value
|
||||
})
|
||||
|
||||
const standardIssueText = (issue: StandardSchemaV1.Issue) => {
|
||||
if (issue.path === undefined || issue.path.length === 0) return issue.message
|
||||
const segments = issue.path.map((segment) =>
|
||||
typeof segment === "object" && segment !== null && "key" in segment ? segment.key : segment,
|
||||
)
|
||||
return `${issue.message} at ${JSON.stringify(segments)}`
|
||||
}
|
||||
|
||||
const standardFailure = (prefix: string, error: unknown) =>
|
||||
new Tool.Error({ message: `${prefix}: ${error instanceof Error ? error.message : String(error)}` })
|
||||
|
||||
|
||||
@@ -323,11 +323,7 @@ describe("SessionProjector", () => {
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
id,
|
||||
sessionID,
|
||||
item: {
|
||||
type: "user",
|
||||
payload: { text: "expanded command template", command: { name: "command", arguments: "input" } },
|
||||
delivery: "steer",
|
||||
},
|
||||
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
|
||||
})
|
||||
if (!admitted) return yield* Effect.die("Prompt admission failed")
|
||||
|
||||
@@ -341,15 +337,7 @@ describe("SessionProjector", () => {
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
|
||||
).toMatchObject({
|
||||
session_id: sessionID,
|
||||
type: "user",
|
||||
seq: event.durable?.seq,
|
||||
data: {
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ session_id: sessionID, type: "user", seq: event.durable?.seq })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -235,18 +235,16 @@ describe("Session.prompt", () => {
|
||||
const message = yield* session.prompt({
|
||||
sessionID,
|
||||
text: "Fix the failing tests",
|
||||
command: { name: "fix", arguments: "tests" },
|
||||
resume: false,
|
||||
})
|
||||
|
||||
expect(message.payload.text).toBe("Fix the failing tests")
|
||||
expect(message.payload.command).toEqual({ name: "fix", arguments: "tests" })
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect(yield* admitted(message.id)).toMatchObject({
|
||||
id: message.id,
|
||||
sessionID,
|
||||
type: "user",
|
||||
payload: { text: "Fix the failing tests", command: { name: "fix", arguments: "tests" } },
|
||||
payload: { text: "Fix the failing tests" },
|
||||
delivery: "steer",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { execute } from "@opencode-ai/core/tool/runtime"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_null"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_null"),
|
||||
id: Tool.CallID.make("call_null"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
// The JSON Schema advertised for these tools renders optional fields as `X | null`
|
||||
// (JSON cannot express undefined), so callers legitimately send null to mean
|
||||
// "omitted". The runtime must accept that without weakening schemas that
|
||||
// genuinely distinguish null.
|
||||
const collect = (input: Info["input"]) => {
|
||||
let received: unknown
|
||||
const tool: Info = {
|
||||
name: "probe",
|
||||
description: "Probe",
|
||||
input,
|
||||
execute: (value) => {
|
||||
received = value
|
||||
return Effect.succeed({ content: "ok" })
|
||||
},
|
||||
}
|
||||
return {
|
||||
tool,
|
||||
run: (value: unknown) => Effect.runPromise(execute(tool, value, context)).then(() => received),
|
||||
fail: (value: unknown) => Effect.runPromiseExit(execute(tool, value, context)).then((exit) => exit.toString()),
|
||||
}
|
||||
}
|
||||
|
||||
test("null optional properties decode as omitted", async () => {
|
||||
const probe = collect(
|
||||
Schema.Struct({
|
||||
title: Schema.String,
|
||||
agent: Schema.optional(Schema.String),
|
||||
}),
|
||||
)
|
||||
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
|
||||
})
|
||||
|
||||
test("nested null optional properties decode as omitted", async () => {
|
||||
const probe = collect(
|
||||
Schema.Struct({
|
||||
worktree: Schema.optional(
|
||||
Schema.Struct({
|
||||
branch: Schema.String,
|
||||
base: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
expect(await probe.run({ worktree: { branch: "main", base: null } })).toEqual({ worktree: { branch: "main" } })
|
||||
})
|
||||
|
||||
test("schemas that accept null keep it", async () => {
|
||||
const probe = collect(Schema.Struct({ next: Schema.NullOr(Schema.String) }))
|
||||
expect(await probe.run({ next: null })).toEqual({ next: null })
|
||||
})
|
||||
|
||||
test("null array elements survive the retry", async () => {
|
||||
const probe = collect(
|
||||
Schema.Struct({
|
||||
tags: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
agent: Schema.optional(Schema.String),
|
||||
}),
|
||||
)
|
||||
expect(await probe.run({ tags: ["a", null], agent: null })).toEqual({ tags: ["a", null] })
|
||||
})
|
||||
|
||||
test("unfixable nulls report the original error", async () => {
|
||||
const probe = collect(Schema.Struct({ title: Schema.String }))
|
||||
const message = await probe.fail({ title: null })
|
||||
expect(message).toContain("Invalid tool input")
|
||||
expect(message).toContain("Expected string")
|
||||
})
|
||||
|
||||
test("standard schema inputs get the same retry", async () => {
|
||||
const attempts: Array<unknown> = []
|
||||
const input = {
|
||||
"~standard": {
|
||||
version: 1,
|
||||
vendor: "test",
|
||||
validate: (value: unknown) => {
|
||||
attempts.push(value)
|
||||
const record = value as Record<string, unknown>
|
||||
if ("agent" in record && record.agent === null) return { issues: [{ message: "Expected string | undefined" }] }
|
||||
return { value }
|
||||
},
|
||||
jsonSchema: {
|
||||
input: () => ({ type: "object" }),
|
||||
output: () => ({ type: "object" }),
|
||||
},
|
||||
},
|
||||
} as unknown as Info["input"]
|
||||
const probe = collect(input)
|
||||
expect(await probe.run({ title: "probe", agent: null })).toEqual({ title: "probe" })
|
||||
expect(attempts).toEqual([
|
||||
{ title: "probe", agent: null },
|
||||
{ title: "probe" },
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeAll, expect, test } from "bun:test"
|
||||
import { cp, mkdir, mkdtemp, readFile, symlink } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { definition, execute } from "@opencode-ai/core/tool/runtime"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_foreign"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_foreign"),
|
||||
id: Tool.CallID.make("call_foreign"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
// Plugins load `effect` from their own node_modules, so their schemas come from a
|
||||
// different module instance than the host's. Simulate that by copying the effect
|
||||
// package to a temporary directory and importing the copy: same version, distinct
|
||||
// instance, exactly like a plugin installed in the config directory.
|
||||
let foreign: typeof Schema
|
||||
|
||||
beforeAll(async () => {
|
||||
const source = path.dirname(fileURLToPath(import.meta.resolve("effect/package.json")))
|
||||
const base = await mkdtemp(path.join(tmpdir(), "opencode-foreign-effect-"))
|
||||
const target = path.join(base, "node_modules", "effect")
|
||||
await cp(source, target, { recursive: true })
|
||||
const dependencies = JSON.parse(await readFile(path.join(source, "package.json"), "utf8")).dependencies ?? {}
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
const real = path.dirname(Bun.resolveSync(`${name}/package.json`, source))
|
||||
const link = path.join(base, "node_modules", name)
|
||||
await mkdir(path.dirname(link), { recursive: true })
|
||||
await symlink(real, link, "dir")
|
||||
}
|
||||
const mod = (await import(pathToFileURL(path.join(target, "dist", "index.js")).href)) as { Schema: typeof Schema }
|
||||
foreign = mod.Schema
|
||||
expect<unknown>(foreign).not.toBe(Schema)
|
||||
})
|
||||
|
||||
test("foreign live schemas skip validation instead of misvalidating checks", async () => {
|
||||
// Regression: a minLength check from a foreign instance used to fail on valid
|
||||
// values ('Expected a value with a length of at least 1 at ["title"]') because the
|
||||
// host parser hands the foreign filter an internal sentinel instead of the value.
|
||||
const input = foreign.Struct({
|
||||
title: foreign.optional(foreign.String.check(foreign.isMinLength(1))),
|
||||
prompt: foreign.optional(foreign.String),
|
||||
})
|
||||
expect(Schema.isSchema(input)).toBe(true)
|
||||
let received: unknown
|
||||
const tool: Info = {
|
||||
name: "create",
|
||||
description: "Create",
|
||||
input,
|
||||
execute: (value) => {
|
||||
received = value
|
||||
return Effect.succeed({ content: "ok" })
|
||||
},
|
||||
}
|
||||
const result = await Effect.runPromise(execute(tool, { title: "probe", prompt: "Say ready." }, context))
|
||||
expect(result.content).toEqual([{ type: "text", text: "ok" }])
|
||||
expect(received).toEqual({ title: "probe", prompt: "Say ready." })
|
||||
})
|
||||
|
||||
test("foreign branded schemas no longer die as defects", async () => {
|
||||
// Regression: decoding a foreign branded ID (like Session.ID) threw "Sync adapter
|
||||
// can only throw schema errors", surfacing as a bare "Tool execution failed".
|
||||
const input = foreign.Struct({
|
||||
sessionID: foreign.String.check(foreign.isStartsWith("ses")).pipe(foreign.brand("SessionID")),
|
||||
})
|
||||
const tool: Info = {
|
||||
name: "notify",
|
||||
description: "Notify",
|
||||
input,
|
||||
execute: (value) => Effect.succeed({ content: JSON.stringify(value) }),
|
||||
}
|
||||
const result = await Effect.runPromise(execute(tool, { sessionID: "ses_123" }, context))
|
||||
expect(result.content).toEqual([{ type: "text", text: '{"sessionID":"ses_123"}' }])
|
||||
})
|
||||
|
||||
test("foreign output schemas pass the produced value through", async () => {
|
||||
const tool: Info = {
|
||||
name: "get",
|
||||
description: "Get",
|
||||
input: foreign.Struct({}),
|
||||
output: foreign.Struct({ sessionID: foreign.String }),
|
||||
execute: () => Effect.succeed({ output: { sessionID: "ses_123" } }),
|
||||
}
|
||||
const result = await Effect.runPromise(execute(tool, {}, context))
|
||||
expect(result.output).toEqual({ sessionID: "ses_123" })
|
||||
})
|
||||
|
||||
// Mirrors the conversion current @opencode-ai/plugin versions perform in the
|
||||
// authoring instance before registration (see packages/plugin/src/effect/tool-schema.ts).
|
||||
const convert = (schema: unknown, direction: "input" | "output") => {
|
||||
const anyForeign = foreign as any
|
||||
const oriented = direction === "input" ? schema : anyForeign.flip(schema)
|
||||
const augmented = anyForeign.toStandardJSONSchemaV1(anyForeign.toStandardSchemaV1(oriented))
|
||||
return { "~standard": augmented["~standard"] } as Info["input"]
|
||||
}
|
||||
|
||||
test("converted standard wrappers validate in the authoring instance", async () => {
|
||||
const input = convert(
|
||||
foreign.Struct({
|
||||
title: foreign.optional(foreign.String.check(foreign.isMinLength(1))),
|
||||
}),
|
||||
"input",
|
||||
)
|
||||
expect(Schema.isSchema(input)).toBe(false)
|
||||
let received: unknown
|
||||
const tool: Info = {
|
||||
name: "create",
|
||||
description: "Create",
|
||||
input,
|
||||
output: convert(foreign.Struct({ sessionID: foreign.String }), "output"),
|
||||
execute: (value) => {
|
||||
received = value
|
||||
return Effect.succeed({ output: { sessionID: "ses_123" }, content: "created" })
|
||||
},
|
||||
}
|
||||
|
||||
const success = await Effect.runPromise(execute(tool, { title: "probe" }, context))
|
||||
expect(received).toEqual({ title: "probe" })
|
||||
expect(success.output).toEqual({ sessionID: "ses_123" })
|
||||
|
||||
const failure = await Effect.runPromiseExit(execute(tool, { title: "" }, context))
|
||||
expect(failure.toString()).toContain("Invalid tool input")
|
||||
expect(failure.toString()).toContain("a value with a length of at least 1")
|
||||
expect(failure.toString()).toContain('at ["title"]')
|
||||
|
||||
const derived = definition(tool)
|
||||
expect(derived.inputSchema).toMatchObject({ type: "object" })
|
||||
expect((derived.inputSchema as { properties?: Record<string, unknown> }).properties).toHaveProperty("title")
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Effect, Scope } from "effect"
|
||||
import { instanceSafeTool } from "./tool-schema.js"
|
||||
import type { PluginOptions } from "../options.js"
|
||||
import type { App } from "../app.js"
|
||||
import type { AgentDomain } from "./agent.js"
|
||||
@@ -41,6 +42,23 @@ export interface Plugin<R = Scope.Scope> {
|
||||
readonly effect: (context: Context) => Effect.Effect<void, never, R>
|
||||
}
|
||||
|
||||
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
|
||||
return plugin
|
||||
export function define<R = Scope.Scope>(plugin: Plugin<R>): Plugin<R> {
|
||||
return {
|
||||
...plugin,
|
||||
effect: (context) => plugin.effect(instanceSafeContext(context)),
|
||||
}
|
||||
}
|
||||
|
||||
// Tool schemas cross from the plugin's module world into the host at `draft.add`;
|
||||
// convert them while authoring-instance code is still on the stack so the host never
|
||||
// interprets a foreign Effect schema. See `instanceSafeTool`.
|
||||
function instanceSafeContext(context: Context): Context {
|
||||
return {
|
||||
...context,
|
||||
tool: {
|
||||
...context.tool,
|
||||
transform: (callback) =>
|
||||
context.tool.transform((draft) => callback({ add: (tool) => draft.add(instanceSafeTool(tool)) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Schema } from "effect"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
|
||||
/**
|
||||
* Converts a tool's Effect schemas into detached Standard Schema wrappers so they
|
||||
* survive the crossing from the plugin's module world into the host.
|
||||
*
|
||||
* Plugins often load their own copy of `effect` (for example from the config
|
||||
* directory's node_modules) while the host bundles a different instance. A live
|
||||
* Effect schema cannot be interpreted across that boundary: schema parsing relies on
|
||||
* per-instance sentinels and class identity, so the host misvalidates checks and
|
||||
* turns branded-type failures into defects. A Standard Schema wrapper instead carries
|
||||
* validation and JSON Schema generation as closures bound to the instance that
|
||||
* created the schema, which the host invokes as-is.
|
||||
*/
|
||||
export function instanceSafeTool(tool: Tool.Info<any, any>): Tool.Info<any, any> {
|
||||
const input = instanceSafeValueSchema(tool.input, "input")
|
||||
const output = tool.output === undefined ? undefined : instanceSafeValueSchema(tool.output, "output")
|
||||
if (input === tool.input && output === tool.output) return tool
|
||||
return { ...tool, input, ...(output === undefined ? {} : { output }) }
|
||||
}
|
||||
|
||||
function instanceSafeValueSchema(schema: Tool.ValueSchema<any>, direction: "input" | "output"): Tool.ValueSchema<any> {
|
||||
if (!Schema.isSchema(schema)) return schema
|
||||
// Inputs are decoded (Encoded -> Type) but outputs are encoded (Type -> Encoded),
|
||||
// so outputs use the flipped schema: its standard `validate` runs in the encode
|
||||
// direction and its `jsonSchema.output` still describes the encoded shape.
|
||||
const oriented = direction === "input" ? (schema as Schema.Top) : Schema.flip(schema as Schema.Top)
|
||||
// Both converters augment the schema object in place and return it; the host must
|
||||
// receive a plain wrapper instead, because the augmented object still satisfies
|
||||
// `Schema.isSchema` and would route back into cross-instance interpretation.
|
||||
const augmented = Schema.toStandardJSONSchemaV1(
|
||||
Schema.toStandardSchemaV1(oriented as never) as never,
|
||||
) as unknown as StandardWrapper
|
||||
return { "~standard": augmented["~standard"] } as Tool.ValueSchema<any>
|
||||
}
|
||||
|
||||
type StandardWrapper = { readonly "~standard": Record<string, unknown> }
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Plugin } from "../src/effect/index.js"
|
||||
import type { Tool } from "@opencode-ai/schema/tool"
|
||||
|
||||
// `define` must hand the host detached Standard Schema wrappers instead of live
|
||||
// Effect schemas: hosts may run a different `effect` instance, which cannot
|
||||
// interpret foreign schemas (checks false-fail and branded types die as defects).
|
||||
const collectTool = async (tool: Tool.Info<any, any>) => {
|
||||
const added: Array<Tool.Info<any, any>> = []
|
||||
const context = {
|
||||
tool: {
|
||||
transform: (callback: (draft: { add: (tool: Tool.Info<any, any>) => void }) => void) => {
|
||||
callback({ add: (item) => added.push(item) })
|
||||
return Effect.succeed({ dispose: Effect.void })
|
||||
},
|
||||
},
|
||||
} as unknown as Plugin.Context
|
||||
const plugin = Plugin.define({
|
||||
id: "test.instance-safe",
|
||||
effect: (ctx) => ctx.tool.transform((draft) => draft.add(tool)).pipe(Effect.asVoid),
|
||||
})
|
||||
await Effect.runPromise(Effect.scoped(plugin.effect(context)))
|
||||
expect(added).toHaveLength(1)
|
||||
return added[0]
|
||||
}
|
||||
|
||||
type StandardValue = StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>
|
||||
|
||||
test("define converts Effect schemas to detached standard wrappers", async () => {
|
||||
const execute = (input: { title?: string }) => Effect.succeed({ output: { id: `ses_${input.title}` } })
|
||||
const registered = await collectTool({
|
||||
name: "create",
|
||||
description: "Create",
|
||||
input: Schema.Struct({ title: Schema.optional(Schema.String.check(Schema.isMinLength(1))) }),
|
||||
output: Schema.Struct({ id: Schema.String }),
|
||||
execute,
|
||||
})
|
||||
|
||||
expect(registered.execute).toBe(execute)
|
||||
expect(Schema.isSchema(registered.input)).toBe(false)
|
||||
expect(Schema.isSchema(registered.output)).toBe(false)
|
||||
|
||||
const input = registered.input as StandardValue
|
||||
expect(await input["~standard"].validate({ title: "probe" })).toEqual({ value: { title: "probe" } })
|
||||
const invalid = await input["~standard"].validate({ title: "" })
|
||||
expect(invalid.issues?.[0]?.message).toContain("a value with a length of at least 1")
|
||||
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "object" })
|
||||
|
||||
// Outputs validate in the encode direction (Type -> Encoded) and describe the
|
||||
// encoded shape.
|
||||
const output = registered.output as StandardValue
|
||||
expect(await output["~standard"].validate({ id: "ses_x" })).toEqual({ value: { id: "ses_x" } })
|
||||
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
})
|
||||
})
|
||||
|
||||
test("define leaves non-Effect schemas untouched", async () => {
|
||||
const input = { type: "object" as const }
|
||||
const registered = await collectTool({
|
||||
name: "raw",
|
||||
description: "Raw",
|
||||
input,
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
})
|
||||
expect(registered.input).toBe(input)
|
||||
expect(registered.output).toBeUndefined()
|
||||
})
|
||||
@@ -61,16 +61,9 @@ export const SkillAttachment = Schema.Struct({
|
||||
mention: PromptMention.pipe(optional),
|
||||
}).annotate({ identifier: "Prompt.SkillAttachment" })
|
||||
|
||||
export interface CommandInvocation extends Schema.Schema.Type<typeof CommandInvocation> {}
|
||||
export const CommandInvocation = Schema.Struct({
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}).annotate({ identifier: "Prompt.CommandInvocation" })
|
||||
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
export const Prompt = Schema.Struct({
|
||||
text: Schema.String,
|
||||
command: CommandInvocation.pipe(optional),
|
||||
files: Schema.Array(FileAttachment).pipe(optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(optional),
|
||||
skills: Schema.Array(SkillAttachment).pipe(optional),
|
||||
@@ -79,10 +72,9 @@ export const Prompt = Schema.Struct({
|
||||
.pipe(
|
||||
statics((schema) => ({
|
||||
equivalence: Schema.toEquivalence(schema),
|
||||
fromUserMessage: (input: Pick<Prompt, "text" | "command" | "files" | "agents" | "skills">) =>
|
||||
fromUserMessage: (input: Pick<Prompt, "text" | "files" | "agents" | "skills">) =>
|
||||
schema.make({
|
||||
text: input.text,
|
||||
...(input.command === undefined ? {} : { command: input.command }),
|
||||
...(input.files === undefined ? {} : { files: input.files }),
|
||||
...(input.agents === undefined ? {} : { agents: input.agents }),
|
||||
...(input.skills === undefined ? {} : { skills: input.skills }),
|
||||
|
||||
@@ -72,7 +72,10 @@ export const LocationSwitched = Schema.Struct({
|
||||
export interface User extends Schema.Schema.Type<typeof User> {}
|
||||
export const User = Schema.Struct({
|
||||
...Base,
|
||||
...Prompt.fields,
|
||||
text: Prompt.fields.text,
|
||||
files: Prompt.fields.files,
|
||||
agents: Prompt.fields.agents,
|
||||
skills: Prompt.fields.skills,
|
||||
type: Schema.tag("user"),
|
||||
}).annotate({ identifier: "Session.Message.User" })
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { dedupeWith } from "effect/Array"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createMemo, onCleanup } from "solid-js"
|
||||
import { batch, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
@@ -32,6 +32,22 @@ export function parseModel(model: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A session stored without a model runs on the server's default model, so the
|
||||
* status line shows that effective model instead of claiming no provider is
|
||||
* selected. "No provider selected" remains only when no usable default exists.
|
||||
*/
|
||||
export function withDefaultModelFallback(options: {
|
||||
selection: (ModelPreferenceModel & { variant?: string }) | undefined
|
||||
defaultModel: ModelPreferenceModel | undefined
|
||||
isValid: (model: ModelPreferenceModel) => boolean
|
||||
variantPreference: (model: ModelPreferenceModel) => string | undefined
|
||||
}) {
|
||||
if (options.selection) return options.selection
|
||||
if (!options.defaultModel || !options.isValid(options.defaultModel)) return undefined
|
||||
return { ...options.defaultModel, variant: normalizeModelVariant(options.variantPreference(options.defaultModel)) }
|
||||
}
|
||||
|
||||
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
|
||||
const seen = new Set<string>()
|
||||
return [model, ...recent]
|
||||
@@ -217,8 +233,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
)
|
||||
})
|
||||
|
||||
const [serverDefaultModel] = createResource(
|
||||
() => {
|
||||
const ref = location.ref ?? data.location.default()
|
||||
// Refetch when the catalog changes, such as a provider connecting.
|
||||
return JSON.stringify([ref.directory, ref.workspaceID, models()?.length ?? -1])
|
||||
},
|
||||
async () => {
|
||||
const ref = location.ref ?? data.location.default()
|
||||
const response = await client.api.model
|
||||
.default({ location: { directory: ref.directory, workspace: ref.workspaceID } })
|
||||
.catch(() => undefined)
|
||||
if (!response?.data) return undefined
|
||||
return { providerID: response.data.providerID, modelID: response.data.id }
|
||||
},
|
||||
)
|
||||
|
||||
const currentSelection = createMemo<ModelSelection | undefined>(() => {
|
||||
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
||||
if (route.data.type === "session")
|
||||
return withDefaultModelFallback({
|
||||
selection: sessionSelection(route.data.sessionID),
|
||||
defaultModel: serverDefaultModel(),
|
||||
isValid: isModelValid,
|
||||
variantPreference: (model) => preferences.variant[modelPreferenceKey(model)],
|
||||
})
|
||||
const model = newSessionModel()
|
||||
if (!model) return
|
||||
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { StreamCommit } from "./types"
|
||||
import { commandText } from "../util/command"
|
||||
|
||||
export function commandCommit(messageID: string | undefined, command: { name: string; arguments: string }): StreamCommit {
|
||||
return {
|
||||
kind: "system",
|
||||
source: "system",
|
||||
messageID,
|
||||
partID: "command",
|
||||
text: `→ Command "${commandText(command)}"`,
|
||||
phase: "start",
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Locale } from "../util/locale"
|
||||
import { isCompactCommand, isExitCommand, isNewCommand } from "./prompt.shared"
|
||||
import type { FooterApi, FooterEvent, RunDelivery, RunPrompt } from "./types"
|
||||
import { commandCommit } from "./command.shared"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -174,16 +173,13 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
||||
}
|
||||
|
||||
if (sent.mode !== "shell") {
|
||||
const commit =
|
||||
sent.command && sent.command.source !== "skill"
|
||||
? commandCommit(sent.messageID, sent.command)
|
||||
: ({
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const)
|
||||
const commit = {
|
||||
kind: "user",
|
||||
text: sent.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: sent.messageID,
|
||||
} as const
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
resolveSessionInfo,
|
||||
} from "./runtime.boot"
|
||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||
import { commandCommit } from "./command.shared"
|
||||
import { cycleVariant, formatModelLabel, resolveVariant } from "./variant.shared"
|
||||
import type {
|
||||
LocalReplayRow,
|
||||
@@ -904,17 +903,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
state.shown = true
|
||||
state.history.push({ ...prompt, delivery: undefined })
|
||||
if (prompt.mode !== "shell" && delivery === "steer") {
|
||||
rememberLocal(
|
||||
prompt.command && prompt.command.source !== "skill"
|
||||
? commandCommit(prompt.messageID, prompt.command)
|
||||
: {
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
},
|
||||
)
|
||||
rememberLocal({
|
||||
kind: "user",
|
||||
text: prompt.text,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: prompt.messageID,
|
||||
})
|
||||
}
|
||||
},
|
||||
admit: async (prompt, delivery, signal) => {
|
||||
@@ -1049,7 +1044,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
admitted,
|
||||
)
|
||||
if (prompt.messageID) {
|
||||
state.localRows = state.localRows.filter((row) => row.commit.messageID !== prompt.messageID)
|
||||
state.localRows = state.localRows.filter(
|
||||
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||
)
|
||||
}
|
||||
// Shell and skill turns never send CLI file attachments; keep them
|
||||
// pending for the next prompt-shaped turn.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import { promptCopy, promptSame } from "./prompt.shared"
|
||||
import type { RunInput, RunPrompt } from "./types"
|
||||
import { commandText } from "../util/command"
|
||||
|
||||
const LIMIT = 200
|
||||
|
||||
@@ -23,7 +22,7 @@ export type RunSession = {
|
||||
|
||||
function messagePrompt(message: SessionMessageUser): RunPrompt {
|
||||
return {
|
||||
text: message.command ? commandText(message.command) : message.text,
|
||||
text: message.text,
|
||||
parts: [
|
||||
...(message.files ?? []).map((file) => ({
|
||||
type: "file" as const,
|
||||
|
||||
@@ -17,8 +17,6 @@ import { createFragmentReconciler, fragmentRef, type FragmentReconciler } from "
|
||||
import { createSubagentTracker, toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
import { normalizeTool, toolOutputText } from "./tool"
|
||||
import { toolDisplayContent } from "../util/tool-display"
|
||||
import { commandCommit } from "./command.shared"
|
||||
import { commandText } from "../util/command"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -188,12 +186,7 @@ function pendingPrompt(item: SessionInboxInfo): FooterQueuedPrompt | undefined {
|
||||
if (item.type !== "user") return undefined
|
||||
return {
|
||||
messageID: item.id,
|
||||
prompt: {
|
||||
messageID: item.id,
|
||||
text: item.payload.command ? commandText(item.payload.command) : item.payload.text,
|
||||
parts: [],
|
||||
command: item.payload.command,
|
||||
},
|
||||
prompt: { messageID: item.id, text: item.payload.text, parts: [] },
|
||||
delivery: item.delivery,
|
||||
}
|
||||
}
|
||||
@@ -662,22 +655,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
state.messageIDs.add(message.id)
|
||||
if (!render) return
|
||||
if (reuseVisibleWait && waiting) return
|
||||
if (message.command) {
|
||||
write([
|
||||
commandCommit(message.id, message.command),
|
||||
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
|
||||
])
|
||||
return
|
||||
}
|
||||
write([
|
||||
...(message.skills ?? []).map((skill) => skillCommit(message.id, skill.name, skill.id)),
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: message.text,
|
||||
phase: "start",
|
||||
messageID: message.id,
|
||||
},
|
||||
{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id },
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -967,19 +947,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const visible = state.messageIDs.has(event.data.inboxID)
|
||||
if (waiting || pending) state.messageIDs.add(event.data.inboxID)
|
||||
if (!waiting && pending && !visible) {
|
||||
write(
|
||||
pending.prompt.command
|
||||
? [commandCommit(event.data.inboxID, pending.prompt.command)]
|
||||
: [
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
],
|
||||
)
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
])
|
||||
}
|
||||
write([], { phase: "running", status: "waiting for assistant" })
|
||||
return
|
||||
@@ -992,19 +968,15 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
if (event.data.delivery === "queue") return
|
||||
if (state.messageIDs.has(event.data.inboxID)) return
|
||||
state.messageIDs.add(event.data.inboxID)
|
||||
write(
|
||||
pending.prompt.command
|
||||
? [commandCommit(event.data.inboxID, pending.prompt.command)]
|
||||
: [
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
],
|
||||
)
|
||||
write([
|
||||
{
|
||||
kind: "user",
|
||||
source: "system",
|
||||
text: pending.prompt.text,
|
||||
phase: "start",
|
||||
messageID: event.data.inboxID,
|
||||
},
|
||||
])
|
||||
return
|
||||
}
|
||||
if (event.type === "session.inbox.cancelled") {
|
||||
|
||||
@@ -100,7 +100,6 @@ import {
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { commandText } from "../../util/command"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { useSessionTabs } from "../../context/session-tabs"
|
||||
@@ -206,11 +205,7 @@ export function Session(props: { verticalTabsWidth: number }) {
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) =>
|
||||
item.delivery === "queue"
|
||||
? [{ id: item.id, text: item.payload.command ? commandText(item.payload.command) : item.payload.text }]
|
||||
: [],
|
||||
),
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
@@ -2183,29 +2178,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={!props.message.command}>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
</Show>
|
||||
<Show when={props.message.command}>
|
||||
{(command) => (
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<text fg={theme.text.default}>
|
||||
<span
|
||||
style={{
|
||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||
fg: theme.background.default,
|
||||
bold: true,
|
||||
}}
|
||||
>
|
||||
{" command "}
|
||||
</span>
|
||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||
{` ${commandText(command())} `}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<text fg={theme.text.default}>{props.message.text}</text>
|
||||
<Show when={skills().length}>
|
||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<For each={skills()}>
|
||||
@@ -3639,10 +3612,7 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
|
||||
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
|
||||
const body = messages.flatMap((message) => {
|
||||
if (message.type === "user")
|
||||
return [
|
||||
`## User\n\n${message.command ? commandText(message.command) : message.text}`,
|
||||
]
|
||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||
if (message.type === "shell")
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
if (message.type !== "assistant") return []
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function commandText(command: { name: string; arguments: string }) {
|
||||
return `/${command.name}${command.arguments ? ` ${command.arguments}` : ""}`
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { parseModel, recentModels } from "../../src/context/local"
|
||||
import { parseModel, recentModels, withDefaultModelFallback } from "../../src/context/local"
|
||||
|
||||
test("parses model IDs containing slashes", () => {
|
||||
expect(parseModel("provider/family/model")).toEqual({
|
||||
@@ -20,3 +20,45 @@ test("moves a model to the front, deduplicates, and limits recents", () => {
|
||||
...recent.slice(6, 10),
|
||||
])
|
||||
})
|
||||
|
||||
test("session selection wins over the default model", () => {
|
||||
const selection = { providerID: "openai", modelID: "gpt", variant: "high" }
|
||||
expect(
|
||||
withDefaultModelFallback({
|
||||
selection,
|
||||
defaultModel: { providerID: "opencode", modelID: "fable" },
|
||||
isValid: () => true,
|
||||
variantPreference: () => undefined,
|
||||
}),
|
||||
).toBe(selection)
|
||||
})
|
||||
|
||||
test("sessions without a stored model fall back to the server default", () => {
|
||||
expect(
|
||||
withDefaultModelFallback({
|
||||
selection: undefined,
|
||||
defaultModel: { providerID: "opencode", modelID: "fable" },
|
||||
isValid: () => true,
|
||||
variantPreference: (model) => (model.modelID === "fable" ? "max" : undefined),
|
||||
}),
|
||||
).toEqual({ providerID: "opencode", modelID: "fable", variant: "max" })
|
||||
})
|
||||
|
||||
test("no provider is reported only without a usable default", () => {
|
||||
expect(
|
||||
withDefaultModelFallback({
|
||||
selection: undefined,
|
||||
defaultModel: undefined,
|
||||
isValid: () => true,
|
||||
variantPreference: () => undefined,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
withDefaultModelFallback({
|
||||
selection: undefined,
|
||||
defaultModel: { providerID: "gone", modelID: "model" },
|
||||
isValid: () => false,
|
||||
variantPreference: () => undefined,
|
||||
}),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -152,6 +152,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: null,
|
||||
})
|
||||
if (url.pathname === "/api/reference")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
if (url.pathname === "/api/websearch/provider") {
|
||||
|
||||
@@ -103,16 +103,6 @@ describe("run session shared", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("uses presentation text for command history", () => {
|
||||
const out = createSession([
|
||||
userMessage("msg-user-1", "expanded command template", {
|
||||
command: { name: "command", arguments: "input" },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(out.turns[0]?.prompt.text).toBe("/command input")
|
||||
})
|
||||
|
||||
test("dedupes consecutive history entries, drops blanks, and copies prompt parts", () => {
|
||||
const parts = [
|
||||
{
|
||||
|
||||
@@ -667,10 +667,7 @@ describe("V2 mini transport", () => {
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
payload: {
|
||||
text: "expanded command template",
|
||||
command: { name: "command", arguments: "input" },
|
||||
},
|
||||
payload: { text: "follow up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
{
|
||||
@@ -710,7 +707,7 @@ describe("V2 mini transport", () => {
|
||||
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
|
||||
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "system", messageID: "msg_queued", text: '→ Command "/command input"' }),
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
expect(pending()).toEqual([["msg_cancelled", "queue"]])
|
||||
events.push({
|
||||
|
||||
Reference in New Issue
Block a user