Compare commits

...

4 Commits

Author SHA1 Message Date
opencode-agent[bot] 6106cb64c7 chore: generate 2026-08-17 03:35:05 +00:00
opencode-agent[bot] c786ab92d3 test(cli): ignore logs before standalone readiness (#42968)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-16 22:33:55 -05:00
Luke Parker c400746dd5 fix(app): render code mode executions (#42949) 2026-08-17 13:03:06 +10:00
opencode-agent[bot] e3ce37899d fix(core): run HTTP hooks for session generate (#42965)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-16 21:44:22 -05:00
9 changed files with 141 additions and 53 deletions
@@ -14,7 +14,7 @@ await Effect.runPromise(
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
)
console.log(`${endpoint.pid} ${endpoint.url} ${response.status}`)
console.log(`STANDALONE_READY ${endpoint.pid} ${endpoint.url} ${response.status}`)
return yield* Effect.never
}),
),
+10 -7
View File
@@ -9,8 +9,11 @@ test("standalone server exits when its owner is killed", async () => {
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url, status] = line?.split(" ") ?? []
const line = await Promise.race([
readLine(owner.stdout, "STANDALONE_READY "),
Bun.sleep(10_000).then(() => undefined),
])
const [, rawPID, url, status] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
@@ -29,7 +32,7 @@ test("standalone server exits when its owner is killed", async () => {
}
})
async function readLine(stream: ReadableStream<Uint8Array>) {
async function readLine(stream: ReadableStream<Uint8Array>, prefix: string) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
@@ -38,14 +41,14 @@ async function readLine(stream: ReadableStream<Uint8Array>) {
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const newline = output.indexOf("\n")
if (newline !== -1) {
const line = output.split("\n").find((line) => line.startsWith(prefix))
if (line) {
reader.releaseLock()
return output.slice(0, newline)
return line
}
}
reader.releaseLock()
return chunks.join("") + decoder.decode()
return (chunks.join("") + decoder.decode()).split("\n").find((line) => line.startsWith(prefix))
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
@@ -11,6 +11,7 @@ import { SessionContext } from "./context.js"
import { SessionGenerate } from "./generate.js"
import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
import { SessionSystemPrompt } from "./system-prompt.js"
@@ -79,6 +80,13 @@ export const layer = Layer.effect(
messages: contextEvent.messages,
tools: hookedTools,
}),
{
http: SessionModelHttp.middleware(hooks, {
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
}),
},
)
yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage })
return response.text
+5 -1
View File
@@ -51,15 +51,17 @@ import { Effect, Layer, Schema, Stream } from "effect"
import { testEffect } from "./lib/effect"
const requests: LLMRequest[] = []
let hasHttpMiddleware = false
let instruction: string | Instructions.Unavailable = "Initial context"
const sessionID = SessionSchema.ID.make("ses_generate_test")
const model = LanguageModel.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
const client = Layer.mock(LLMClient.Service)({
stream: () => Stream.die(new Error("unused")),
generate: (request) =>
generate: (request, options) =>
Effect.sync(() => {
requests.push(request)
hasHttpMiddleware = typeof options?.http === "function"
const response = LLMResponse.fromEvents([
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "generate" }),
@@ -221,6 +223,7 @@ const setup = Effect.gen(function* () {
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
hasHttpMiddleware = false
instruction = "Initial context"
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
@@ -298,6 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
@@ -547,6 +547,12 @@ export function getToolInfo(
title: i18n.t("ui.tool.shell"),
subtitle: input.command,
}
case "execute":
return {
icon: "console",
title: i18n.t("ui.tool.execute"),
subtitle: input.code,
}
case "edit":
return {
icon: "code-lines",
@@ -1574,6 +1580,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
if (typeof value === "string" && value) return value
return taskId()
})
const toolError = createMemo(() => partError(part(), i18n.t("ui.toolErrorCard.failed")))
const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
@@ -1583,7 +1590,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
<Show when={!hideQuestion()}>
<div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
<Switch>
<Match when={part().state.status === "error" && (part().state as any).error}>
<Match when={toolError()}>
{(error) => {
const cleaned = error().replace("Error: ", "")
if (part().tool === "question" && cleaned.includes("dismissed this question")) {
@@ -1644,6 +1651,26 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
)
}
function partError(part: ToolPart, fallback: string) {
if (part.state.status === "error") return part.state.error
if (part.tool !== "execute" || !("metadata" in part.state)) return undefined
const calls = part.state.metadata?.toolCalls
const failed =
part.state.metadata?.error === true ||
(Array.isArray(calls) &&
calls.some(
(call) =>
call !== null &&
typeof call === "object" &&
!Array.isArray(call) &&
"status" in call &&
call.status === "error",
))
if (!failed) return undefined
if ("output" in part.state && typeof part.state.output === "string" && part.state.output) return part.state.output
return fallback
}
export function MessageDivider(props: { label: string }) {
return (
<div data-component="compaction-part">
@@ -2104,6 +2131,84 @@ ToolRegistry.register({
ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
const i18n = useI18n()
const [copied, setCopied] = createSignal(false)
const copy = async () => {
if (!props.copy) return
if (!(await writeClipboard(props.copy))) return
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div data-component="bash-output" dir="ltr">
<div data-slot="bash-copy">
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
<IconButtonV2
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(event) => event.preventDefault()}
onClick={copy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</TooltipV2>
</div>
<div
data-slot="bash-scroll"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<pre data-slot="bash-pre">
<code>{props.children}</code>
</pre>
</div>
</div>
)
}
ToolRegistry.register({
name: "execute",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "streaming" || props.status === "running"
const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
const text = createMemo(() => {
const output = stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
return `${code()}${output ? "\n\n" + output : ""}`
})
const sawPending = pending()
return (
<BasicTool
{...props}
icon="console"
allowOpenWhilePending
trigger={(open) => (
<div data-slot="basic-tool-tool-info-structured">
<span data-slot="basic-tool-tool-indicator">
<Icon name="console" size="small" />
</span>
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
</span>
<Show when={!open() && code()}>
<ShellSubmessage text={code()} animate={sawPending} />
</Show>
</div>
</div>
)}
>
<ConsoleOutput copy={text()}>{text()}</ConsoleOutput>
</BasicTool>
)
},
})
ToolRegistry.register({
name: "shell",
render(props) {
@@ -2116,17 +2221,6 @@ ToolRegistry.register({
const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n")
return `${command()}${out ? "\n\n" + out : ""}`
})
const [copied, setCopied] = createSignal(false)
const handleCopy = async () => {
const content = command()
if (!content) return
if (await writeClipboard(content)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<BasicTool
{...props}
@@ -2145,36 +2239,12 @@ ToolRegistry.register({
</div>
)}
>
<div data-component="bash-output" dir="ltr">
<div data-slot="bash-copy">
<TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
<IconButtonV2
icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
size="normal"
variant="ghost-muted"
onMouseDown={(e) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</TooltipV2>
</div>
<div
data-slot="bash-scroll"
data-scrollable
tabIndex={0}
role="region"
aria-label={i18n.t("ui.scrollView.ariaLabel")}
>
<pre data-slot="bash-pre">
<code>
<span data-slot="bash-prompt" aria-hidden="true">
{"$ "}
</span>
{text()}
</code>
</pre>
</div>
</div>
<ConsoleOutput copy={command()}>
<span data-slot="bash-prompt" aria-hidden="true">
{"$ "}
</span>
{text()}
</ConsoleOutput>
</BasicTool>
)
},
@@ -71,8 +71,9 @@ describe("partDefaultOpen", () => {
).toBe(true)
})
test("preserves shell defaults", () => {
test("applies shell defaults to console tools", () => {
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
expect(partDefaultOpen(tool("execute", {}), true, false)).toBe(true)
})
})
@@ -23,7 +23,7 @@ function deletionOnly(part: ToolPart) {
export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
if (part.type !== "tool") return undefined
if (part.tool === "bash" || part.tool === "shell") return shell
if (part.tool === "bash" || part.tool === "shell" || part.tool === "execute") return shell
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
if (!edit) return false
return !deletionOnly(part)
@@ -54,6 +54,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
execute: "ui.tool.execute",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",
+1
View File
@@ -158,6 +158,7 @@ const source = {
"ui.tool.websearch": "Web Search",
"ui.tool.websearch.provider": "{{provider}} Web Search",
"ui.tool.shell": "Shell",
"ui.tool.execute": "Execute",
"ui.tool.patch": "Patch",
"ui.tool.questions": "Questions",
"ui.tool.questions.numbered": "Questions {{number}}",