mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-16 03:34:22 +00:00
Compare commits
2 Commits
dev
...
fix/stale-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bfce604bf | ||
|
|
060f482eb2 |
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
7
packages/opencode/src/session/assistant.ts
Normal file
7
packages/opencode/src/session/assistant.ts
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
44
packages/opencode/test/session/assistant.test.ts
Normal file
44
packages/opencode/test/session/assistant.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user