Compare commits
3 commits
dev
...
oc-issue-q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff1c6f333 |
||
|
|
f7493d41cb |
||
|
|
f2f8efa411 |
5 changed files with 215 additions and 33 deletions
|
|
@ -360,14 +360,10 @@ function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undef
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Question tool replies can complete without a matching question.replied event.
|
// Tool-owned question requests can complete without a matching question.replied
|
||||||
// When that happens, drop the recovered pending request tied to this tool call so
|
// event. When that happens, drop the recovered pending request tied to this tool
|
||||||
// the footer can return to the next blocker or to the prompt.
|
// call so the footer can return to the next blocker or to the prompt.
|
||||||
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
|
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
|
||||||
if (part.tool !== "question") {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.state.status !== "completed" && part.state.status !== "error") {
|
if (part.state.status !== "completed" && part.state.status !== "error") {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
// The tick counter prevents stale idle events from resolving the wrong turn.
|
// The tick counter prevents stale idle events from resolving the wrong turn.
|
||||||
// We also re-check live session status before resolving an idle event so a
|
// We also re-check live session status before resolving an idle event so a
|
||||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||||
import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2"
|
import type { Event, GlobalEvent, OpencodeClient, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import {
|
import {
|
||||||
|
|
@ -505,7 +505,10 @@ function createLayer(input: StreamInput) {
|
||||||
state.footerView = current
|
state.footerView = current
|
||||||
}
|
}
|
||||||
|
|
||||||
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) {
|
const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (part: ToolPart) {
|
||||||
|
const partID = part.id
|
||||||
|
const matches = (request: SessionData["questions"][number]) =>
|
||||||
|
request.tool?.messageID === part.messageID && request.tool?.callID === part.callID
|
||||||
if (recovering.has(partID)) {
|
if (recovering.has(partID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -513,7 +516,7 @@ function createLayer(input: StreamInput) {
|
||||||
recovering.add(partID)
|
recovering.add(partID)
|
||||||
try {
|
try {
|
||||||
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
|
while (!closed && !abort.signal.aborted && !input.footer.isClosed) {
|
||||||
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
|
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -521,11 +524,14 @@ function createLayer(input: StreamInput) {
|
||||||
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
|
Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)),
|
||||||
Effect.orElseSucceed(() => []),
|
Effect.orElseSucceed(() => []),
|
||||||
)
|
)
|
||||||
if (state.data.questions.length > 0 || !state.data.tools.has(partID)) {
|
if (state.data.questions.some(matches) || !state.data.tools.has(partID)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (questions.length > 0) {
|
const matching = questions.filter(matches)
|
||||||
|
if (matching.length > 0) {
|
||||||
|
const active = new Set(questions.map((request) => request.id))
|
||||||
|
state.data.questions = state.data.questions.filter((request) => active.has(request.id))
|
||||||
bootstrapSessionData({
|
bootstrapSessionData({
|
||||||
data: state.data,
|
data: state.data,
|
||||||
messages: [],
|
messages: [],
|
||||||
|
|
@ -535,9 +541,11 @@ function createLayer(input: StreamInput) {
|
||||||
for (const request of questions) {
|
for (const request of questions) {
|
||||||
seedBlocker(request.id)
|
seedBlocker(request.id)
|
||||||
}
|
}
|
||||||
|
const priority = Math.min(0, ...state.blockers.values()) - 1
|
||||||
|
for (const request of matching) state.blockers.set(request.id, priority)
|
||||||
input.trace?.write("question.recover", {
|
input.trace?.write("question.recover", {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
requests: questions.map((request) => request.id),
|
requests: matching.map((request) => request.id),
|
||||||
})
|
})
|
||||||
syncFooter([])
|
syncFooter([])
|
||||||
return
|
return
|
||||||
|
|
@ -783,11 +791,10 @@ function createLayer(input: StreamInput) {
|
||||||
event.type === "message.part.updated" &&
|
event.type === "message.part.updated" &&
|
||||||
event.properties.part.sessionID === input.sessionID &&
|
event.properties.part.sessionID === input.sessionID &&
|
||||||
event.properties.part.type === "tool" &&
|
event.properties.part.type === "tool" &&
|
||||||
event.properties.part.tool === "question" &&
|
(event.properties.part.tool === "question" || event.properties.part.tool === "plan_exit") &&
|
||||||
event.properties.part.state.status === "running" &&
|
event.properties.part.state.status === "running"
|
||||||
state.data.questions.length === 0
|
|
||||||
) {
|
) {
|
||||||
yield* recoverQuestion(event.properties.part.id).pipe(
|
yield* recoverQuestion(event.properties.part).pipe(
|
||||||
Effect.forkIn(scope, { startImmediately: true }),
|
Effect.forkIn(scope, { startImmediately: true }),
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ import path from "path"
|
||||||
import { useKV } from "./kv"
|
import { useKV } from "./kv"
|
||||||
import { aggregateFailures } from "./aggregate-failures"
|
import { aggregateFailures } from "./aggregate-failures"
|
||||||
|
|
||||||
|
export function questionToolRequestIndex(requests: readonly QuestionRequest[] | undefined, part: Part) {
|
||||||
|
if (part.type !== "tool") return -1
|
||||||
|
if (part.state.status !== "completed" && part.state.status !== "error") return -1
|
||||||
|
return requests?.findIndex(
|
||||||
|
(request) => request.tool?.messageID === part.messageID && request.tool?.callID === part.callID,
|
||||||
|
) ?? -1
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
name: "Sync",
|
name: "Sync",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -304,21 +312,30 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.part.updated": {
|
case "message.part.updated": {
|
||||||
const parts = store.part[event.properties.part.messageID]
|
const part = event.properties.part
|
||||||
|
const parts = store.part[part.messageID]
|
||||||
if (!parts) {
|
if (!parts) {
|
||||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
setStore("part", part.messageID, [part])
|
||||||
break
|
|
||||||
}
|
|
||||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
|
||||||
if (result.found) {
|
|
||||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
if (parts) {
|
||||||
|
const result = Binary.search(parts, part.id, (p) => p.id)
|
||||||
|
if (result.found) setStore("part", part.messageID, result.index, reconcile(part))
|
||||||
|
if (!result.found)
|
||||||
setStore(
|
setStore(
|
||||||
"part",
|
"part",
|
||||||
event.properties.part.messageID,
|
part.messageID,
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.splice(result.index, 0, event.properties.part)
|
draft.splice(result.index, 0, part)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const index = questionToolRequestIndex(store.question[part.sessionID], part)
|
||||||
|
if (index !== -1)
|
||||||
|
setStore(
|
||||||
|
"question",
|
||||||
|
part.sessionID,
|
||||||
|
produce((draft) => {
|
||||||
|
draft.splice(index, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
|
||||||
|
|
@ -835,12 +835,17 @@ describe("run stream transport", () => {
|
||||||
callID: "call-question-1",
|
callID: "call-question-1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
const other = {
|
||||||
|
...request,
|
||||||
|
id: "question-old",
|
||||||
|
tool: { messageID: "msg-old", callID: "call-question-old" },
|
||||||
|
}
|
||||||
const transport = await createSessionTransport({
|
const transport = await createSessionTransport({
|
||||||
sdk: sdk({
|
sdk: sdk({
|
||||||
stream: src.stream,
|
stream: src.stream,
|
||||||
questions: async () => {
|
questions: async () => {
|
||||||
questionCalls += 1
|
questionCalls += 1
|
||||||
return ok(questionCalls > 1 ? [request] : [])
|
return ok(questionCalls === 1 ? [other] : [request])
|
||||||
},
|
},
|
||||||
promptAsync: async () => {
|
promptAsync: async () => {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
|
@ -885,7 +890,9 @@ describe("run stream transport", () => {
|
||||||
|
|
||||||
const view = await waitFor(() => {
|
const view = await waitFor(() => {
|
||||||
const item = ui.events.findLast((event) => event.type === "stream.view")
|
const item = ui.events.findLast((event) => event.type === "stream.view")
|
||||||
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
|
return item?.type === "stream.view" && item.view.type === "question" && item.view.request.id === request.id
|
||||||
|
? item.view
|
||||||
|
: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(view).toEqual({
|
expect(view).toEqual({
|
||||||
|
|
@ -901,6 +908,7 @@ describe("run stream transport", () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const count = ui.events.length
|
||||||
src.push(
|
src.push(
|
||||||
toolUpdated(
|
toolUpdated(
|
||||||
completedTool({
|
completedTool({
|
||||||
|
|
@ -930,6 +938,121 @@ describe("run stream transport", () => {
|
||||||
view: { type: "prompt" },
|
view: { type: "prompt" },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
ui.events.slice(count).findLast(
|
||||||
|
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === other.id,
|
||||||
|
),
|
||||||
|
).toBeUndefined()
|
||||||
|
|
||||||
|
ctrl.abort()
|
||||||
|
await run
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("recovers pending plan_exit questions from question.list when question.asked is missed", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let questionCalls = 0
|
||||||
|
const request = {
|
||||||
|
id: "question-plan-1",
|
||||||
|
sessionID: "session-1",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "Plan is complete. Start implementing it now?",
|
||||||
|
header: "Build Agent",
|
||||||
|
options: [{ label: "Yes", description: "Switch to build agent and start implementing." }],
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: {
|
||||||
|
messageID: "msg-plan-1",
|
||||||
|
callID: "call-plan-exit-1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
questions: async () => {
|
||||||
|
questionCalls += 1
|
||||||
|
return ok(questionCalls === 1 ? [] : [request])
|
||||||
|
},
|
||||||
|
promptAsync: async () => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
src.push(busy())
|
||||||
|
src.push(assistant("msg-plan-1"))
|
||||||
|
src.push(
|
||||||
|
toolUpdated(
|
||||||
|
runningTool({
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-plan-1",
|
||||||
|
id: "plan-exit-tool-1",
|
||||||
|
callID: "call-plan-exit-1",
|
||||||
|
tool: "plan_exit",
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return ok(undefined)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const run = transport.runPromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { text: "hello", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
signal: ctrl.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.view")
|
||||||
|
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "question",
|
||||||
|
request,
|
||||||
|
})
|
||||||
|
|
||||||
|
src.push(
|
||||||
|
toolUpdated(
|
||||||
|
completedTool({
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-plan-1",
|
||||||
|
id: "plan-exit-tool-1",
|
||||||
|
callID: "call-plan-exit-1",
|
||||||
|
tool: "plan_exit",
|
||||||
|
body: {},
|
||||||
|
output: "User approved switching to build agent.",
|
||||||
|
metadata: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await waitFor(() => {
|
||||||
|
const item = ui.events.findLast((event) => event.type === "stream.view")
|
||||||
|
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
type: "stream.view",
|
||||||
|
view: { type: "prompt" },
|
||||||
|
})
|
||||||
|
|
||||||
ctrl.abort()
|
ctrl.abort()
|
||||||
await run
|
await run
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
39
packages/opencode/test/cli/tui/sync.test.ts
Normal file
39
packages/opencode/test/cli/tui/sync.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
|
import { questionToolRequestIndex } from "@/cli/cmd/tui/context/sync"
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
id: "question-new",
|
||||||
|
sessionID: "session-1",
|
||||||
|
questions: [],
|
||||||
|
tool: { messageID: "msg-new", callID: "call-new" },
|
||||||
|
} satisfies QuestionRequest
|
||||||
|
|
||||||
|
function part(status: "running" | "completed" | "error", tool = "question", callID = "call-new"): ToolPart {
|
||||||
|
return {
|
||||||
|
id: "part-new",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg-new",
|
||||||
|
type: "tool",
|
||||||
|
callID,
|
||||||
|
tool,
|
||||||
|
state:
|
||||||
|
status === "running"
|
||||||
|
? { status, input: {}, time: { start: 1 } }
|
||||||
|
: status === "completed"
|
||||||
|
? { status, input: {}, output: "", title: "question", metadata: {}, time: { start: 1, end: 2 } }
|
||||||
|
: { status, input: {}, error: "Tool execution aborted", time: { start: 1, end: 2 } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("tui sync", () => {
|
||||||
|
test("matches terminal tool-owned question requests", () => {
|
||||||
|
const stale = { ...request, id: "question-old", tool: { messageID: "msg-old", callID: "call-old" } }
|
||||||
|
|
||||||
|
expect(questionToolRequestIndex([stale, request], part("error"))).toBe(1)
|
||||||
|
expect(questionToolRequestIndex([stale, request], part("completed"))).toBe(1)
|
||||||
|
expect(questionToolRequestIndex([stale, request], part("completed", "plan_exit"))).toBe(1)
|
||||||
|
expect(questionToolRequestIndex([stale, request], part("running"))).toBe(-1)
|
||||||
|
expect(questionToolRequestIndex([stale, request], part("error", "bash", "call-other"))).toBe(-1)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue