Compare commits

...

5 Commits

Author SHA1 Message Date
Aiden Cline 2ef6c88a0b
Merge branch 'dev' into fix/stale-running-session-ui 2026-03-19 22:20:12 -05:00
Shoubhit Dash c19cbccf06 merge origin/dev into fix/stale-running-session-ui 2026-03-16 21:02:22 +05:30
ismeth 5a7a86810a fix(app): use reconcile for session_status in bootstrap to clear stale entries
(cherry picked from commit 912ba2c57f)
2026-03-16 20:58:02 +05:30
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
10 changed files with 164 additions and 115 deletions

View File

@ -151,7 +151,7 @@ export async function bootstrapDirectory(input: {
Promise.all([ Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", x.data!)), input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), input.sdk.session.status().then((x) => input.setStore("session_status", reconcile(x.data!))),
input.loadSessions(input.directory), input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),

View File

@ -204,18 +204,8 @@ 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(
(message) =>
message.role === "assistant" &&
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
)
const status = sessionStore.session_status[props.session.id] const status = sessionStore.session_status[props.session.id]
return ( return status !== undefined && status.type !== "idle"
pending !== undefined ||
status?.type === "busy" ||
status?.type === "retry" ||
(status !== undefined && status.type !== "idle")
)
}) })
const tint = createMemo(() => { const tint = createMemo(() => {

View File

@ -1361,10 +1361,8 @@ 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 const status = sync.data.session_status[sessionID]
return (sync.data.message[sessionID] ?? []).some( return status !== undefined && status.type !== "idle"
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
)
} }
const queuedFollowups = createMemo(() => { const queuedFollowups = createMemo(() => {

View File

@ -236,17 +236,17 @@ 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(() => {
sessionMessages().findLast( const item = sessionMessages().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 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(() => sessionStatus().type !== "idle")
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 +264,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 +282,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 +292,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

@ -139,7 +139,9 @@ export function Session() {
}) })
const pending = createMemo(() => { 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(() => { 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 { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission"
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,91 +600,100 @@ 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,
permission: session.permission, permission: session.permission,
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
@ -1723,7 +1733,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(() => {