Skip to content
17 changes: 17 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
import PROMPT_DEBUG from "./prompt/debug.txt"
import { PermissionNext } from "@/permission/next"
import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global"
Expand Down Expand Up @@ -104,6 +105,22 @@ export namespace Agent {
mode: "primary",
native: true,
},
debug: {
name: "debug",
description:
"Evidence-based debugging mode. Use this to generate hypotheses, add runtime instrumentation, analyze logs, and only then implement fixes.",
prompt: PROMPT_DEBUG,
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
Expand Down
67 changes: 67 additions & 0 deletions packages/opencode/src/agent/prompt/debug.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
You are operating in Debug Mode.

Core principle: NEVER implement a fix without runtime evidence first. Do not guess from code reading alone.

You must follow this workflow:

Phase 1: Hypotheses
- Generate 3–5 precise, testable hypotheses about why the bug occurs.
- Each hypothesis must be verifiable via instrumentation.

Phase 2: Instrumentation
- Add 3–8 small instrumentation logs to test all hypotheses in parallel.
- Do NOT log secrets, tokens, passwords, API keys, or PII.
- Each log entry must be a single JSON object and include these required fields:
- sessionId, runId, hypothesisId, location, message, data, timestamp
- Logs must be sent to the ingest endpoint provided in <debug_config>.
- Do NOT rely on console.log as the primary evidence channel. Evidence MUST be collected via debug logs written to .opencode/debug.log by POSTing to ingestUrl.
- Logging mechanics (MANDATORY):
- Read ingestUrl from <debug_config> and POST JSON to it for every instrumentation log.
- Use Content-Type: application/json.
- Never throw if logging fails; always swallow errors (so instrumentation doesn't break the app).
- JS/TS template (replace ingestUrl with the value from <debug_config>):
- // #region agent log
- fetch(ingestUrl, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- location: "file.js:42",
- message: "Function entry",
- data: { /* values */ },
- timestamp: Date.now(),
- sessionId: "<sessionId from debug_config URL>",
- runId: "run1",
- hypothesisId: "A",
- }),
- }).catch(() => {})
- // #endregion
- If you propose instrumentation code, wrap each log in a code region:
- // #region agent log
- // #endregion

Phase 3: Reproduction
- Before each reproduction run, CLEAR the log file at logFileAbsolute from <debug_config>.
- Use the write tool to clear it by writing an empty string (do not use shell rm).
- Call the reproduction_steps tool with an ordered steps array to show the reproduction prompt.
- Remind the user if a restart is needed for instrumentation to take effect.

Phase 4: Log Analysis
- Read the log file from <debug_config> using the read tool.
- Treat it as NDJSON: one JSON object per line.
- For each hypothesis, classify: CONFIRMED / REJECTED / INCONCLUSIVE, citing specific log lines.

Phase 5: Fix Implementation
- Only implement a fix if evidence clearly supports a hypothesis.
- Keep instrumentation active while fixing (do not remove logs yet).
- Forbidden: artificial delays (setTimeout/sleep) as a “fix”.

Phase 6: Verification
- Ask the user to reproduce again with the same steps.
- Tag post-fix logs with runId: \"post-fix\".
- Compare before/after logs and cite evidence that behavior is now correct.

Phase 7: Cleanup
- Only after verification + user confirmation, remove instrumentation code.

When unsure, ask the user for clarification rather than assuming.

43 changes: 43 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
Command,
PermissionRequest,
QuestionRequest,
ReproductionStepsRequest,
LspStatus,
McpStatus,
McpResource,
Expand Down Expand Up @@ -46,6 +47,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
question: {
[sessionID: string]: QuestionRequest[]
}
reproduction_steps: {
[sessionID: string]: ReproductionStepsRequest[]
}
config: Config
session: Session[]
session_status: {
Expand Down Expand Up @@ -85,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: [],
permission: {},
question: {},
reproduction_steps: {},
command: [],
provider: [],
provider_default: {},
Expand Down Expand Up @@ -185,6 +190,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}

case "reproduction.steps.replied":
case "reproduction.steps.rejected": {
const requests = store.reproduction_steps[event.properties.sessionID]
if (!requests) break
const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
if (!match.found) break
setStore(
"reproduction_steps",
event.properties.sessionID,
produce((draft) => {
draft.splice(match.index, 1)
}),
)
break
}

case "reproduction.steps.asked": {
const request = event.properties
const requests = store.reproduction_steps[request.sessionID]
if (!requests) {
setStore("reproduction_steps", request.sessionID, [request])
break
}
const match = Binary.search(requests, request.id, (r) => r.id)
if (match.found) {
setStore("reproduction_steps", request.sessionID, match.index, reconcile(request))
break
}
setStore(
"reproduction_steps",
request.sessionID,
produce((draft) => {
draft.splice(match.index, 0, request)
}),
)
break
}

case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
Expand Down
17 changes: 15 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { ReproductionStepsPrompt } from "./reproduction-steps"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"

Expand Down Expand Up @@ -127,6 +128,10 @@ export function Session() {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const reproductionSteps = createMemo(() => {
if (session()?.parentID) return []
return children().flatMap((x) => sync.data.reproduction_steps[x.id] ?? [])
})

const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
Expand Down Expand Up @@ -1029,8 +1034,16 @@ export function Session() {
<Show when={permissions().length === 0 && questions().length > 0}>
<QuestionPrompt request={questions()[0]} />
</Show>
<Show when={permissions().length === 0 && questions().length === 0 && reproductionSteps().length > 0}>
<ReproductionStepsPrompt request={reproductionSteps()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
visible={
!session()?.parentID &&
permissions().length === 0 &&
questions().length === 0 &&
reproductionSteps().length === 0
}
ref={(r) => {
prompt = r
promptRef.set(r)
Expand All @@ -1039,7 +1052,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={permissions().length > 0 || questions().length > 0}
disabled={permissions().length > 0 || questions().length > 0 || reproductionSteps().length > 0}
onSubmit={() => {
toBottom()
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { useTheme, selectedForeground } from "../../context/theme"
import { SplitBorder } from "../../component/border"
import type { ReproductionStepsAction, ReproductionStepsRequest } from "@opencode-ai/sdk/v2"
import { useSDK } from "../../context/sdk"
import { useKeybind } from "../../context/keybind"
import { useDialog } from "../../ui/dialog"

type Action = "proceed" | "fixed"

export function ReproductionStepsPrompt(props: { request: ReproductionStepsRequest }) {
const sdk = useSDK()
const { theme } = useTheme()
const keybind = useKeybind()
const dialog = useDialog()
const renderer = useRenderer()
const keys = ["proceed", "fixed"] as const
const labels = createMemo(() => ({
proceed: "Proceed",
fixed: "Mark as Fixed",
}))
const [store, setStore] = createStore<{ selected: Action }>({
selected: "proceed",
})

function submit(action: Action) {
sdk.client.reproductionSteps
.reply({
requestID: props.request.id,
reproductionStepsReply: {
action,
},
})
.catch(() => {})
}

useKeyboard((evt) => {
if (dialog.stack.length > 0) return
if (evt.name === "left" || evt.name === "h") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx - 1 + keys.length) % keys.length]
setStore("selected", next)
}
if (evt.name === "right" || evt.name === "l") {
evt.preventDefault()
const idx = keys.indexOf(store.selected)
const next = keys[(idx + 1) % keys.length]
setStore("selected", next)
}
if (evt.name === "return") {
evt.preventDefault()
submit(store.selected)
}
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
evt.preventDefault()
sdk.client.reproductionSteps
.reply({
requestID: props.request.id,
reproductionStepsReply: {
action: "skipped" as ReproductionStepsAction,
},
})
.catch(() => {})
}
})

return (
<box
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.warning}
customBorderChars={SplitBorder.customBorderChars}
>
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
<box flexDirection="row" gap={1} paddingLeft={1}>
<text fg={theme.warning}>{"△"}</text>
<text fg={theme.text}>Reproduction steps</text>
</box>
<box paddingLeft={1} gap={0}>
{props.request.steps.map((step, index) => {
const formatted = /^\d+\.\s+/.test(step) ? step : `${index + 1}. ${step}`
return <text fg={theme.text}>{formatted}</text>
})}
</box>
</box>
<box
flexDirection="row"
flexShrink={0}
gap={1}
paddingTop={1}
paddingLeft={2}
paddingRight={3}
paddingBottom={1}
backgroundColor={theme.backgroundElement}
justifyContent="space-between"
>
<box flexDirection="row" gap={1}>
{keys.map((option) => (
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setStore("selected", option)
submit(option)
}}
>
<text fg={option === store.selected ? selectedForeground(theme, theme.warning) : theme.textMuted}>
{labels()[option]}
</text>
</box>
))}
</box>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
{"⇆"} <span style={{ fg: theme.textMuted }}>select</span>
</text>
<text fg={theme.text}>
enter <span style={{ fg: theme.textMuted }}>confirm</span>
</text>
</box>
</box>
</box>
)
}
Loading