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.
This commit is contained in:
Shoubhit Dash 2026-03-15 17:39:44 +05:30
commit 060f482eb2
10 changed files with 214 additions and 114 deletions

View file

@ -15,6 +15,7 @@ import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { working } from "@/pages/session/activity"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "../session/composer/session-request-tree" import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { hasProjectPermissions } from "./helpers" import { hasProjectPermissions } from "./helpers"
@ -204,18 +205,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}) })
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (hasPermissions()) return false if (hasPermissions()) return false
const pending = (sessionStore.message[props.session.id] ?? []).findLast( return working(sessionStore.session_status[props.session.id])
(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")
)
}) })
const tint = createMemo(() => { const tint = createMemo(() => {

View file

@ -39,6 +39,7 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { working } from "@/pages/session/activity"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
@ -1361,10 +1362,7 @@ export default function Page() {
}) })
const busy = (sessionID: string) => { const busy = (sessionID: string) => {
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true return working(sync.data.session_status[sessionID])
return (sync.data.message[sessionID] ?? []).some(
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
} }
const queuedFollowups = createMemo(() => { const queuedFollowups = createMemo(() => {

View file

@ -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()
})
})

View file

@ -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"

View file

@ -12,7 +12,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { TextField } from "@opencode-ai/ui/text-field" 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 { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@ -27,6 +27,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { pending, working } from "@/pages/session/activity"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
@ -236,17 +237,13 @@ export function MessageTimeline(props: {
if (!id) return emptyMessages if (!id) return emptyMessages
return sync.data.message[id] ?? emptyMessages return sync.data.message[id] ?? emptyMessages
}) })
const pending = createMemo(() => const assistant = createMemo(() => pending(sessionMessages()))
sessionMessages().findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => { const sessionStatus = createMemo(() => {
const id = sessionID() const id = sessionID()
if (!id) return idle if (!id) return idle
return sync.data.session_status[id] ?? 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 tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({ const [slot, setSlot] = createStore({
@ -264,7 +261,7 @@ export function MessageTimeline(props: {
onCleanup(clear) onCleanup(clear)
createEffect( createEffect(
on( on(
working, busy,
(on, prev) => { (on, prev) => {
clear() clear()
if (on) { if (on) {
@ -282,7 +279,9 @@ export function MessageTimeline(props: {
), ),
) )
const activeMessageID = createMemo(() => { const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID if (!busy()) return undefined
const parentID = assistant()?.parentID
if (parentID) { if (parentID) {
const messages = sessionMessages() const messages = sessionMessages()
const result = Binary.search(messages, parentID, (message) => message.id) 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 if (message && message.role === "user") return message.id
} }
const status = sessionStatus() const messages = sessionMessages()
if (status.type !== "idle") { for (let i = messages.length - 1; i >= 0; i--) {
const messages = sessionMessages() if (messages[i].role === "user") return messages[i].id
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].id
}
} }
return undefined
}) })
const info = createMemo(() => { const info = createMemo(() => {
const id = sessionID() const id = sessionID()

View file

@ -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
}

View file

@ -14,6 +14,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next" import { PermissionNext } from "@/permission/next"
import { Question } from "@/question" import { Question } from "@/question"
import { done } from "./assistant"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID, MessageID } 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) await Session.updateMessage(input.assistantMessage)
if (needsCompaction) return "compact" if (needsCompaction) return "compact"
if (blocked) return "stop" if (blocked) return "stop"

View file

@ -4,6 +4,7 @@ import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { done } from "./assistant"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Log } from "../util/log" import { Log } from "../util/log"
import { SessionRevert } from "./revert" import { SessionRevert } from "./revert"
@ -465,7 +466,7 @@ export namespace SessionPrompt {
result, result,
) )
assistantMessage.finish = "tool-calls" assistantMessage.finish = "tool-calls"
assistantMessage.time.completed = Date.now() done(assistantMessage)
await Session.updateMessage(assistantMessage) await Session.updateMessage(assistantMessage)
if (result && part.state.status === "running") { if (result && part.state.status === "running") {
await Session.updatePart({ await Session.updatePart({
@ -599,90 +600,99 @@ export namespace SessionPrompt {
}) })
using _ = defer(() => InstructionPrompt.clear(processor.message.id)) using _ = defer(() => InstructionPrompt.clear(processor.message.id))
// Check if user explicitly invoked an agent via @ in this turn const format = lastUser.format ?? { type: "text" }
const lastUserMsg = msgs.findLast((m) => m.info.role === "user") const result = await (async () => {
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
const tools = await resolveTools({ const tools = await resolveTools({
agent, agent,
session, session,
model, model,
tools: lastUser.tools, tools: lastUser.tools,
processor, processor,
bypassAgentCheck, bypassAgentCheck,
messages: msgs, 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
},
}) })
}
if (step === 1) { if (format.type === "json_schema") {
SessionSummary.summarize({ tools["StructuredOutput"] = createStructuredOutputTool({
sessionID: sessionID, schema: format.schema,
messageID: lastUser.id, onSuccess(output) {
}) structuredOutput = output
} },
})
}
// Ephemerally wrap queued user messages with a reminder to stay on track if (step === 1) {
if (step > 1 && lastFinished) { SessionSummary.summarize({
for (const msg of msgs) { sessionID: sessionID,
if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue messageID: lastUser.id,
for (const part of msg.parts) { })
if (part.type !== "text" || part.ignored || part.synthetic) continue }
if (!part.text.trim()) continue
part.text = [ if (step > 1 && lastFinished) {
"<system-reminder>", for (const msg of msgs) {
"The user sent the following message:", if (msg.info.role !== "user" || msg.info.id <= lastFinished.id) continue
part.text, for (const part of msg.parts) {
"", if (part.type !== "text" || part.ignored || part.synthetic) continue
"Please address this message and continue with your tasks.", if (!part.text.trim()) continue
"</system-reminder>", part.text = [
].join("\n") "<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 skills = await SystemPrompt.skills(agent) const system = [
const system = [ ...(await SystemPrompt.environment(model)),
...(await SystemPrompt.environment(model)), ...(skills ? [skills] : []),
...(skills ? [skills] : []), ...(await InstructionPrompt.system()),
...(await InstructionPrompt.system()), ]
] if (format.type === "json_schema") {
const format = lastUser.format ?? { type: "text" } system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
if (format.type === "json_schema") { }
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
}
const result = await processor.process({ return processor.process({
user: lastUser, user: lastUser,
agent, agent,
abort, abort,
sessionID, sessionID,
system, system,
messages: [ messages: [
...MessageV2.toModelMessages(msgs, model), ...MessageV2.toModelMessages(msgs, model),
...(isLastStep ...(isLastStep
? [ ? [
{ {
role: "assistant" as const, role: "assistant" as const,
content: MAX_STEPS, content: MAX_STEPS,
}, },
] ]
: []), : []),
], ],
tools, tools,
model, model,
toolChoice: format.type === "json_schema" ? "required" : undefined, 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 // 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) { if (aborted) {
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n") output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
} }
msg.time.completed = Date.now() done(msg)
await Session.updateMessage(msg) await Session.updateMessage(msg)
if (part.state.status === "running") { if (part.state.status === "running") {
part.state = { part.state = {

View file

@ -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)
})
})

View file

@ -195,9 +195,9 @@ export function SessionTurn(
const pending = createMemo(() => { const pending = createMemo(() => {
if (typeof props.active === "boolean") return if (typeof props.active === "boolean") return
const messages = allMessages() ?? emptyMessages const messages = allMessages() ?? emptyMessages
return messages.findLast( const item = messages.findLast((item): item is AssistantMessage => item.role === "assistant")
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", if (!item || typeof item.time.completed === "number") return
) return item
}) })
const pendingUser = createMemo(() => { const pendingUser = createMemo(() => {