Skip to content
Merged
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
12 changes: 9 additions & 3 deletions documentor/FORK.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/core/src/v1/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export {
ToolStatePending,
ToolStateRunning,
User,
UserAuthor,
WithParts,
} from "@opencode-ai/schema/session-v1"

Expand Down
35 changes: 23 additions & 12 deletions packages/opencode/src/acp/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,20 @@ type GlobalEventStream = {
* `_meta.opencode` on every content and tool update: the assistant message the
* update belongs to, and the user message that assistant answers. A client that
* sends a prompt while a turn runs reads from it the moment the agent starts
* answering that prompt.
* answering that prompt. On a replayed user message `messageId` is that user
* message and `parentId` is `null`; it also names its author, carries
* `answerable: false` when it is context no turn answers, and carries its text
* part's metadata.
*/
export type OpencodeUpdateMeta = { opencode: { messageId: string; parentId: string | null } }
export type OpencodeUpdateMeta = {
opencode: {
messageId: string
parentId: string | null
author?: { id: string; name: string }
answerable?: boolean
partMetadata?: Record<string, unknown>
}
}

export function start(input: { sdk: OpencodeClient; connection: Connection; session: ACPSession.Interface }) {
const subscription = new Subscription(input)
Expand Down Expand Up @@ -144,6 +155,7 @@ export class Subscription {
: message.info.role === "user"
? "user_message_chunk"
: "agent_message_chunk"
const user = message.info.role === "user" ? message.info : undefined

for (const chunk of partsToContentChunks([part as ReplayPart])) {
await this.input.connection.sessionUpdate({
Expand All @@ -152,16 +164,15 @@ export class Subscription {
sessionUpdate,
messageId: part.type === "reasoning" ? part.id : message.info.id,
...chunk,
...(sessionUpdate === "user_message_chunk"
? {}
: {
_meta: {
opencode: {
messageId: message.info.id,
parentId: message.info.role === "assistant" ? (message.info.parentID ?? null) : null,
},
} satisfies OpencodeUpdateMeta,
}),
_meta: {
opencode: {
messageId: message.info.id,
parentId: message.info.role === "assistant" ? (message.info.parentID ?? null) : null,
...(user?.author ? { author: user.author } : {}),
...(user?.answerable === false ? { answerable: false } : {}),
...(user && part.type === "text" && part.metadata ? { partMetadata: part.metadata } : {}),
},
} satisfies OpencodeUpdateMeta,
},
})
}
Expand Down
98 changes: 77 additions & 21 deletions packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export function make(input: {
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
const documentor = documentorMeta(params._meta)
const created = yield* profiledRequest(
"acp.newSession.session.create",
() =>
Expand All @@ -180,6 +181,7 @@ export function make(input: {
id: selected.modelID,
...(variant ? { variant } : {}),
},
...(documentor.metadata ? { metadata: documentor.metadata } : {}),
},
{ throwOnError: true },
),
Expand Down Expand Up @@ -520,29 +522,40 @@ export function make(input: {
const parts = promptContentToParts(params.prompt)
const command = detectSlashCommand(parts)
const messageID = parts.length > 0 ? opencodeMessageID(params.messageId) : undefined
const documentor = documentorMeta(params._meta)
const silent = documentor.noReply === true || documentor.answerable === false

if (!command) {
const response = yield* request(
() =>
runUntilIdle(current.id, () =>
input.sdk.session.prompt(
{
sessionID: current.id,
...(messageID ? { messageID } : {}),
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
),
"session",
)
const send = () =>
input.sdk.session.prompt(
{
sessionID: current.id,
...(messageID ? { messageID } : {}),
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts: withPartMetadata(parts, documentor.partMetadata),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
...(documentor.author ? { author: documentor.author } : {}),
...(documentor.answerable === false ? { answerable: false } : {}),
...(documentor.noReply ? { noReply: true } : {}),
...(documentor.system ? { system: documentor.system } : {}),
},
{ throwOnError: true },
)
if (silent) {
// No loop runs, so no idle event follows; the stored user message is the whole result.
yield* request(send, "session")
return {
stopReason: "end_turn" as const,
...(params.messageId ? { userMessageId: params.messageId } : {}),
_meta: {},
}
}
const response = yield* request(() => runUntilIdle(current.id, send), "session")
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return yield* promptResponse(response.info, params.messageId)
}
Expand Down Expand Up @@ -859,6 +872,49 @@ function opencodeMessageID(messageId: string | null | undefined) {
return typeof messageId === "string" && messageId.startsWith("msg") ? messageId : undefined
}

// DocuMentor's additions to a prompt and to a new session travel in ACP's `_meta`, under one key, so that
// another client's `_meta` is never read as ours. Each field is taken only when it has the expected shape.
export type DocumentorMeta = {
author?: { id: string; name: string }
answerable?: false
noReply?: true
system?: string
/** Becomes the `metadata` of the prompt's first text part (DocuMentor keeps a message's mentions there). */
partMetadata?: Record<string, unknown>
metadata?: Record<string, unknown>
}

export function documentorMeta(meta: unknown): DocumentorMeta {
const root = isRecord(meta) ? meta["documentor"] : undefined
if (!isRecord(root)) return {}
const out: DocumentorMeta = {}
const author = root["author"]
if (isRecord(author) && typeof author["id"] === "string" && typeof author["name"] === "string") {
out.author = { id: author["id"], name: author["name"] }
}
if (root["answerable"] === false) out.answerable = false
if (root["noReply"] === true) out.noReply = true
if (typeof root["system"] === "string" && root["system"] !== "") out.system = root["system"]
if (isRecord(root["partMetadata"])) out.partMetadata = root["partMetadata"]
if (isRecord(root["metadata"])) out.metadata = root["metadata"]
return out
}

/** The prompt's parts with `partMetadata` on the first text part; the input is not changed. */
export function withPartMetadata<T extends { type: string }>(
parts: T[],
partMetadata: Record<string, unknown> | undefined,
): T[] {
if (!partMetadata) return parts
const index = parts.findIndex((part) => part.type === "text")
if (index === -1) return parts
return parts.map((part, i) => (i === index ? { ...part, metadata: partMetadata } : part))
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

const promptResponse = Effect.fn("ACP.promptResponse")(function* (
info: AssistantInfo,
messageId: string | null | undefined,
Expand Down
15 changes: 11 additions & 4 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type CompletedCompaction = {
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`

const serialize = (message: SessionV1.WithParts) => {
const serialize = (message: SessionV1.WithParts, attributeAuthors: boolean) => {
if (message.info.role === "user") {
const text = message.parts
.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
Expand All @@ -61,7 +61,8 @@ const serialize = (message: SessionV1.WithParts) => {
const files = message.parts.flatMap((part) =>
part.type === "file" ? [`[Attached ${part.mime}: ${part.filename ?? "file"}]`] : [],
)
return [...(text ? [`[User]: ${text}`] : []), ...files].join("\n")
const prefix = attributeAuthors ? MessageV2.authorPrefix(message.info.author) : ""
return [...(text ? [`[User]: ${prefix}${text}`] : []), ...files].join("\n")
}
return message.parts
.flatMap((part) => {
Expand Down Expand Up @@ -124,6 +125,7 @@ function turns(messages: SessionV1.WithParts[]) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.info.role !== "user") continue
if (msg.info.answerable === false) continue
if (msg.parts.some((part) => part.type === "compaction")) continue
result.push({
start: i,
Expand Down Expand Up @@ -287,7 +289,7 @@ const layer = Layer.effect(

loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (msg.info.role === "user" && msg.info.answerable !== false) 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 @@ -328,6 +330,8 @@ const layer = Layer.effect(
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
}
const userMessage = parent.info
const sessionInfo = yield* session.get(input.sessionID).pipe(Effect.orDie)
const attributeAuthors = MessageV2.isDiscussion(sessionInfo.metadata)
const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction")

let messages = input.messages
Expand Down Expand Up @@ -377,7 +381,10 @@ const layer = Layer.effect(
)
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
const conversation = msgs
.map((m) => serialize(m, attributeAuthors))
.filter(Boolean)
.join("\n\n")
const nextPrompt =
compacting.prompt ??
[
Expand Down
30 changes: 25 additions & 5 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,24 @@ function providerMeta(metadata: Record<string, any> | undefined) {
return Object.keys(rest).length > 0 ? rest : undefined
}

/** `"Name: "`, the name flattened to one line so a name cannot pose as a second speaker. */
export function authorPrefix(author: SessionV1.UserAuthor | undefined): string {
const name = author?.name.replace(/\s+/g, " ").trim() ?? ""
return name ? `${name}: ` : ""
}

/** A discussion is a session DocuMentor marks so; its model input carries who said what. */
export function isDiscussion(metadata: Record<string, unknown> | undefined): boolean {
const documentor = metadata?.["documentor"]
return (
typeof documentor === "object" && documentor !== null && (documentor as { kind?: unknown }).kind === "discussion"
)
}

export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; attributeAuthors?: boolean },
) {
const result: UIMessage[] = []
const toolNames = new Set<string>()
Expand Down Expand Up @@ -205,13 +219,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
role: "user",
parts: [],
}
const prefix = options?.attributeAuthors ? authorPrefix(msg.info.author) : ""
let attributed = false
for (const part of msg.parts) {
// User message parts should never be empty
if (part.type === "text" && !part.ignored && part.text !== "")
if (part.type === "text" && !part.ignored && part.text !== "") {
const own = prefix !== "" && !attributed && !part.synthetic
if (own) attributed = true
userMessage.parts.push({
type: "text",
text: part.text,
text: own ? `${prefix}${part.text}` : part.text,
})
}
// text/plain and directory files are converted into text parts, ignore them
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
if (options?.stripMedia && isMedia(part.mime)) {
Expand Down Expand Up @@ -421,7 +440,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
export function toModelMessages(
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number },
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; attributeAuthors?: boolean },
): Promise<ModelMessage[]> {
return Effect.runPromise(toModelMessagesEffect(input, model, options))
}
Expand Down Expand Up @@ -589,7 +608,8 @@ export function latest(msgs: WithParts[]) {
let finished: Assistant | undefined
for (const msg of msgs) {
const info = msg.info
if (info.role === "user" && isAfter(info, user)) user = info
// `user` is the message a turn answers; one marked unanswerable is context, never that.
if (info.role === "user" && info.answerable !== false && isAfter(info, user)) user = info
if (info.role === "assistant" && isAfter(info, assistant)) assistant = info
if (info.role === "assistant" && info.finish && isAfter(info, finished)) finished = info
}
Expand Down
26 changes: 22 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,8 @@ const layer = Layer.effect(
},
system: input.system,
format: input.format,
...(input.author ? { author: input.author } : {}),
...(input.answerable === false ? { answerable: false } : {}),
}

const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
Expand Down Expand Up @@ -1053,7 +1055,12 @@ const layer = Layer.effect(
)(function* (input: PromptInput) {
const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
yield* revert.cleanup(session)
if (input.parts.length === 0 && input.noReply !== true && (yield* hasUserMessage(input.sessionID))) {
if (
input.parts.length === 0 &&
input.noReply !== true &&
input.answerable !== false &&
(yield* hasUserMessage(input.sessionID))
) {
// A prompt with no parts resumes the session instead of storing an empty user message. The loop runs over
// the messages already stored, so user messages that arrived during a cancelled step are answered, and a
// session with nothing left to answer returns its last assistant without calling the model.
Expand All @@ -1071,7 +1078,8 @@ const layer = Layer.effect(
yield* sessions.setPermission({ sessionID: session.id, permission: permissions })
}

if (input.noReply === true) return message
// An unanswerable message is context for a later turn, never the start of one.
if (input.noReply === true || input.answerable === false) return message
return yield* loop({ sessionID: input.sessionID })
})

Expand Down Expand Up @@ -1105,7 +1113,13 @@ const layer = Layer.effect(

const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = MessageV2.latest(msgs)

if (!lastUser) throw new Error("No user message found in stream. This should never happen.")
if (!lastUser) {
// Nothing to answer: every user message is unanswerable (or there is none). Leave
// through the normal exit, which returns the newest assistant message (or the newest
// message) without a step; the runner sets the session idle when the loop returns.
yield* Effect.logInfo("exiting loop, nothing answerable", { "session.id": sessionID })
break
}

const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
Expand Down Expand Up @@ -1269,7 +1283,9 @@ const layer = Layer.effect(
sys.environment(model),
instruction.system().pipe(Effect.orDie),
sys.mcp(agent, session.permission),
MessageV2.toModelMessagesEffect(msgs, model),
MessageV2.toModelMessagesEffect(msgs, model, {
attributeAuthors: MessageV2.isDiscussion(session.metadata),
}),
])
const system = [
...env,
Expand Down Expand Up @@ -1512,6 +1528,8 @@ export const PromptInput = Schema.Struct({
model: Schema.optional(ModelRef),
agent: Schema.optional(Schema.String),
noReply: Schema.optional(Schema.Boolean),
author: Schema.optional(SessionV1.UserAuthor),
answerable: Schema.optional(Schema.Boolean),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description:
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
Expand Down
Loading
Loading