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