Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,55 @@ test("todo dock auto-hides after all todos complete", async ({ page, project })
)
})

test("todo dock keeps the original hide timer during terminal-only refreshes", async ({ page, project }) => {
await project.open()
await page.clock.install()
await withDockSession(
project.sdk,
"e2e composer dock todo unchanged terminal refresh",
async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)

try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
{ content: "third task", status: "pending", priority: "medium" },
{ content: "fourth task", status: "pending", priority: "low" },
])
await dock.expectCollapsed(["pending", "in_progress", "pending", "pending"])

const completed = [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "completed", priority: "medium" },
{ content: "third task", status: "completed", priority: "medium" },
{ content: "fourth task", status: "completed", priority: "low" },
] as const

await dock.finish([
{ ...completed[0], content: "first task done" },
{ ...completed[1], content: "second task done" },
{ ...completed[2], content: "third task done" },
{ ...completed[3], content: "fourth task done" },
])
await dock.expectState({ dock: true, completing: true, count: 4 })
await page.clock.fastForward(2_500)

await dock.finish([...completed])
await dock.expectState({ dock: true, completing: true, count: 4 })
await page.clock.fastForward(500)

await dock.expectState({ dock: false, completing: false, count: 4 })
await dock.expectDockGone()
} finally {
await dock.clear()
}
},
{ trackSession: project.trackSession },
)
})

test("todo dock appears from real todowrite tool parts", async ({ page, llm, project }) => {
await project.open()
await withDockSession(
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { sessionPermissionRequest } from "../session/blockers/request-tree"
import { createSessionRunning } from "../session/session-running-state"
import { childSessionOnPath, hasProjectPermissions } from "./helpers"

Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/pages/session/blockers/question-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, ToolState } from "@opencode-ai/sdk/v2"
import { findRunningQuestionFallbackSession } from "./question-fallback"

const message = (id: string): Message => ({ id }) as Message

const toolState = (status: ToolState["status"]): ToolState =>
({
status,
input: {},
title: "",
metadata: {},
time: { start: 0 },
}) as ToolState

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

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

test("returns undefined when a question request already exists", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: true,
messages: [message("m")],
partsByMessageID: { m: [toolPart("question")] },
}),
).toBeUndefined()
})

test("returns the session when a recent running question tool part exists", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
messages: [message("m")],
partsByMessageID: { m: [toolPart("question")] },
}),
).toBe("s")
})

test("ignores non-running question parts and other tools", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
messages: [message("m1"), message("m2")],
partsByMessageID: { m1: [toolPart("question", "completed")], m2: [toolPart("todowrite", "running")] },
}),
).toBeUndefined()
})

test("ignores running question parts older than the lookback window", () => {
expect(
findRunningQuestionFallbackSession({
sessionID: "s",
hasQuestionRequest: false,
lookback: 2,
messages: [message("old"), message("recent-1"), message("recent-2")],
partsByMessageID: { old: [toolPart("question")] },
}),
).toBeUndefined()
})
})
27 changes: 27 additions & 0 deletions packages/app/src/pages/session/blockers/question-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Message, Part } from "@opencode-ai/sdk/v2"

export const QUESTION_FALLBACK_LOOKBACK_MESSAGES = 5

export function findRunningQuestionFallbackSession(input: {
sessionID?: string
hasQuestionRequest: boolean
messages?: Message[]
partsByMessageID: Record<string, Part[] | undefined>
lookback?: number
}): string | undefined {
if (!input.sessionID) return undefined
if (input.hasQuestionRequest) return undefined
const messages = input.messages
if (!messages?.length) return undefined

const lookback = input.lookback ?? QUESTION_FALLBACK_LOOKBACK_MESSAGES
for (let i = messages.length - 1; i >= Math.max(0, messages.length - lookback); i--) {
const parts = input.partsByMessageID[messages[i].id]
if (!parts) continue
for (const part of parts) {
if (part.type === "tool" && part.tool === "question" && part.state.status === "running") return input.sessionID
}
}

return undefined
}
113 changes: 113 additions & 0 deletions packages/app/src/pages/session/blockers/question-reconcile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import type { QuestionRequest } from "@opencode-ai/sdk/v2/client"
import { refetchPendingQuestionsForSession } from "./question-reconcile"

const question = (id: string, sessionID: string) =>
({
id,
sessionID,
questions: [],
}) as QuestionRequest

describe("refetchPendingQuestionsForSession", () => {
test("retries until the target session question appears", async () => {
const pending = question("q-late", "root")
let attempts = 0
const applied: Record<string, QuestionRequest[] | undefined> = {}

const result = await refetchPendingQuestionsForSession({
sessionID: "root",
maxAttempts: 2,
delayMs: 1,
sleep: async () => {},
shouldContinue: () => true,
list: async () => {
attempts += 1
return attempts === 1 ? [] : [pending]
},
apply(sessionID, questions) {
applied[sessionID] = questions
},
})

expect(result).toBe(true)
expect(attempts).toBe(2)
expect(applied.root?.map((item) => item.id)).toEqual(["q-late"])
})

test("does not stop when another session has a pending question first", async () => {
let attempts = 0
const applied: Record<string, QuestionRequest[] | undefined> = {}

const result = await refetchPendingQuestionsForSession({
sessionID: "target",
maxAttempts: 2,
delayMs: 1,
sleep: async () => {},
shouldContinue: () => true,
list: async () => {
attempts += 1
return attempts === 1 ? [question("q-other", "other")] : [question("q-target", "target")]
},
apply(sessionID, questions) {
applied[sessionID] = questions
},
})

expect(result).toBe(true)
expect(applied.other).toBeUndefined()
expect(applied.target?.map((item) => item.id)).toEqual(["q-target"])
})

test("does not apply stale results when continuation becomes false after list", async () => {
let shouldContinue = true
const applied: QuestionRequest[] = []

const result = await refetchPendingQuestionsForSession({
sessionID: "root",
maxAttempts: 1,
shouldContinue: () => shouldContinue,
list: async () => {
shouldContinue = false
return [question("q-root", "root")]
},
apply(_sessionID, questions) {
applied.push(...questions)
},
})

expect(result).toBe(false)
expect(applied).toEqual([])
})

test("filters invalid questions and sorts by id before apply", async () => {
const applied: Record<string, QuestionRequest[] | undefined> = {}

const result = await refetchPendingQuestionsForSession({
sessionID: "root",
maxAttempts: 1,
shouldContinue: () => true,
list: async () => [question("q-b", "root"), { id: "broken" } as QuestionRequest, question("q-a", "root")],
apply(sessionID, questions) {
applied[sessionID] = questions
},
})

expect(result).toBe(true)
expect(applied.root?.map((item) => item.id)).toEqual(["q-a", "q-b"])
})

test("returns false when max attempts are reached", async () => {
expect(
await refetchPendingQuestionsForSession({
sessionID: "root",
maxAttempts: 2,
delayMs: 1,
sleep: async () => {},
shouldContinue: () => true,
list: async () => [],
apply() {},
}),
).toBe(false)
})
})
43 changes: 43 additions & 0 deletions packages/app/src/pages/session/blockers/question-reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { QuestionRequest } from "@opencode-ai/sdk/v2/client"

export const QUESTION_REFETCH_ATTEMPTS = 4
export const QUESTION_REFETCH_DELAY_MS = 250

// Session-scoped and cancellation-safe by design: keep all three
// shouldContinue() checks so stale async question.list() results cannot write
// into the store after the active fallback session changes.
export async function refetchPendingQuestionsForSession(input: {
sessionID: string
maxAttempts?: number
delayMs?: number
sleep?: (ms: number) => Promise<void>
shouldContinue: () => boolean
list: () => Promise<ReadonlyArray<QuestionRequest>>
apply: (sessionID: string, questions: QuestionRequest[]) => void
}): Promise<boolean> {
const maxAttempts = input.maxAttempts ?? QUESTION_REFETCH_ATTEMPTS
const delayMs = input.delayMs ?? QUESTION_REFETCH_DELAY_MS
const sleep = input.sleep ?? ((ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms)))

for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (!input.shouldContinue()) return false

const questions = await input.list()

if (!input.shouldContinue()) return false

const target = questions
.filter((question): question is QuestionRequest => !!question?.id && question.sessionID === input.sessionID)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))

if (target.length > 0) {
if (!input.shouldContinue()) return false
input.apply(input.sessionID, target)
return true
}

if (attempt < maxAttempts - 1) await sleep(delayMs)
}

return false
}
Loading
Loading