Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
daa9acc
fix(opencode): publish rejected on Question.ask interrupt
Astro-Han May 4, 2026
940d8f6
fix(opencode): clearer error message for cancelled question tool
Astro-Han May 4, 2026
8b3eb8f
fix(app): fallback recovers multi-pending question loss
Astro-Han May 4, 2026
f27a67b
fix(ui): surface friendly hint when a question tool is cancelled
Astro-Han May 4, 2026
5e0af58
test(app): e2e coverage for cancelled question hint
Astro-Han May 4, 2026
aafef46
fix(opencode): distinguish question dismiss from session cancel
Astro-Han May 4, 2026
0f00d59
fix(app): pool unmatched buckets in question fallback
Astro-Han May 4, 2026
f5adf51
fix(app): scrub host AI provider env vars from e2e backend
Astro-Han May 4, 2026
c8064c3
feat(app): add question recovery snapshot reducer
Astro-Han May 4, 2026
592689e
feat(app): add question recovery clock
Astro-Han May 4, 2026
e62e57b
refactor(app): hoist halt above composer + plumb to blockers
Astro-Han May 4, 2026
8f6b77d
feat(app): wire question recovery clock into session blockers
Astro-Han May 4, 2026
3f9b5fb
test(app): lock followup auto-send after halt
Astro-Han May 4, 2026
b5b08be
fix(app): tighten question recovery against navigation + transient li…
Astro-Han May 4, 2026
c644e6d
fix(app): re-arm question recovery clock on transient list failures
Astro-Han May 4, 2026
39d22d3
docs(app): clarify retry semantics on transient question.list failure
Astro-Han May 4, 2026
8819e58
fix(app): bound question recovery retry to one follow-up attempt
Astro-Han May 4, 2026
8b2d3a6
fix(opencode): provide explicit context to abort-fired publish
Astro-Han May 4, 2026
d3086f3
test(app): cover question recovery reverify glue
Astro-Han May 4, 2026
53c7a52
test(ui): lock interrupted hint live-stream reactivity contract
Astro-Han May 4, 2026
3725b7f
chore(opencode): tighten Question.ask abort tests + compress comments
Astro-Han May 4, 2026
1cfec29
chore(ui): tighten cancelled question hint zh copy
Astro-Han May 4, 2026
a1a3659
fix(app): reverify generic matches QuestionRequest tool identity
Astro-Han May 4, 2026
cae9db3
chore(opencode): compress Question.ask cancellation comments
Astro-Han May 4, 2026
b1dc539
refactor(app): drop remaining as never in reverify by tightening deps
Astro-Han May 4, 2026
ac2e901
fix(opencode): branch RejectedError message on cancelled flag
Astro-Han May 4, 2026
410b8f2
fix(app): extend question recovery retry budget before silent give-up
Astro-Han May 4, 2026
781ede3
fix(app): escalate to halt when question recovery retry budget exhausts
Astro-Han May 4, 2026
5399a0d
fix(opencode): combine publish+fail in question abort handler
Astro-Han May 4, 2026
31b256a
test(app): cover full question recovery chain
Astro-Han May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/app/e2e/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,30 @@ const LOG_CAP = 100

const INTERNAL_SERVER_AUTH_ENV = new Set(["opencode_server_password", "opencode_server_username"])

// Strip any host-provided AI provider credentials from the spawned backend's
// environment so the test fixture's OPENCODE_E2E_LLM_URL routing always wins.
// Without this, a developer with e.g. GEMINI_API_KEY exported on their host
// gets that provider auto-picked as default model in the worker backend, and
// the test silently makes a real network call (or fails with auth errors)
// instead of routing through the in-process e2e LLM fixture.
//
// Pattern catches `*_API_KEY` / `*_API_TOKEN` (the bulk of provider env names
// in models.dev). Explicit set covers the long tail that doesn't match
// (e.g. `GITHUB_TOKEN` for Copilot, `HF_TOKEN`, `AWS_BEARER_TOKEN_BEDROCK`).
const PROVIDER_ENV_PATTERN = /_API_(KEY|TOKEN)$/
const PROVIDER_ENV_EXTRA = new Set([
"AWS_BEARER_TOKEN_BEDROCK",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"BAILING_API_TOKEN",
"DIGITALOCEAN_ACCESS_TOKEN",
"FRIENDLI_TOKEN",
"GITHUB_TOKEN",
"GITLAB_TOKEN",
"GOOGLE_APPLICATION_CREDENTIALS",
"HF_TOKEN",
])

function cap(input: string[]) {
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
}
Expand Down Expand Up @@ -90,6 +114,7 @@ export function createBackendEnv(input: {
}
for (const key of Object.keys(env)) {
if (INTERNAL_SERVER_AUTH_ENV.has(key.toLowerCase())) delete env[key]
else if (PROVIDER_ENV_PATTERN.test(key) || PROVIDER_ENV_EXTRA.has(key)) delete env[key]
}
return env
}
Expand Down
44 changes: 44 additions & 0 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1460,3 +1460,47 @@ test("question text renders source newlines as visible line breaks", async ({ pa
{ trackSession: project.trackSession },
)
})

// Cancelling a session while a question tool is awaiting an answer must clear
// the dock AND surface a friendly hint in the message stream so the user is
// not left staring at a stuck UI. See #419.
test("cancelled question tool surfaces interrupted hint in message stream", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
project.sdk,
"e2e composer dock question cancelled",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)

await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})

await expectQuestionBlocked(page)

await project.sdk.session.abort({ sessionID: session.id })

// Dock disappears via the live `question.rejected` SSE event published
// by Question.ask's abort handler — no reload needed for this leg.
await expect(page.locator(questionDockSelector)).toHaveCount(0, { timeout: 10_000 })

// The message stream isn't subscribed to mid-session message updates
// in this dock-focused test setup (matching the permission-flow tests
// which also reload before asserting on tool-result UI). Reload so the
// initial render walks the full message history and our error tool
// part with `metadata.interrupted = true` lands.
await page.goto(page.url())

// Hint string lives in packages/ui/src/i18n/en.ts (not the app dict);
// hardcode it here as the contract anchor for this fix.
await expect(
page.getByText("This question was cancelled before it was answered. Ask again below if you want to continue."),
).toBeVisible({ timeout: 10_000 })
})
},
{ trackSession: project.trackSession },
)
})
19 changes: 13 additions & 6 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,19 @@ export default function Page() {
const timelineSessionID = timeline.sessionID
const timelineSessionKey = timeline.sessionKey
const timelineIsChildSession = timeline.isChildSession
const composer = createSessionComposerState({ sessionID: timelineSessionID, fallbackSessionID: () => params.id })
const haltAbort = (sessionID: string) =>
isSessionRunning(sync.data.session_status[sessionID], sync.data.message[sessionID])
? sdk.client.session.abort({ sessionID })
: Promise.resolve()
// sessionRevert chains halt with .then(), so its existing outer .catch
// already handles abort failures. The auto-heal clock wants to see the
// error so it can structured-warn — pass haltAbort directly there.
const halt = (sessionID: string) => haltAbort(sessionID).catch(() => {})
const composer = createSessionComposerState({
sessionID: timelineSessionID,
fallbackSessionID: () => params.id,
halt: haltAbort,
})
const timelineMessages = timeline.messages
const timelineMessagesReady = timeline.messagesReady
const timelineDiffs = timeline.diffs
Expand Down Expand Up @@ -449,11 +461,6 @@ export default function Page() {
attachmentLabel: () => language.t("common.attachment"),
})

const halt = (sessionID: string) =>
isSessionRunning(sync.data.session_status[sessionID], sync.data.message[sessionID])
? sdk.client.session.abort({ sessionID }).catch(() => {})
: Promise.resolve()

const sessionRevert = createSessionRevert({
sessionID: timelineSessionID,
revertMessageID: timelineRevertMessageID,
Expand Down
113 changes: 95 additions & 18 deletions packages/app/src/pages/session/blockers/question-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, ToolState } from "@opencode-ai/sdk/v2"
import type { Message, Part, QuestionRequest, ToolState } from "@opencode-ai/sdk/v2"
import { findRunningQuestionFallbackSession } from "./question-fallback"

const message = (id: string): Message => ({ id }) as Message
Expand All @@ -13,37 +13,69 @@ const toolState = (status: ToolState["status"]): ToolState =>
time: { start: 0 },
}) as ToolState

const toolPart = (tool: string, status: ToolState["status"] = "running"): Part =>
const toolPart = (
id: string,
tool: string,
status: ToolState["status"] = "running",
attrs?: { messageID?: string; callID?: string },
): Part =>
({
id: `part-${tool}-${status}`,
id,
type: "tool",
tool,
state: toolState(status),
messageID: attrs?.messageID,
callID: attrs?.callID,
}) as Part

const syncQ = (id: string, sessionID: string, tool?: { messageID: string; callID: string }): QuestionRequest =>
({
id,
sessionID,
questions: [{ header: "h", question: "q", options: [] }],
tool,
}) as QuestionRequest

describe("findRunningQuestionFallbackSession", () => {
test("returns undefined without a session", () => {
expect(findRunningQuestionFallbackSession({ hasQuestionRequest: false, partsByMessageID: {} })).toBeUndefined()
expect(findRunningQuestionFallbackSession({ syncQuestions: [], partsByMessageID: {} })).toBeUndefined()
})

test("returns undefined when a question request already exists", () => {
test("returns undefined when sync entry matches the running part by (messageID, callID)", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: true,
messages: [message("m")],
partsByMessageID: { m: [toolPart("question")] },
syncQuestions: [syncQ("q1", "s", { messageID: "m1", callID: "c1" })],
messages: [message("m1")],
partsByMessageID: { m1: [toolPart("p1", "question", "running", { messageID: "m1", callID: "c1" })] },
}),
).toBeUndefined()
})

test("returns the session when a recent running question tool part exists", () => {
test("triggers when running part has no matching sync entry by identity", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
// sync has an entry, but its tool identity points to a different call
syncQuestions: [syncQ("q_other", "s", { messageID: "m1", callID: "c_other" })],
messages: [message("m1")],
partsByMessageID: { m1: [toolPart("p1", "question", "running", { messageID: "m1", callID: "c1" })] },
}),
).toBe("s")
})

test("triggers when running parts outnumber matched sync entries (multi-pending parallel)", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
messages: [message("m")],
partsByMessageID: { m: [toolPart("question")] },
// only q1 matches; q2 q3 are running but unknown to sync
syncQuestions: [syncQ("q1", "s", { messageID: "m1", callID: "c1" })],
messages: [message("m1"), message("m2"), message("m3")],
partsByMessageID: {
m1: [toolPart("p1", "question", "running", { messageID: "m1", callID: "c1" })],
m2: [toolPart("p2", "question", "running", { messageID: "m2", callID: "c2" })],
m3: [toolPart("p3", "question", "running", { messageID: "m3", callID: "c3" })],
},
}),
).toBe("s")
})
Expand All @@ -52,21 +84,66 @@ describe("findRunningQuestionFallbackSession", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
syncQuestions: [],
messages: [message("m1"), message("m2")],
partsByMessageID: { m1: [toolPart("question", "completed")], m2: [toolPart("todowrite", "running")] },
partsByMessageID: {
m1: [toolPart("p1", "question", "completed", { messageID: "m1", callID: "c1" })],
m2: [toolPart("p2", "todowrite", "running", { messageID: "m2", callID: "c2" })],
},
}),
).toBeUndefined()
})

test("triggers for a running question part beyond the legacy 5-message window", () => {
const messages = Array.from({ length: 50 }, (_, i) => message(`m${i}`))
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
syncQuestions: [],
messages,
partsByMessageID: { m0: [toolPart("p0", "question", "running", { messageID: "m0", callID: "c0" })] },
}),
).toBe("s")
})

test("falls back to count check when neither side has tool identity", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
// sync entry without tool identity, running part also missing identity
syncQuestions: [syncQ("q1", "s")],
messages: [message("m1")],
partsByMessageID: { m1: [toolPart("p1", "question", "running")] },
}),
).toBeUndefined()
})

test("recovers running question parts even when they are older than the lookback window", () => {
test("count fallback triggers when running-without-identity exceeds entries-without-identity", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
messages: [message("old"), message("recent-1"), message("recent-2")],
partsByMessageID: { old: [toolPart("question")] },
syncQuestions: [],
messages: [message("m1"), message("m2")],
partsByMessageID: {
m1: [toolPart("p1", "question", "running")],
m2: [toolPart("p2", "question", "running")],
},
}),
).toBe("s")
})

// Mixed-state guard for #419: when a sync entry lacks tool identity but a
// running part has identity, the legacy entry should still cover it. Pre-fix
// behavior would treat the running part as missing and trigger fallback,
// even though the sync entry was a legitimate (legacy-shaped) match.
test("legacy sync entry without identity absorbs running part with identity", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
syncQuestions: [syncQ("q_legacy", "s")],
messages: [message("m1")],
partsByMessageID: { m1: [toolPart("p1", "question", "running", { messageID: "m1", callID: "c1" })] },
}),
).toBeUndefined()
})
})
46 changes: 39 additions & 7 deletions packages/app/src/pages/session/blockers/question-fallback.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,55 @@
import type { Message, Part } from "@opencode-ai/sdk/v2"

// Minimal sync-entry shape this matcher needs. Widened from the full
// QuestionRequest so callers (e.g. reverify) can pass narrower generics
// without `as never` while keeping QuestionRequest[] callers happy via
// structural subtyping.
export interface QuestionFallbackEntry {
tool?: { messageID: string; callID: string }
}

// Triggers fallback recovery when a running question tool part on this session
// has no matching entry in sync. Identity is (messageID, callID) so a model
// emitting parallel question tool calls is covered correctly even when the
// counts happen to line up but the entries point to different tool calls.
//
// Sync entries that lack tool identity (e.g. legacy / seeded test fixtures)
// can't be matched by key, so they cover any one running part. The
// uncovered-with-identity bucket and the without-identity bucket are pooled
// against the entries-without-identity bucket: a fallback only fires when
// the uncovered total truly exceeds what the legacy entries can absorb.
// See #419.
export function findRunningQuestionFallbackSession(input: {
sessionID?: string
hasQuestionRequest: boolean
messages?: Message[]
partsByMessageID: Record<string, Part[] | undefined>
syncQuestions: ReadonlyArray<QuestionFallbackEntry>
messages?: ReadonlyArray<Message>
partsByMessageID: Record<string, ReadonlyArray<Part> | undefined>
}): string | undefined {
if (!input.sessionID) return undefined
if (input.hasQuestionRequest) return undefined
const messages = input.messages
if (!messages?.length) return undefined

for (let i = messages.length - 1; i >= 0; i--) {
const parts = input.partsByMessageID[messages[i].id]
const coveredKeys = new Set<string>()
let entriesWithoutTool = 0
for (const q of input.syncQuestions) {
if (q.tool) coveredKeys.add(`${q.tool.messageID}:${q.tool.callID}`)
else entriesWithoutTool++
}

let uncoveredRunning = 0
for (const m of messages) {
const parts = input.partsByMessageID[m.id]
if (!parts) continue
for (const part of parts) {
if (part.type === "tool" && part.tool === "question" && part.state.status === "running") return input.sessionID
if (part.type !== "tool" || part.tool !== "question" || part.state.status !== "running") continue
const callID = part.callID
const messageID = part.messageID
if (!callID || !messageID || !coveredKeys.has(`${messageID}:${callID}`)) {
uncoveredRunning++
}
}
}

if (uncoveredRunning > entriesWithoutTool) return input.sessionID
return undefined
}
Loading
Loading