Compare commits

...

2 Commits

Author SHA1 Message Date
Shoubhit Dash
6bfce604bf fix(session): ignore stale pending ui state
Inline the app-side running checks and make the tui only treat the trailing assistant as pending so old crashed messages do not keep sessions active or queued.
2026-03-15 18:00:33 +05:30
Shoubhit Dash
060f482eb2 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.
2026-03-15 17:39:44 +05:30
9 changed files with 162 additions and 113 deletions

View File

@@ -204,18 +204,8 @@ 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 status !== undefined && status.type !== "idle"
})
const tint = createMemo(() => {

View File

@@ -1361,10 +1361,8 @@ 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",
)
const status = sync.data.session_status[sessionID]
return status !== undefined && status.type !== "idle"
}
const queuedFollowups = createMemo(() => {

View File

@@ -236,17 +236,17 @@ 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(() => {
const item = sessionMessages().findLast((item): item is AssistantMessage => item.role === "assistant")
if (!item || typeof item.time.completed === "number") return
return item
})
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(() => sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({
@@ -264,7 +264,7 @@ export function MessageTimeline(props: {
onCleanup(clear)
createEffect(
on(
working,
busy,
(on, prev) => {
clear()
if (on) {
@@ -282,7 +282,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 +292,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()

View File

@@ -139,7 +139,9 @@ export function Session() {
})
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
const last = messages().findLast((x) => x.role === "assistant")
if (!last || last.time.completed) return
return last.id
})
const lastAssistant = createMemo(() => {

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

View File

@@ -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 = {

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(() => {
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(() => {