Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export async function bootstrapDirectory(input: {
Promise.all([
input.sdk.path.get().then((x) => input.setStore("path", 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.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
Expand Down
12 changes: 1 addition & 11 deletions packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
6 changes: 2 additions & 4 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
29 changes: 13 additions & 16 deletions packages/app/src/pages/session/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -264,7 +264,7 @@ export function MessageTimeline(props: {
onCleanup(clear)
createEffect(
on(
working,
busy,
(on, prev) => {
clear()
if (on) {
Expand All @@ -282,23 +282,20 @@ 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)
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
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()
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 141 to +144
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can u explain this?

})

const lastAssistant = createMemo(() => {
Expand Down
7 changes: 7 additions & 0 deletions packages/opencode/src/session/assistant.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission"
import { Question } from "@/question"
import { done } from "./assistant"
import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema"

Expand Down Expand Up @@ -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"
Expand Down
166 changes: 88 additions & 78 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -599,91 +600,100 @@ 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 tools = await resolveTools({
agent,
session,
model,
tools: lastUser.tools,
processor,
bypassAgentCheck,
messages: msgs,
})
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

// 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,
permission: session.permission,
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,
permission: session.permission,
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
Expand Down Expand Up @@ -1723,7 +1733,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 = {
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/test/session/assistant.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading