diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index f8e16f3e12..b4b1e5f8a2 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -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(() => {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 6d29170081..84e46b96d1 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -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(() => {
diff --git a/packages/app/src/pages/session/activity.test.ts b/packages/app/src/pages/session/activity.test.ts
new file mode 100644
index 0000000000..3c9bb11d7a
--- /dev/null
+++ b/packages/app/src/pages/session/activity.test.ts
@@ -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()
+ })
+})
diff --git a/packages/app/src/pages/session/activity.ts b/packages/app/src/pages/session/activity.ts
new file mode 100644
index 0000000000..3f66309f78
--- /dev/null
+++ b/packages/app/src/pages/session/activity.ts
@@ -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"
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index 74f2e8c2c1..7784215ea1 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -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()
diff --git a/packages/opencode/src/session/assistant.ts b/packages/opencode/src/session/assistant.ts
new file mode 100644
index 0000000000..586ec6a9b3
--- /dev/null
+++ b/packages/opencode/src/session/assistant.ts
@@ -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
+}
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 38dac41b05..387e96974e 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -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"
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 743537f598..d000793db0 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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 = [
- "",
- "The user sent the following message:",
- part.text,
- "",
- "Please address this message and continue with your tasks.",
- "",
- ].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 = [
+ "",
+ "The user sent the following message:",
+ part.text,
+ "",
+ "Please address this message and continue with your tasks.",
+ "",
+ ].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" + ["", "User aborted the command", ""].join("\n")
}
- msg.time.completed = Date.now()
+ done(msg)
await Session.updateMessage(msg)
if (part.state.status === "running") {
part.state = {
diff --git a/packages/opencode/test/session/assistant.test.ts b/packages/opencode/test/session/assistant.test.ts
new file mode 100644
index 0000000000..caa21db132
--- /dev/null
+++ b/packages/opencode/test/session/assistant.test.ts
@@ -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)
+ })
+})
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index 8c9c1ffe40..7ba9e84d58 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -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(() => {