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
4 changes: 2 additions & 2 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,8 +634,8 @@ export namespace ACP {
return undefined
})

const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
if (lastUser?.role === "user") {
const lastUser = messages?.findLast((m) => MessageV2.isRealUserMessage(m))?.info
if (lastUser) {
result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
this.sessionManager.setModel(sessionId, {
providerID: ProviderID.make(lastUser.model.providerID),
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/effect/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ export const make = Effect.gen(function* () {
evaluate: () => proc.stdin!,
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
endOnDone: cfg.endOnDone,
encoding: cfg.encoding,
encoding: cfg.encoding === "utf-16le" ? "utf16le" : cfg.encoding,
})
}
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,9 +526,9 @@ export const SessionRoutes = lazy(() =>
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || (await Agent.defaultAgent())
const msg = msgs[i]
if (MessageV2.isRealUserMessage(msg)) {
currentAgent = msg.info.agent || (await Agent.defaultAgent())
break
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export namespace SessionCompaction {

loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (MessageV2.isRealUserMessage(msg)) turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
Expand Down Expand Up @@ -120,14 +120,14 @@ export namespace SessionCompaction {
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
for (let i = idx - 1; i >= 0; i--) {
const msg = input.messages[i]
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
if (MessageV2.isRealUserMessage(msg) && !msg.parts.some((p) => p.type === "compaction")) {
replay = msg
messages = input.messages.slice(0, i)
break
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
replay && messages.some((m) => MessageV2.isRealUserMessage(m) && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ export namespace MessageV2 {
return mime.startsWith("image/") || mime === "application/pdf"
}

export function isRealUserMessage(msg: MessageV2.WithParts): msg is MessageV2.WithParts & { info: MessageV2.User }
export function isRealUserMessage<T extends { info: { role: string }; parts: readonly unknown[] }>(
msg: T,
): msg is T & { info: T["info"] & { role: "user" } }
export function isRealUserMessage(msg: { info: { role: string }; parts: readonly unknown[] }) {
return (
msg.info.role === "user" &&
msg.parts.some((part) => {
if (typeof part !== "object" || part === null || !("type" in part)) return false
if (part.type === "compaction" || part.type === "subtask") return false
if ("synthetic" in part && part.synthetic) return false
return true
})
)
}

export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
export const StructuredOutputError = NamedError.create(
Expand Down
20 changes: 8 additions & 12 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,9 @@ export namespace SessionPrompt {
let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = []
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i]
if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User
if (!lastUser && MessageV2.isRealUserMessage(msg)) lastUser = msg.info as MessageV2.User
if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish)
lastFinished = msg.info as MessageV2.Assistant
if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant
if (lastUser && lastFinished) break
const task = msg.parts.filter((part) => part.type === "compaction" || part.type === "subtask")
if (task && !lastFinished) {
Expand Down Expand Up @@ -621,7 +620,7 @@ 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 lastUserMsg = msgs.findLast((m) => MessageV2.isRealUserMessage(m))
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false

const tools = await resolveTools({
Expand Down Expand Up @@ -654,7 +653,7 @@ export namespace SessionPrompt {
// 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
if (!MessageV2.isRealUserMessage(msg) || 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
Expand Down Expand Up @@ -757,7 +756,7 @@ export namespace SessionPrompt {

async function lastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
if (MessageV2.isRealUserMessage(item) && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
Expand Down Expand Up @@ -1387,7 +1386,7 @@ export namespace SessionPrompt {
}

async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
const userMessage = input.messages.findLast((msg) => MessageV2.isRealUserMessage(msg))
if (!userMessage) return input.messages

// Original logic when experimental plan mode is disabled
Expand Down Expand Up @@ -1982,14 +1981,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!Session.isDefaultTitle(input.session.title)) return

// Find first non-synthetic user message
const firstRealUserIdx = input.history.findIndex(
(m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic),
)
const firstRealUserIdx = input.history.findIndex((m) => MessageV2.isRealUserMessage(m))
if (firstRealUserIdx === -1) return

const isFirst =
input.history.filter((m) => m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic))
.length === 1
input.history.filter((m) => MessageV2.isRealUserMessage(m)).length === 1
if (!isFirst) return

// Gather all messages up to and including the first real user message for context
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/revert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export namespace SessionRevert {
let revert: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
if (MessageV2.isRealUserMessage(msg)) lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (revert) {
Expand Down
18 changes: 9 additions & 9 deletions packages/opencode/src/share/share-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,6 @@ export namespace ShareNext {
data: evt.properties.info,
},
])
if (info.role === "user") {
await sync(info.sessionID, [
{
type: "model",
data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
},
])
}
})
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
await sync(evt.properties.part.sessionID, [
Expand All @@ -99,6 +91,14 @@ export namespace ShareNext {
data: evt.properties.part,
},
])
const msg = await MessageV2.get({ sessionID: evt.properties.part.sessionID, messageID: evt.properties.part.messageID })
if (!MessageV2.isRealUserMessage(msg)) return
await sync(evt.properties.part.sessionID, [
{
type: "model",
data: [await Provider.getModel(msg.info.model.providerID, msg.info.model.modelID).then((m) => m)],
},
])
})
Bus.subscribe(Session.Event.Diff, async (evt) => {
await sync(evt.properties.sessionID, [
Expand Down Expand Up @@ -258,7 +258,7 @@ export namespace ShareNext {
Array.from(
new Map(
messages
.filter((m) => m.info.role === "user")
.filter((m) => MessageV2.isRealUserMessage(m))
.map((m) => (m.info as SDK.UserMessage).model)
.map((m) => [`${m.providerID}/${m.modelID}`, m] as const),
).values(),
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/storage/node-sqlite.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
declare module "node:sqlite" {
export class DatabaseSync {
constructor(path: string)
}
}
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import EXIT_DESCRIPTION from "./plan-exit.txt"

async function getLastModel(sessionID: SessionID) {
for await (const item of MessageV2.stream(sessionID)) {
if (item.info.role === "user" && item.info.model) return item.info.model
if (MessageV2.isRealUserMessage(item) && item.info.model) return item.info.model
}
return Provider.defaultModel()
}
Expand Down
72 changes: 72 additions & 0 deletions packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,78 @@ function basePart(messageID: string, id: string) {
}

describe("session.message-v2.toModelMessage", () => {
test("detects real user messages", () => {
const realUser: MessageV2.WithParts = {
info: userInfo("m-real"),
parts: [
{
...basePart("m-real", "p-real"),
type: "text",
text: "hello",
} as MessageV2.Part,
],
}

const syntheticUser: MessageV2.WithParts = {
info: userInfo("m-synth"),
parts: [
{
...basePart("m-synth", "p-synth"),
type: "text",
text: "synthetic",
synthetic: true,
} as MessageV2.Part,
],
}

const emptyUser: MessageV2.WithParts = {
info: userInfo("m-empty"),
parts: [],
}

const compactionUser: MessageV2.WithParts = {
info: userInfo("m-compaction"),
parts: [
{
...basePart("m-compaction", "p-compaction"),
type: "compaction",
auto: true,
} as MessageV2.Part,
],
}

const subtaskUser: MessageV2.WithParts = {
info: userInfo("m-subtask"),
parts: [
{
...basePart("m-subtask", "p-subtask"),
type: "subtask",
agent: "general",
description: "run task",
prompt: "do thing",
} as MessageV2.Part,
],
}

const assistant: MessageV2.WithParts = {
info: assistantInfo("m-assistant", "m-real"),
parts: [
{
...basePart("m-assistant", "p-assistant"),
type: "text",
text: "assistant",
} as MessageV2.Part,
],
}

expect(MessageV2.isRealUserMessage(realUser)).toBe(true)
expect(MessageV2.isRealUserMessage(syntheticUser)).toBe(false)
expect(MessageV2.isRealUserMessage(emptyUser)).toBe(false)
expect(MessageV2.isRealUserMessage(compactionUser)).toBe(false)
expect(MessageV2.isRealUserMessage(subtaskUser)).toBe(false)
expect(MessageV2.isRealUserMessage(assistant)).toBe(false)
})

test("filters out messages with no parts", () => {
const input: MessageV2.WithParts[] = [
{
Expand Down
Loading
Loading