fix(app): clear stale running session indicators
Use live session status for the running UI and finalize assistant messages when prompt setup fails so crashed runs do not leave sessions stuck as active.pull/17593/head
parent
2fc06c5a17
commit
060f482eb2
|
|
@ -15,6 +15,7 @@ import { useLanguage } from "@/context/language"
|
|||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { working } from "@/pages/session/activity"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
|
|
@ -204,18 +205,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
|
||||
)
|
||||
const status = sessionStore.session_status[props.session.id]
|
||||
return (
|
||||
pending !== undefined ||
|
||||
status?.type === "busy" ||
|
||||
status?.type === "retry" ||
|
||||
(status !== undefined && status.type !== "idle")
|
||||
)
|
||||
return working(sessionStore.session_status[props.session.id])
|
||||
})
|
||||
|
||||
const tint = createMemo(() => {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { useSettings } from "@/context/settings"
|
|||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import { working } from "@/pages/session/activity"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
|
|
@ -1361,10 +1362,7 @@ export default function Page() {
|
|||
})
|
||||
|
||||
const busy = (sessionID: string) => {
|
||||
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
|
||||
return (sync.data.message[sessionID] ?? []).some(
|
||||
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
return working(sync.data.session_status[sessionID])
|
||||
}
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { AssistantMessage, Message as MessageType, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { pending, working } from "./activity"
|
||||
|
||||
const user = (id: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
}) as UserMessage
|
||||
|
||||
const assistant = (id: string, parentID: string, completed?: number) =>
|
||||
({
|
||||
id,
|
||||
sessionID: "ses_1",
|
||||
parentID,
|
||||
role: "assistant",
|
||||
time: completed === undefined ? { created: 2 } : { created: 2, completed },
|
||||
}) as AssistantMessage
|
||||
|
||||
describe("session activity", () => {
|
||||
test("treats only non-idle status as running", () => {
|
||||
expect(working(undefined)).toBe(false)
|
||||
expect(working({ type: "idle" })).toBe(false)
|
||||
expect(working({ type: "busy" })).toBe(true)
|
||||
expect(working({ type: "retry", attempt: 1, message: "retry", next: 1 })).toBe(true)
|
||||
})
|
||||
|
||||
test("returns the trailing incomplete assistant", () => {
|
||||
const messages: MessageType[] = [user("msg_1"), assistant("msg_2", "msg_1")]
|
||||
|
||||
expect(pending(messages)?.id).toBe("msg_2")
|
||||
})
|
||||
|
||||
test("ignores older incomplete assistants once a later assistant completed", () => {
|
||||
const messages: MessageType[] = [
|
||||
user("msg_1"),
|
||||
assistant("msg_2", "msg_1"),
|
||||
user("msg_3"),
|
||||
assistant("msg_4", "msg_3", 4),
|
||||
]
|
||||
|
||||
expect(pending(messages)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const pending = (messages: readonly MessageType[]) => {
|
||||
const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant")
|
||||
if (!item || typeof item.time.completed === "number") return
|
||||
return item
|
||||
}
|
||||
|
||||
export const working = (status: SessionStatus | undefined) => status !== undefined && status.type !== "idle"
|
||||
|
|
@ -12,7 +12,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
|
|||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
|
|
@ -27,6 +27,7 @@ import { usePlatform } from "@/context/platform"
|
|||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { pending, working } from "@/pages/session/activity"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
|
||||
|
|
@ -236,17 +237,13 @@ export function MessageTimeline(props: {
|
|||
if (!id) return emptyMessages
|
||||
return sync.data.message[id] ?? emptyMessages
|
||||
})
|
||||
const pending = createMemo(() =>
|
||||
sessionMessages().findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const assistant = createMemo(() => pending(sessionMessages()))
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
|
||||
const busy = createMemo(() => working(sessionStatus()))
|
||||
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
|
||||
|
||||
const [slot, setSlot] = createStore({
|
||||
|
|
@ -264,7 +261,7 @@ export function MessageTimeline(props: {
|
|||
onCleanup(clear)
|
||||
createEffect(
|
||||
on(
|
||||
working,
|
||||
busy,
|
||||
(on, prev) => {
|
||||
clear()
|
||||
if (on) {
|
||||
|
|
@ -282,7 +279,9 @@ export function MessageTimeline(props: {
|
|||
),
|
||||
)
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (!busy()) return undefined
|
||||
|
||||
const parentID = assistant()?.parentID
|
||||
if (parentID) {
|
||||
const messages = sessionMessages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
|
|
@ -290,15 +289,10 @@ export function MessageTimeline(props: {
|
|||
if (message && message.role === "user") return message.id
|
||||
}
|
||||
|
||||
const status = sessionStatus()
|
||||
if (status.type !== "idle") {
|
||||
const messages = sessionMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
const messages = sessionMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { MessageV2 } from "./message-v2"
|
||||
|
||||
export const done = (msg: MessageV2.Assistant, time = Date.now()) => {
|
||||
if (typeof msg.time.completed === "number") return msg
|
||||
msg.time.completed = time
|
||||
return msg
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { Config } from "@/config/config"
|
|||
import { SessionCompaction } from "./compaction"
|
||||
import { PermissionNext } from "@/permission/next"
|
||||
import { Question } from "@/question"
|
||||
import { done } from "./assistant"
|
||||
import { PartID } from "./schema"
|
||||
import type { SessionID, MessageID } from "./schema"
|
||||
|
||||
|
|
@ -416,7 +417,7 @@ export namespace SessionProcessor {
|
|||
})
|
||||
}
|
||||
}
|
||||
input.assistantMessage.time.completed = Date.now()
|
||||
done(input.assistantMessage)
|
||||
await Session.updateMessage(input.assistantMessage)
|
||||
if (needsCompaction) return "compact"
|
||||
if (blocked) return "stop"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import fs from "fs/promises"
|
|||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { done } from "./assistant"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Log } from "../util/log"
|
||||
import { SessionRevert } from "./revert"
|
||||
|
|
@ -465,7 +466,7 @@ export namespace SessionPrompt {
|
|||
result,
|
||||
)
|
||||
assistantMessage.finish = "tool-calls"
|
||||
assistantMessage.time.completed = Date.now()
|
||||
done(assistantMessage)
|
||||
await Session.updateMessage(assistantMessage)
|
||||
if (result && part.state.status === "running") {
|
||||
await Session.updatePart({
|
||||
|
|
@ -599,90 +600,99 @@ export namespace SessionPrompt {
|
|||
})
|
||||
using _ = defer(() => InstructionPrompt.clear(processor.message.id))
|
||||
|
||||
// Check if user explicitly invoked an agent via @ in this turn
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
const format = lastUser.format ?? { type: "text" }
|
||||
const result = await (async () => {
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
|
||||
const tools = await resolveTools({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
// Inject StructuredOutput tool if JSON schema mode enabled
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: lastUser.format.schema,
|
||||
onSuccess(output) {
|
||||
structuredOutput = output
|
||||
},
|
||||
const tools = await resolveTools({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
SessionSummary.summarize({
|
||||
sessionID: sessionID,
|
||||
messageID: lastUser.id,
|
||||
})
|
||||
}
|
||||
if (format.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: format.schema,
|
||||
onSuccess(output) {
|
||||
structuredOutput = output
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Ephemerally wrap queued user messages with a reminder to stay on track
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "text" || part.ignored || part.synthetic) continue
|
||||
if (!part.text.trim()) continue
|
||||
part.text = [
|
||||
"<system-reminder>",
|
||||
"The user sent the following message:",
|
||||
part.text,
|
||||
"",
|
||||
"Please address this message and continue with your tasks.",
|
||||
"</system-reminder>",
|
||||
].join("\n")
|
||||
if (step === 1) {
|
||||
SessionSummary.summarize({
|
||||
sessionID: sessionID,
|
||||
messageID: lastUser.id,
|
||||
})
|
||||
}
|
||||
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const msg of msgs) {
|
||||
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "text" || part.ignored || part.synthetic) continue
|
||||
if (!part.text.trim()) continue
|
||||
part.text = [
|
||||
"<system-reminder>",
|
||||
"The user sent the following message:",
|
||||
part.text,
|
||||
"",
|
||||
"Please address this message and continue with your tasks.",
|
||||
"</system-reminder>",
|
||||
].join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
|
||||
// Build system prompt, adding structured output instruction if needed
|
||||
const skills = await SystemPrompt.skills(agent)
|
||||
const system = [
|
||||
...(await SystemPrompt.environment(model)),
|
||||
...(skills ? [skills] : []),
|
||||
...(await InstructionPrompt.system()),
|
||||
]
|
||||
const format = lastUser.format ?? { type: "text" }
|
||||
if (format.type === "json_schema") {
|
||||
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
}
|
||||
const skills = await SystemPrompt.skills(agent)
|
||||
const system = [
|
||||
...(await SystemPrompt.environment(model)),
|
||||
...(skills ? [skills] : []),
|
||||
...(await InstructionPrompt.system()),
|
||||
]
|
||||
if (format.type === "json_schema") {
|
||||
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
}
|
||||
|
||||
const result = await processor.process({
|
||||
user: lastUser,
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system,
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(msgs, model),
|
||||
...(isLastStep
|
||||
? [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: MAX_STEPS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
return processor.process({
|
||||
user: lastUser,
|
||||
agent,
|
||||
abort,
|
||||
sessionID,
|
||||
system,
|
||||
messages: [
|
||||
...MessageV2.toModelMessages(msgs, model),
|
||||
...(isLastStep
|
||||
? [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: MAX_STEPS,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
})
|
||||
})().catch(async (err) => {
|
||||
if (typeof processor.message.time.completed !== "number") {
|
||||
await Session.updateMessage(done(processor.message)).catch((cause) => {
|
||||
log.error("failed to finalize assistant after prompt error", {
|
||||
cause,
|
||||
messageID: processor.message.id,
|
||||
sessionID,
|
||||
})
|
||||
})
|
||||
}
|
||||
throw err
|
||||
})
|
||||
|
||||
// If structured output was captured, save it and exit immediately
|
||||
|
|
@ -1697,7 +1707,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||
if (aborted) {
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
msg.time.completed = Date.now()
|
||||
done(msg)
|
||||
await Session.updateMessage(msg)
|
||||
if (part.state.status === "running") {
|
||||
part.state = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { done } from "../../src/session/assistant"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
|
||||
const sessionID = SessionID.make("session")
|
||||
const providerID = ProviderID.make("test")
|
||||
const modelID = ModelID.make("model")
|
||||
|
||||
const assistant = (completed?: number) =>
|
||||
({
|
||||
id: "msg_1",
|
||||
sessionID,
|
||||
parentID: "msg_0",
|
||||
role: "assistant",
|
||||
time: completed === undefined ? { created: 1 } : { created: 1, completed },
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/", root: "/" },
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
modelID,
|
||||
providerID,
|
||||
}) as MessageV2.Assistant
|
||||
|
||||
describe("session assistant", () => {
|
||||
test("marks incomplete assistants as done", () => {
|
||||
const msg = assistant()
|
||||
|
||||
expect(done(msg, 10).time.completed).toBe(10)
|
||||
})
|
||||
|
||||
test("preserves existing completion time", () => {
|
||||
const msg = assistant(5)
|
||||
|
||||
expect(done(msg, 10).time.completed).toBe(5)
|
||||
})
|
||||
})
|
||||
|
|
@ -195,9 +195,9 @@ export function SessionTurn(
|
|||
const pending = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
return messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant")
|
||||
if (!item || typeof item.time.completed === "number") return
|
||||
return item
|
||||
})
|
||||
|
||||
const pendingUser = createMemo(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue