Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a474e92433 | ||
|
|
ee5cf45ef9 | ||
|
|
2e1593dea5 | ||
|
|
0f3d168fdd | ||
|
|
ce09fc8356 | ||
|
|
44a35c5895 | ||
|
|
8a321c4536 | ||
|
|
ef9e567e5f | ||
|
|
b396b71c6f | ||
|
|
d8efc575fa | ||
|
|
e1fbed8fb6 |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -84,7 +84,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -192,7 +192,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -307,7 +307,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -337,7 +337,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -353,7 +353,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
"@smithy/util-utf8": "4.2.2",
|
||||
@@ -384,7 +384,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -522,7 +522,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -560,7 +560,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -575,7 +575,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -610,7 +610,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -659,7 +659,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-qAkjcbc1nJqOnCrNQ0bnsM4WG2ii5K1JWS9ohAYdjus=",
|
||||
"aarch64-linux": "sha256-Nb+F0e3CvQv+uLzHzj9JKp5hV78mCnlSqFXzgIvgR24=",
|
||||
"aarch64-darwin": "sha256-BIXALWWrjEZLUKZrY6l6+scjZmKFscFxX26TvWOvXGQ=",
|
||||
"x86_64-darwin": "sha256-3uaFXl/n6je7AzIfsY1pvt3Ln/U1Oshx3z7ohuVPEs8="
|
||||
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
|
||||
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
|
||||
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
|
||||
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
const words = [
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
"echo",
|
||||
"foxtrot",
|
||||
"golf",
|
||||
"hotel",
|
||||
"india",
|
||||
"juliet",
|
||||
"kilo",
|
||||
"lima",
|
||||
"metro",
|
||||
"nova",
|
||||
"orbit",
|
||||
"pixel",
|
||||
"quartz",
|
||||
"river",
|
||||
"signal",
|
||||
"vector",
|
||||
]
|
||||
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
let i = seed
|
||||
while (out.length < length) {
|
||||
const word = words[i % words.length]
|
||||
out += (out ? " " : "") + word
|
||||
if (i % 17 === 0) out += ".\n\n"
|
||||
i += 7
|
||||
}
|
||||
return out.slice(0, length)
|
||||
}
|
||||
|
||||
function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function textPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
|
||||
}
|
||||
|
||||
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
|
||||
return {
|
||||
id: id(`prt_reasoning_${partIndex}`, index),
|
||||
type: "reasoning",
|
||||
text: lorem(index * 19 + partIndex, length),
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
index: number,
|
||||
partIndex: number,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
outputLength = 160,
|
||||
): MessagePart {
|
||||
const metadata =
|
||||
tool === "apply_patch"
|
||||
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
|
||||
: tool === "edit" || tool === "write"
|
||||
? {
|
||||
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
|
||||
diff: patch(index, outputLength),
|
||||
preview: patch(index + 1, 420),
|
||||
}
|
||||
: tool === "question"
|
||||
? { answers: [["Proceed"], ["Keep sample output"]] }
|
||||
: {}
|
||||
return {
|
||||
id: id(`prt_tool_${tool}_${partIndex}`, index),
|
||||
type: "tool",
|
||||
callID: id("call", index * 10 + partIndex),
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
output: lorem(index * 23 + partIndex, outputLength),
|
||||
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
|
||||
metadata,
|
||||
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function patchFile(seed: number, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath: `src/generated/patch-${seed}.ts`,
|
||||
relativePath: `src/generated/patch-${seed}.ts`,
|
||||
type,
|
||||
additions: (seed % 7) + 1,
|
||||
deletions: type === "add" ? 0 : seed % 4,
|
||||
patch: patch(seed, 520),
|
||||
before: type === "add" ? undefined : code(seed, 18),
|
||||
after: type === "delete" ? undefined : code(seed + 1, 24),
|
||||
}
|
||||
}
|
||||
|
||||
function fileDiff(file: string, seed: number) {
|
||||
return {
|
||||
file,
|
||||
additions: (seed % 9) + 1,
|
||||
deletions: seed % 4,
|
||||
before: code(seed, 32),
|
||||
after: code(seed + 1, 38),
|
||||
}
|
||||
}
|
||||
|
||||
function patch(seed: number, length: number) {
|
||||
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
|
||||
}
|
||||
|
||||
function code(seed: number, lines: number) {
|
||||
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
|
||||
"\n",
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
|
||||
...(index % 3 === 0
|
||||
? [
|
||||
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
|
||||
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
|
||||
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
|
||||
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
|
||||
]
|
||||
: []),
|
||||
textPart(index, 2, 160 + (index % 6) * 90),
|
||||
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
|
||||
...(index % 6 === 0
|
||||
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
|
||||
: []),
|
||||
...(index % 8 === 0
|
||||
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
|
||||
: []),
|
||||
...(index % 7 === 0
|
||||
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
|
||||
: []),
|
||||
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
|
||||
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
|
||||
...(index % 13 === 0
|
||||
? [
|
||||
toolPart(
|
||||
index,
|
||||
11,
|
||||
"question",
|
||||
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
|
||||
120,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(index % 17 === 0
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text.trim()
|
||||
if (part.type === "reasoning") return !!part.text.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function orderedParts(message: Message) {
|
||||
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "smoke-project",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sourceID,
|
||||
slug: "source",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Uncommitted changes inquiry",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: targetID,
|
||||
slug: "target",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Example Game: sample jump movement & sample physics analysis",
|
||||
version: "dev",
|
||||
time: { created: 1700000001000, updated: 1700000001000 },
|
||||
},
|
||||
],
|
||||
sourceID,
|
||||
targetID,
|
||||
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) =>
|
||||
orderedParts(message)
|
||||
.filter(renderable)
|
||||
.map((part) => part.id),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
export function pageMessages(sessionID: string, limit: number, before?: string) {
|
||||
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "./session-timeline.fixture"
|
||||
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const forbiddenText = ["Load details", "Show earlier steps"]
|
||||
|
||||
type SmokeState = {
|
||||
ids: string[]
|
||||
visibleIds: string[]
|
||||
messageIds: string[]
|
||||
visibleMessageIds: string[]
|
||||
topVisibleId?: string
|
||||
signature: string
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
errorToasts: string[]
|
||||
forbiddenText: string[]
|
||||
}
|
||||
|
||||
type SmokeWindow = Window & {
|
||||
__timelineSmokeState?: () => SmokeState
|
||||
__timelineSmokeErrorToasts?: string[]
|
||||
__timelineSmokeForbiddenText?: string[]
|
||||
}
|
||||
|
||||
test.describe("smoke: session timeline", () => {
|
||||
test.setTimeout(240_000)
|
||||
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
})
|
||||
await configureSmokePage(page)
|
||||
|
||||
await openProject(page, "SmokeProject")
|
||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page, "smoke-project")
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
})
|
||||
})
|
||||
|
||||
async function configureSmokePage(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
showSessionProgressBar: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const smoke = window as SmokeWindow
|
||||
smoke.__timelineSmokeErrorToasts = []
|
||||
smoke.__timelineSmokeForbiddenText = []
|
||||
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
|
||||
const idsOf = (el: HTMLElement) =>
|
||||
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
|
||||
|
||||
smoke.__timelineSmokeState = () => {
|
||||
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||
)
|
||||
if (!scroller) {
|
||||
return {
|
||||
ids: [],
|
||||
visibleIds: [],
|
||||
messageIds: [],
|
||||
visibleMessageIds: [],
|
||||
topVisibleId: undefined,
|
||||
signature: "",
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const ids: string[] = []
|
||||
const visibleIds: string[] = []
|
||||
const scrollerRect = scroller.getBoundingClientRect()
|
||||
let topVisibleId: string | undefined
|
||||
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
|
||||
const next = idsOf(el)
|
||||
ids.push(...next)
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
|
||||
if (!topVisibleId) topVisibleId = next[0]
|
||||
visibleIds.push(...next)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIds: string[] = []
|
||||
const visibleMessageIds: string[] = []
|
||||
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const id = el.dataset.messageId
|
||||
if (id) {
|
||||
messageIds.push(id)
|
||||
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
|
||||
}
|
||||
return {
|
||||
id,
|
||||
top: Math.round(rect.top),
|
||||
bottom: Math.round(rect.bottom),
|
||||
}
|
||||
})
|
||||
const signature = JSON.stringify({
|
||||
top: Math.round(scroller.scrollTop),
|
||||
height: Math.round(scroller.scrollHeight),
|
||||
rows,
|
||||
ids,
|
||||
})
|
||||
|
||||
return {
|
||||
ids,
|
||||
visibleIds,
|
||||
messageIds,
|
||||
visibleMessageIds,
|
||||
topVisibleId,
|
||||
signature,
|
||||
scrollTop: Math.round(scroller.scrollTop),
|
||||
scrollHeight: Math.round(scroller.scrollHeight),
|
||||
clientHeight: Math.round(scroller.clientHeight),
|
||||
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
|
||||
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
|
||||
}
|
||||
}
|
||||
let recordFrame: number | undefined
|
||||
const record = () => {
|
||||
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
|
||||
const text = toast.textContent?.trim()
|
||||
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
|
||||
}
|
||||
const text = document.body?.textContent ?? ""
|
||||
for (const value of ["Load details", "Show earlier steps"]) {
|
||||
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
|
||||
smoke.__timelineSmokeForbiddenText!.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = () => {
|
||||
const root = document.documentElement ?? document.body
|
||||
if (!root) return
|
||||
new MutationObserver(() => {
|
||||
if (recordFrame) return
|
||||
recordFrame = requestAnimationFrame(() => {
|
||||
recordFrame = undefined
|
||||
record()
|
||||
})
|
||||
}).observe(root, { childList: true, subtree: true })
|
||||
record()
|
||||
}
|
||||
if (document.documentElement ?? document.body) start()
|
||||
else document.addEventListener("DOMContentLoaded", start, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function expectCanScrollToStart(
|
||||
page: Page,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await pointAtTimeline(page)
|
||||
const seenParts = new Set<string>()
|
||||
const seenMessages = new Set<string>()
|
||||
const samples: TraversalSample[] = []
|
||||
let current = await timelineState(page)
|
||||
let unchangedAtTop = 0
|
||||
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
collectSeen(current, seenParts, seenMessages)
|
||||
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
|
||||
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
|
||||
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
|
||||
|
||||
if (
|
||||
current.scrollTop <= 1 &&
|
||||
seenParts.size === expectedPartIDs.length &&
|
||||
seenMessages.size === expectedMessageIDs.length
|
||||
) {
|
||||
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||
return
|
||||
}
|
||||
|
||||
const before = current
|
||||
const changed = await scrollTimelineUp(page, current)
|
||||
current = await timelineState(page)
|
||||
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
|
||||
else unchangedAtTop = 0
|
||||
if (unchangedAtTop >= 2) break
|
||||
}
|
||||
|
||||
collectSeen(current, seenParts, seenMessages)
|
||||
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
|
||||
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
|
||||
}
|
||||
|
||||
async function timelineState(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as SmokeWindow).__timelineSmokeState?.() ?? {
|
||||
ids: [],
|
||||
visibleIds: [],
|
||||
messageIds: [],
|
||||
visibleMessageIds: [],
|
||||
topVisibleId: undefined,
|
||||
signature: "",
|
||||
scrollTop: 0,
|
||||
scrollHeight: 0,
|
||||
clientHeight: 0,
|
||||
errorToasts: [],
|
||||
forbiddenText: [],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function timelineScroller(page: Page) {
|
||||
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
}
|
||||
|
||||
async function pointAtTimeline(page: Page) {
|
||||
const box = await timelineScroller(page).boundingBox()
|
||||
if (!box) throw new Error("Timeline scroller is not visible")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
}
|
||||
|
||||
async function scrollTimelineUp(page: Page, before: SmokeState) {
|
||||
return page.evaluate(
|
||||
(prev) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
|
||||
el.querySelector("[data-timeline-row], [data-session-title]"),
|
||||
)
|
||||
if (!scroller) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
|
||||
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
|
||||
|
||||
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
let frames = 0
|
||||
let stableFrames = 0
|
||||
let last = ""
|
||||
let changed = false
|
||||
const check = () => {
|
||||
const current = read()
|
||||
if (current !== prev) changed = true
|
||||
if (current === last) stableFrames++
|
||||
else {
|
||||
stableFrames = 0
|
||||
last = current
|
||||
}
|
||||
if (changed && stableFrames >= 2) {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
frames++
|
||||
if (frames >= 30) {
|
||||
resolve(changed)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
}
|
||||
requestAnimationFrame(check)
|
||||
}),
|
||||
before.signature,
|
||||
)
|
||||
}
|
||||
|
||||
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
|
||||
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
|
||||
const actualSet = new Set(actual)
|
||||
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
|
||||
}
|
||||
|
||||
function unique(values: string[]) {
|
||||
return values.filter((value, index) => values.indexOf(value) === index)
|
||||
}
|
||||
|
||||
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
|
||||
for (const id of state.ids) seenParts.add(id)
|
||||
for (const id of state.visibleIds) seenParts.add(id)
|
||||
for (const id of state.messageIds) seenMessages.add(id)
|
||||
for (const id of state.visibleMessageIds) seenMessages.add(id)
|
||||
}
|
||||
|
||||
type TraversalSample = ReturnType<typeof sampleTraversal>
|
||||
|
||||
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
|
||||
return {
|
||||
seenParts,
|
||||
seenMessages,
|
||||
mounted: state.ids.length,
|
||||
visible: state.visibleIds.length,
|
||||
mountedMessages: unique(state.messageIds).length,
|
||||
visibleMessages: unique(state.visibleMessageIds).length,
|
||||
top: state.scrollTop,
|
||||
height: state.scrollHeight,
|
||||
first: state.ids[0],
|
||||
last: state.ids.at(-1),
|
||||
topVisible: state.topVisibleId,
|
||||
visibleFirst: state.visibleIds[0],
|
||||
visibleLast: state.visibleIds.at(-1),
|
||||
}
|
||||
}
|
||||
|
||||
function sampleSummary(samples: TraversalSample[]) {
|
||||
return samples
|
||||
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
|
||||
.map(
|
||||
(sample, index) =>
|
||||
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
async function waitForTimelineStable(page: Page) {
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
requestAnimationFrame(() => {
|
||||
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
|
||||
requestAnimationFrame(() =>
|
||||
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
|
||||
)
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function expectSessionTimelineReady(
|
||||
page: Page,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
await waitForTimelineStable(page)
|
||||
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
|
||||
const currentState = await timelineState(page)
|
||||
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
|
||||
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
|
||||
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
|
||||
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
|
||||
}
|
||||
|
||||
function expectCompleteScroll(
|
||||
state: SmokeState,
|
||||
expectedPartIDs: string[],
|
||||
expectedMessageIDs: string[],
|
||||
seenParts: Set<string>,
|
||||
seenMessages: Set<string>,
|
||||
samples: TraversalSample[],
|
||||
) {
|
||||
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
|
||||
expect(
|
||||
expectedPartIDs.filter((id) => !seenParts.has(id)),
|
||||
`missing visible timeline parts\n${sampleSummary(samples)}`,
|
||||
).toEqual([])
|
||||
expect(
|
||||
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
|
||||
`missing visible messages\n${sampleSummary(samples)}`,
|
||||
).toEqual([])
|
||||
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
|
||||
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
|
||||
expect(expectedPartIDs.length).toBe(331)
|
||||
}
|
||||
|
||||
async function openProject(page: Page, projectName: string) {
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
|
||||
}
|
||||
|
||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
|
||||
await page
|
||||
.locator(`a[href*="${sessionId}"]`)
|
||||
.first()
|
||||
.evaluate((el) => (el as HTMLElement).click())
|
||||
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectSessionReady(page: Page, projectName: string) {
|
||||
await expect(page.getByText(projectName).first()).toBeVisible()
|
||||
await expect(page.getByText("Ask anything...")).toBeVisible()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { test } from "@playwright/test"
|
||||
|
||||
test(
|
||||
"test something cool",
|
||||
{
|
||||
annotation: { type: "todo" },
|
||||
},
|
||||
async () => {
|
||||
test.fixme()
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
|
||||
export function trackPageErrors(page: Page) {
|
||||
const errors: string[] = []
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") errors.push(message.text())
|
||||
})
|
||||
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||
return errors
|
||||
}
|
||||
|
||||
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
|
||||
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
|
||||
consoleErrors: [],
|
||||
toastErrors: [],
|
||||
forbiddenText: [],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set([
|
||||
"/skill",
|
||||
"/command",
|
||||
"/lsp",
|
||||
"/formatter",
|
||||
"/permission",
|
||||
"/question",
|
||||
"/vcs/status",
|
||||
"/vcs/diff",
|
||||
])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/provider": config.provider,
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
},
|
||||
"/project": [config.project],
|
||||
"/project/current": config.project,
|
||||
"/agent": [{ name: "build", mode: "primary" }],
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
if (url.port !== targetPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) {
|
||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const before = url.searchParams.get("before") ?? undefined
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
|
||||
}
|
||||
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, headers?: Record<string, string>) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-expose-headers": "x-next-cursor",
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(body ?? null),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.15.4"
|
||||
version = "1.15.5"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.4/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -42,12 +42,7 @@ export function status(input: Pick<StreamInput, "model" | "provider" | "auth">):
|
||||
if (input.model.api.npm !== "@ai-sdk/openai") return { type: "unsupported", reason: "provider package is not OpenAI" }
|
||||
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
|
||||
|
||||
const apiKey =
|
||||
input.auth?.type === "api"
|
||||
? input.auth.key
|
||||
: typeof input.provider.options.apiKey === "string"
|
||||
? input.provider.options.apiKey
|
||||
: undefined
|
||||
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
|
||||
if (!apiKey) return { type: "unsupported", reason: "OpenAI API key is not configured" }
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,17 +8,15 @@ import * as Session from "./session"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Instruction } from "./instruction"
|
||||
import { Plugin } from "../plugin"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { ulid } from "ulid"
|
||||
@@ -48,7 +46,6 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
@@ -63,6 +60,7 @@ import * as Database from "@/storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
||||
import { SessionReminders } from "./reminders"
|
||||
import { SessionTools } from "./tools"
|
||||
import { LLMEvent } from "@opencode-ai/llm"
|
||||
|
||||
// @ts-ignore
|
||||
@@ -126,9 +124,6 @@ export const layer = Layer.effect(
|
||||
const references = yield* Reference.Service
|
||||
const events = yield* EventV2Bridge.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
||||
return yield* EffectBridge.make()
|
||||
})
|
||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||
return {
|
||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||
@@ -301,186 +296,6 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
|
||||
})
|
||||
|
||||
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
|
||||
agent: Agent.Info
|
||||
model: Provider.Model
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
const run = yield* runner()
|
||||
const promptOps = yield* ops()
|
||||
|
||||
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||
if (!["running", "pending"].includes(match.state.status)) return match
|
||||
return {
|
||||
...match,
|
||||
state: {
|
||||
title: val.title,
|
||||
metadata: val.metadata,
|
||||
status: "running",
|
||||
input: args,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
}
|
||||
}),
|
||||
ask: (req) =>
|
||||
permission
|
||||
.ask({
|
||||
...req,
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
execute(args, options) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, options)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ args },
|
||||
)
|
||||
const result = yield* item.execute(args, ctx)
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||
output,
|
||||
)
|
||||
if (options.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||
const transformed = ProviderTransform.schema(input.model, schema)
|
||||
item.inputSchema = jsonSchema(transformed)
|
||||
item.execute = (args, opts) =>
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* Effect.promise(() => execute(args, opts))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": key,
|
||||
"tool.call_id": opts.toolCallId,
|
||||
"session.id": ctx.sessionID,
|
||||
"message.id": input.processor.message.id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
result,
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") textParts.push(contentItem.text)
|
||||
else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
})
|
||||
} else if (contentItem.type === "resource") {
|
||||
const { resource } = contentItem
|
||||
if (resource.text) textParts.push(resource.text)
|
||||
if (resource.blob) {
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
filename: resource.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||
const metadata = {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
const output = {
|
||||
title: "",
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
attachments: attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
content: result.content,
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
return tools
|
||||
})
|
||||
|
||||
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
|
||||
task: MessageV2.SubtaskPart
|
||||
model: Provider.Model
|
||||
@@ -1552,16 +1367,23 @@ export const layer = Layer.effect(
|
||||
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
const promptOps = yield* ops()
|
||||
|
||||
const tools = yield* resolveTools({
|
||||
const tools = yield* SessionTools.resolve({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor: handle,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
promptOps,
|
||||
}).pipe(
|
||||
Effect.provideService(Plugin.Service, plugin),
|
||||
Effect.provideService(Permission.Service, permission),
|
||||
Effect.provideService(ToolRegistry.Service, registry),
|
||||
Effect.provideService(MCP.Service, mcp),
|
||||
Effect.provideService(Truncate.Service, truncate),
|
||||
)
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ModelID } from "@/provider/schema"
|
||||
import { Plugin } from "@/plugin"
|
||||
import type { TaskPromptOps } from "@/tool/task"
|
||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Session from "./session"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { PartID } from "./schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
|
||||
const log = Log.create({ service: "session.tools" })
|
||||
|
||||
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
agent: Agent.Info
|
||||
model: Provider.Model
|
||||
session: Session.Info
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
promptOps: TaskPromptOps
|
||||
}) {
|
||||
using _ = log.time("resolveTools")
|
||||
const tools: Record<string, AITool> = {}
|
||||
const run = yield* EffectBridge.make()
|
||||
const plugin = yield* Plugin.Service
|
||||
const permission = yield* Permission.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
|
||||
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||
sessionID: input.session.id,
|
||||
abort: options.abortSignal!,
|
||||
messageID: input.processor.message.id,
|
||||
callID: options.toolCallId,
|
||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
|
||||
agent: input.agent.name,
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||
if (!["running", "pending"].includes(match.state.status)) return match
|
||||
return {
|
||||
...match,
|
||||
state: {
|
||||
title: val.title,
|
||||
metadata: val.metadata,
|
||||
status: "running",
|
||||
input: args,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
}
|
||||
}),
|
||||
ask: (req) =>
|
||||
permission
|
||||
.ask({
|
||||
...req,
|
||||
sessionID: input.session.id,
|
||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
execute(args, options) {
|
||||
return run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, options)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||
{ args },
|
||||
)
|
||||
const result = yield* item.execute(args, ctx)
|
||||
const output = {
|
||||
...result,
|
||||
attachments: result.attachments?.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
}
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||
output,
|
||||
)
|
||||
if (options.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||
const transformed = ProviderTransform.schema(input.model, schema)
|
||||
item.inputSchema = jsonSchema(transformed)
|
||||
item.execute = (args, opts) =>
|
||||
run.promise(
|
||||
Effect.gen(function* () {
|
||||
const ctx = context(args, opts)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||
{ args },
|
||||
)
|
||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
return yield* Effect.promise(() => execute(args, opts))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
"tool.name": key,
|
||||
"tool.call_id": opts.toolCallId,
|
||||
"session.id": ctx.sessionID,
|
||||
"message.id": input.processor.message.id,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||
result,
|
||||
)
|
||||
|
||||
const textParts: string[] = []
|
||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||
for (const contentItem of result.content) {
|
||||
if (contentItem.type === "text") textParts.push(contentItem.text)
|
||||
else if (contentItem.type === "image") {
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime: contentItem.mimeType,
|
||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||
})
|
||||
} else if (contentItem.type === "resource") {
|
||||
const { resource } = contentItem
|
||||
if (resource.text) textParts.push(resource.text)
|
||||
if (resource.blob) {
|
||||
attachments.push({
|
||||
type: "file",
|
||||
mime: resource.mimeType ?? "application/octet-stream",
|
||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||
filename: resource.uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||
const metadata = {
|
||||
...result.metadata,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
const output = {
|
||||
title: "",
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
attachments: attachments.map((attachment) => ({
|
||||
...attachment,
|
||||
id: PartID.ascending(),
|
||||
sessionID: ctx.sessionID,
|
||||
messageID: input.processor.message.id,
|
||||
})),
|
||||
content: result.content,
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
tools[key] = item
|
||||
}
|
||||
|
||||
return tools
|
||||
})
|
||||
|
||||
export * as SessionTools from "./tools"
|
||||
@@ -0,0 +1,84 @@
|
||||
// Subprocess integration tests for `opencode run` (non-interactive mode).
|
||||
// These exercise the real CLI binary against a TestLLMServer running in the
|
||||
// same process. See `test/lib/run-process.ts` for the harness — each test uses
|
||||
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
|
||||
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { runIt } from "../../lib/run-process"
|
||||
|
||||
describe("opencode run (non-interactive subprocess)", () => {
|
||||
// Happy path: prompt completes, output reaches stdout, process exits 0.
|
||||
// If this fails, all the others likely will too — debug here first.
|
||||
runIt.live(
|
||||
"exits 0 and writes the response to stdout on a successful prompt",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("hello from the test llm")
|
||||
const result = yield* opencode.run("say hi")
|
||||
opencode.expectExit(result, 0)
|
||||
expect(result.stdout).toContain("hello from the test llm")
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
// Regression for #27371: an unknown model used to hang the process forever
|
||||
// waiting on a session.status === idle event that never arrived. The fix
|
||||
// makes the SDK call surface an error promptly so the process exits nonzero.
|
||||
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
|
||||
// would expire the timeout and produce a different (signal-killed) failure.
|
||||
runIt.live(
|
||||
"exits nonzero promptly when the model is unknown (regression for #27371)",
|
||||
({ opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* opencode.run("say hi", {
|
||||
model: "test/nonexistent-model",
|
||||
timeoutMs: 15_000,
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.durationMs).toBeLessThan(15_000)
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
|
||||
// Locks in the current behavior: when the LLM stream errors mid-response
|
||||
// (the prompt was accepted, then the upstream provider failed), opencode
|
||||
// emits a session.error event and the process exits 0 today.
|
||||
//
|
||||
// This is debatable — a future cleanup might flip it to exit 1. If you're
|
||||
// changing this expectation, do it deliberately and say so in the PR.
|
||||
runIt.live(
|
||||
"mid-stream LLM error still exits 0 today (contract lock-in)",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.fail("upstream provider exploded mid-stream")
|
||||
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
// --format json puts one JSON object per line on stdout for each emitted
|
||||
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
|
||||
// shape so a future event-emit change has to update this expectation.
|
||||
runIt.live(
|
||||
"--format json emits parseable line-delimited JSON to stdout",
|
||||
({ llm, opencode }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* llm.text("structured output")
|
||||
const result = yield* opencode.run("say hi", { format: "json" })
|
||||
opencode.expectExit(result, 0)
|
||||
|
||||
const events = opencode.parseJsonEvents(result.stdout)
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
for (const evt of events) {
|
||||
expect(typeof evt.type).toBe("string")
|
||||
expect(typeof evt.sessionID).toBe("string")
|
||||
}
|
||||
// At least one `text` event should appear with the LLM's response.
|
||||
const text = events.find((e) => e.type === "text")
|
||||
expect(text).toBeDefined()
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Effect, Exit, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigManaged } from "@/config/managed"
|
||||
@@ -13,7 +13,7 @@ import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
|
||||
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { InstanceRuntime } from "@/project/instance-runtime"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -94,14 +94,6 @@ const listDirs = (ctx: InstanceContext) =>
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
const ready = (ctx: InstanceContext) =>
|
||||
Effect.runPromise(
|
||||
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(layer),
|
||||
),
|
||||
)
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
@@ -123,6 +115,25 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
|
||||
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
|
||||
}
|
||||
|
||||
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
|
||||
Effect.promise(() => writeConfig(dir, config, name))
|
||||
|
||||
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const original = process.env[key]
|
||||
process.env[key] = value
|
||||
return original
|
||||
}),
|
||||
() => effect,
|
||||
(original) =>
|
||||
Effect.sync(() => {
|
||||
if (original !== undefined) process.env[key] = original
|
||||
else delete process.env[key]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function check(map: (dir: string) => string) {
|
||||
if (process.platform !== "win32") return
|
||||
await using globalTmp = await tmpdir()
|
||||
@@ -210,43 +221,24 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
|
||||
}
|
||||
})
|
||||
|
||||
test("loads JSON config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "test/model",
|
||||
username: "testuser",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"loads JSON config file",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
}),
|
||||
{ config: { model: "test/model", username: "testuser" } },
|
||||
)
|
||||
|
||||
test("loads shell config field", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.shell).toBe("bash")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"loads shell config field",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.shell).toBe("bash")
|
||||
}),
|
||||
{ config: { shell: "bash" } },
|
||||
)
|
||||
|
||||
test("updates config and preserves empty shell sentinel", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -330,41 +322,23 @@ test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads formatter boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
formatter: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.formatter).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"loads formatter boolean config",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.formatter).toBe(true)
|
||||
}),
|
||||
{ config: { formatter: true } },
|
||||
)
|
||||
|
||||
test("loads lsp boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
lsp: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.lsp).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"loads lsp boolean config",
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.lsp).toBe(true)
|
||||
}),
|
||||
{ config: { lsp: true } },
|
||||
)
|
||||
|
||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
@@ -405,124 +379,118 @@ test("ignores legacy tui keys in opencode config", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads JSONC config file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.jsonc"),
|
||||
it.instance("loads JSONC config file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(
|
||||
path.join(test.directory, "opencode.jsonc"),
|
||||
`{
|
||||
// This is a comment
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"model": "test/model",
|
||||
"username": "testuser"
|
||||
}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
},
|
||||
})
|
||||
})
|
||||
),
|
||||
)
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("test/model")
|
||||
expect(config.username).toBe("testuser")
|
||||
}),
|
||||
)
|
||||
|
||||
test("jsonc overrides json in the same directory", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(
|
||||
dir,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "base",
|
||||
username: "base",
|
||||
},
|
||||
"opencode.jsonc",
|
||||
)
|
||||
await writeConfig(dir, {
|
||||
it.instance("jsonc overrides json in the same directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(
|
||||
test.directory,
|
||||
{
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "override",
|
||||
model: "base",
|
||||
username: "base",
|
||||
},
|
||||
"opencode.jsonc",
|
||||
)
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
model: "override",
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles environment variable substitution", () =>
|
||||
withProcessEnv(
|
||||
"TEST_VAR",
|
||||
"test-user",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{env:TEST_VAR}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.model).toBe("base")
|
||||
expect(config.username).toBe("base")
|
||||
},
|
||||
})
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("handles environment variable substitution", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
process.env["TEST_VAR"] = "test-user"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{env:TEST_VAR}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["TEST_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["TEST_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves env variables when adding $schema to config", async () => {
|
||||
const originalEnv = process.env["PRESERVE_VAR"]
|
||||
process.env["PRESERVE_VAR"] = "secret_value"
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Config without $schema - should trigger auto-add
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
it.instance("preserves env variables when adding $schema to config", () =>
|
||||
withProcessEnv(
|
||||
"PRESERVE_VAR",
|
||||
"secret_value",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// Config without $schema - should trigger auto-add
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(
|
||||
path.join(test.directory, "opencode.json"),
|
||||
JSON.stringify({
|
||||
username: "{env:PRESERVE_VAR}",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("secret_value")
|
||||
),
|
||||
)
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("secret_value")
|
||||
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
},
|
||||
// Read the file to verify the env variable was preserved
|
||||
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
|
||||
expect(content).toContain("{env:PRESERVE_VAR}")
|
||||
expect(content).not.toContain("secret_value")
|
||||
expect(content).toContain("$schema")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("handles file inclusion substitution", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.txt}",
|
||||
})
|
||||
} finally {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env["PRESERVE_VAR"] = originalEnv
|
||||
} else {
|
||||
delete process.env["PRESERVE_VAR"]
|
||||
}
|
||||
}
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("test-user")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("handles file inclusion with replacement tokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
|
||||
)
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.md}",
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
}),
|
||||
)
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
@@ -589,105 +557,50 @@ test("resolves env templates in account config with account token", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("handles file inclusion substitution", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.txt}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("test-user")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("validates config schema and throws on invalid fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("handles file inclusion with replacement tokens", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
username: "{file:included.md}",
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.username).toBe("const out = await Bun.$`echo hi`")
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("throws error for invalid JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
|
||||
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("validates config schema and throws on invalid fields", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
invalid_field: "should cause error",
|
||||
})
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
// Strict schema should throw an error for invalid fields
|
||||
await expect(load(ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
|
||||
},
|
||||
})
|
||||
await provideTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
await expect(load(ctx)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("handles agent configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const config = await load(ctx)
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
it.instance("handles agent configuration", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeConfigEffect(test.directory, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
test_agent: {
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
const config = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(config.agent?.["test_agent"]).toEqual(
|
||||
expect.objectContaining({
|
||||
model: "test/model",
|
||||
temperature: 0.7,
|
||||
description: "test agent",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("treats agent variant as model-scoped setting (not provider option)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Subprocess test harness for the `opencode run` CLI.
|
||||
//
|
||||
// This is the missing test tier: every other `cli/run/*.test.ts` is a unit
|
||||
// test of an extracted helper. Nothing actually exercises the `RunCommand`
|
||||
// handler end-to-end. Bugs that span argv parsing → server boot → SDK call →
|
||||
// event consumption → exit code (like the original /event race or the
|
||||
// non-interactive hang #27371) are invisible to in-process tests.
|
||||
//
|
||||
// The harness uses opencode's built-in test affordances to spawn the real CLI
|
||||
// hermetically:
|
||||
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
|
||||
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
|
||||
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
|
||||
// - OPENCODE_PURE : skip external plugin discovery + install
|
||||
// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
|
||||
//
|
||||
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
|
||||
//
|
||||
// The custom `test` provider points at a TestLLMServer running in the same
|
||||
// process at a random port. The CLI subprocess talks to it over real HTTP.
|
||||
import type { TestOptions } from "bun:test"
|
||||
import * as Scope from "effect/Scope"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import { Process } from "@/util/process"
|
||||
import { TestLLMServer } from "./llm-server"
|
||||
import { testProviderConfig } from "./test-provider"
|
||||
import { it } from "./effect"
|
||||
|
||||
const opencodeRoot = path.resolve(import.meta.dir, "../../")
|
||||
const cliEntry = path.join(opencodeRoot, "src/index.ts")
|
||||
|
||||
export const testModelID = "test/test-model"
|
||||
|
||||
function isolatedEnv(home: string, configJson: string): Record<string, string> {
|
||||
return {
|
||||
OPENCODE_TEST_HOME: home,
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: path.join(home, ".config"),
|
||||
XDG_DATA_HOME: path.join(home, ".local/share"),
|
||||
XDG_STATE_HOME: path.join(home, ".local/state"),
|
||||
XDG_CACHE_HOME: path.join(home, ".cache"),
|
||||
OPENCODE_CONFIG_CONTENT: configJson,
|
||||
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
|
||||
OPENCODE_PURE: "1",
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
OPENCODE_DISABLE_AUTOCOMPACT: "1",
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "1",
|
||||
OPENCODE_AUTH_CONTENT: "{}",
|
||||
}
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
readonly exitCode: number
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
readonly durationMs: number
|
||||
}
|
||||
|
||||
type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
|
||||
|
||||
// A `RunOpts` is the typed equivalent of constructing argv for `opencode run`.
|
||||
// New flags should land here so tests stay grep-able and refactor-safe.
|
||||
export type RunOpts = SpawnOpts & {
|
||||
readonly model?: string
|
||||
readonly agent?: string
|
||||
readonly format?: "default" | "json"
|
||||
readonly command?: string
|
||||
readonly printLogs?: boolean
|
||||
readonly extraArgs?: string[]
|
||||
}
|
||||
|
||||
export type OpencodeCli = {
|
||||
// High-level: run a single prompt against the test model.
|
||||
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
|
||||
// Escape hatch: any CLI invocation with full control over argv.
|
||||
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
|
||||
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
|
||||
// failures are debuggable without re-running locally.
|
||||
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
|
||||
// Parse `--format json` stdout into one event object per non-empty line.
|
||||
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
|
||||
// event (see src/cli/cmd/run.ts `emit`). Throws if any line is malformed
|
||||
// so tests fail loudly rather than silently skipping data.
|
||||
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export type RunFixture = {
|
||||
readonly llm: TestLLMServer["Service"]
|
||||
readonly home: string
|
||||
readonly opencode: OpencodeCli
|
||||
}
|
||||
|
||||
// `withRunFixture(fn)` provisions a TestLLMServer + tmpdir + spawn helper and
|
||||
// invokes fn. Cleans up the tmpdir on scope exit.
|
||||
//
|
||||
// Note on the R channel: TestLLMServer.layer is provided internally so the
|
||||
// caller doesn't need to wire it up. The fixture's lifetime is tied to the
|
||||
// surrounding Scope.
|
||||
export function withRunFixture<A, E>(
|
||||
fn: (input: RunFixture) => Effect.Effect<A, E>,
|
||||
): Effect.Effect<A, E | unknown, Scope.Scope> {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
|
||||
const home = path.join(os.tmpdir(), "oc-run-" + Math.random().toString(36).slice(2))
|
||||
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
|
||||
)
|
||||
|
||||
const configJson = JSON.stringify(testProviderConfig(llm.url))
|
||||
const env = isolatedEnv(home, configJson)
|
||||
|
||||
const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
|
||||
Effect.promise(async () => {
|
||||
const start = Date.now()
|
||||
// Process.run pipes stdout/stderr by default and returns them as Buffers.
|
||||
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
|
||||
cwd: home,
|
||||
timeout: opts?.timeoutMs ?? 30_000,
|
||||
env: { ...process.env, ...env, ...opts?.env },
|
||||
nothrow: true,
|
||||
})
|
||||
return {
|
||||
exitCode: result.code,
|
||||
stdout: result.stdout.toString(),
|
||||
stderr: result.stderr.toString(),
|
||||
durationMs: Date.now() - start,
|
||||
}
|
||||
})
|
||||
|
||||
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
|
||||
const argv: string[] = ["run"]
|
||||
if (opts?.printLogs) argv.push("--print-logs")
|
||||
argv.push("--model", opts?.model ?? testModelID)
|
||||
if (opts?.agent) argv.push("--agent", opts.agent)
|
||||
if (opts?.format) argv.push("--format", opts.format)
|
||||
if (opts?.command) argv.push("--command", opts.command)
|
||||
if (opts?.extraArgs) argv.push(...opts.extraArgs)
|
||||
argv.push(message)
|
||||
return spawn(argv, opts)
|
||||
}
|
||||
|
||||
const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
|
||||
|
||||
return yield* fn({ llm, home, opencode })
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
// Convenience for the common assertion pattern. Dumps stderr/stdout when
|
||||
// the exit code doesn't match — saves debugging time on CI failures.
|
||||
function expectExit(result: RunResult, expected: number, label = "opencode") {
|
||||
if (result.exitCode === expected) return
|
||||
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
|
||||
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
|
||||
}
|
||||
|
||||
// `runIt.live(name, fixture => effect)` is the same as
|
||||
// `it.live(name, () => withRunFixture(fixture))` — one fewer nesting level at
|
||||
// every call site. Use this for any test that needs the opencode CLI fixture.
|
||||
//
|
||||
// Only `.live` is exposed because subprocess tests must run against the real
|
||||
// clock — a TestClock-paused environment can't drive a child process. If you
|
||||
// need `.only` or `.skip`, fall back to `it.live` + `withRunFixture` directly.
|
||||
export const runIt = {
|
||||
live: <A, E>(name: string, body: (input: RunFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
|
||||
it.live(name, () => withRunFixture(body), opts),
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Shared provider config for tests that need opencode to talk to a fake LLM
|
||||
// over a real HTTP endpoint. Registers a single provider `test` with a single
|
||||
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
|
||||
// caller supplies (typically a TestLLMServer instance).
|
||||
//
|
||||
// Used by:
|
||||
// - test/lib/run-process.ts (subprocess CLI tests)
|
||||
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
|
||||
export function testProviderConfig(llmUrl: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: { apiKey: "test-key", baseURL: llmUrl },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
@@ -99,39 +100,6 @@ function authorization(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
}
|
||||
|
||||
function providerConfig(url: string) {
|
||||
return {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function call<T>(request: () => Promise<T>) {
|
||||
return Effect.promise(request)
|
||||
}
|
||||
@@ -283,7 +251,7 @@ function withStandardProject<A, E>(
|
||||
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
@@ -297,7 +265,7 @@ function withFakeLlmProject<A, E>(
|
||||
return yield* withProject(
|
||||
serverPath,
|
||||
{
|
||||
config: providerConfig(llm.url),
|
||||
config: testProviderConfig(llm.url),
|
||||
setup: options.setup,
|
||||
},
|
||||
(input) => run({ ...input, llm }),
|
||||
|
||||
@@ -292,6 +292,34 @@ describe("session.llm-native.request", () => {
|
||||
).toEqual({ type: "unsupported", reason: "OpenAI API key is not configured" })
|
||||
})
|
||||
|
||||
test("prefers console provider api key over stored opencode auth", () => {
|
||||
expect(
|
||||
LLMNativeRuntime.status({
|
||||
model: { ...baseModel, providerID: ProviderID.make("opencode") },
|
||||
provider: {
|
||||
...providerInfo,
|
||||
id: ProviderID.make("opencode"),
|
||||
options: { apiKey: "console-token" },
|
||||
key: "zen-token",
|
||||
},
|
||||
auth: { type: "api", key: "zen-token" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "supported",
|
||||
apiKey: "console-token",
|
||||
})
|
||||
expect(
|
||||
LLMNativeRuntime.status({
|
||||
model: baseModel,
|
||||
provider: { ...providerInfo, options: {}, key: "provider-key" },
|
||||
auth: undefined,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "supported",
|
||||
apiKey: "provider-key",
|
||||
})
|
||||
})
|
||||
|
||||
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
||||
const wrapped = LLMNativeRuntime.nativeTools(
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1563,7 +1563,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
const streaming = createMemo(
|
||||
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||
)
|
||||
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim()
|
||||
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim()
|
||||
|
||||
return (
|
||||
<Show when={text()}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -71,6 +71,8 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
||||
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
|
||||
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
|
||||
| Provider env precedence and model lookup cases can use Effect-aware instance fixtures | Migrated four more provider lookup/default-model cases to `it.instance` | 6.12s | 6.36s | keep | Noisy 5-run median; kept as a small stacked cleanup slice but do not claim speedup from this migration. |
|
||||
| Simple config load cases can use Effect-aware instance fixtures | Migrated JSON, shell, formatter, and lsp config load cases to `it.instance` | 14.18s | 3.93s | keep | Three-run medians before/after; removes manual `tmpdir` + `withTestInstance` setup from the first simple config block. |
|
||||
| Config template, file include, and simple agent cases can use Effect-aware instance fixtures | Migrated JSONC, env/file substitution, invalid config, and agent config cases to `it.instance` | 1.87s | 1.90s | keep | Stacked on the first config slice; neutral timing but removes more manual `tmpdir` + instance plumbing. |
|
||||
|
||||
## Profiling Results
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.5",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user