Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton a83e989ffa feat: unwrap session namespaces to flat exports + barrel 2026-04-15 23:14:55 -04:00
54 changed files with 5025 additions and 5039 deletions
+2 -2
View File
@@ -42,9 +42,9 @@ import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent" import { Agent as AgentModule } from "../agent/agent"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session"
import { Config } from "@/config" import { Config } from "@/config"
import { Todo } from "@/session/todo" import { Todo } from "@/session"
import { z } from "zod" import { z } from "zod"
import { LoadAPIKeyError } from "ai" import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
+1 -1
View File
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Agent } from "../../../agent/agent" import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider" import { Provider } from "../../../provider"
import { Session } from "../../../session" import { Session } from "../../../session"
import type { MessageV2 } from "../../../session/message-v2" import type { MessageV2 } from "../../../session"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { Session } from "../../session" import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session"
import { SessionID } from "../../session/schema" import { SessionID } from "../../session/schema"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
+2 -2
View File
@@ -27,8 +27,8 @@ import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema"
import { Provider } from "../../provider" import { Provider } from "../../provider"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session"
import { AppRuntime } from "@/effect/app-runtime" import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { Session } from "../../session" import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { Database } from "../../storage/db" import { Database } from "../../storage/db"
@@ -85,7 +85,7 @@ import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { DialogGoUpsell } from "../../component/dialog-go-upsell" import { DialogGoUpsell } from "../../component/dialog-go-upsell"
import { SessionRetry } from "@/session/retry" import { SessionRetry } from "@/session"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
+10 -10
View File
@@ -22,17 +22,17 @@ import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery" import { Discovery } from "@/skill/discovery"
import { Question } from "@/question" import { Question } from "@/question"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Todo } from "@/session/todo" import { Todo } from "@/session"
import { Session } from "@/session" import { Session } from "@/session"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session"
import { SessionRunState } from "@/session/run-state" import { SessionRunState } from "@/session"
import { SessionProcessor } from "@/session/processor" import { SessionProcessor } from "@/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session"
import { SessionRevert } from "@/session/revert" import { SessionRevert } from "@/session"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session"
import { Instruction } from "@/session/instruction" import { Instruction } from "@/session"
import { LLM } from "@/session/llm" import { LLM } from "@/session"
import { LSP } from "@/lsp" import { LSP } from "@/lsp"
import { MCP } from "@/mcp" import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth" import { McpAuth } from "@/mcp/auth"
@@ -5,7 +5,7 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -4,15 +4,15 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { SessionID, MessageID, PartID } from "@/session/schema" import { SessionID, MessageID, PartID } from "@/session/schema"
import z from "zod" import z from "zod"
import { Session } from "../../session" import { Session } from "../../session"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session"
import { SessionPrompt } from "../../session/prompt" import { SessionPrompt } from "../../session"
import { SessionRunState } from "@/session/run-state" import { SessionRunState } from "@/session"
import { SessionCompaction } from "../../session/compaction" import { SessionCompaction } from "../../session"
import { SessionRevert } from "../../session/revert" import { SessionRevert } from "../../session"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session"
import { SessionSummary } from "@/session/summary" import { SessionSummary } from "@/session"
import { Todo } from "../../session/todo" import { Todo } from "../../session"
import { Effect } from "effect" import { Effect } from "effect"
import { AppRuntime } from "../../effect/app-runtime" import { AppRuntime } from "../../effect/app-runtime"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
+333 -335
View File
@@ -3,11 +3,11 @@ import { Bus } from "@/bus"
import { Session } from "." import { Session } from "."
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "../provider" import { Provider } from "../provider"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import z from "zod" import z from "zod"
import { Token } from "../util/token" import { Token } from "../util/token"
import { Log } from "../util/log" import { Log } from "../util/log"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "."
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Config } from "@/config" import { Config } from "@/config"
@@ -17,173 +17,172 @@ import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { isOverflow as overflow } from "./overflow" import { isOverflow as overflow } from "./overflow"
export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" })
const log = Log.create({ service: "session.compaction" })
export const Event = { export const Event = {
Compacted: BusEvent.define( Compacted: BusEvent.define(
"session.compacted", "session.compacted",
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
}), }),
), ),
} }
export const PRUNE_MINIMUM = 20_000 export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000 export const PRUNE_PROTECT = 40_000
const PRUNE_PROTECTED_TOOLS = ["skill"] const PRUNE_PROTECTED_TOOLS = ["skill"]
export interface Interface { export interface Interface {
readonly isOverflow: (input: { readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
}) => Effect.Effect<"continue" | "stop">
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const layer: Layer.Layer<
Service,
never,
| Bus.Service
| Config.Service
| Session.Service
| Agent.Service
| Plugin.Service
| SessionProcessor.Service
| Provider.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const processors = yield* SessionProcessor.Service
const provider = yield* Provider.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"] tokens: MessageV2.Assistant["tokens"]
model: Provider.Model model: Provider.Model
}) => Effect.Effect<boolean> }) {
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void> return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
readonly process: (input: { })
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") 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--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
})
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID parentID: MessageID
messages: MessageV2.WithParts[] messages: MessageV2.WithParts[]
sessionID: SessionID sessionID: SessionID
auto: boolean auto: boolean
overflow?: boolean overflow?: boolean
}) => Effect.Effect<"continue" | "stop"> }) {
readonly create: (input: { const parent = input.messages.findLast((m) => m.info.id === input.parentID)
sessionID: SessionID if (!parent || parent.info.role !== "user") {
agent: string throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
model: { providerID: ProviderID; modelID: ModelID } }
auto: boolean const userMessage = parent.info
overflow?: boolean
}) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {} let messages = input.messages
let replay:
export const layer: Layer.Layer< | {
Service, info: MessageV2.User
never, parts: MessageV2.Part[]
| Bus.Service }
| Config.Service | undefined
| Session.Service if (input.overflow) {
| Agent.Service const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
| Plugin.Service for (let i = idx - 1; i >= 0; i--) {
| SessionProcessor.Service const msg = input.messages[i]
| Provider.Service if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
> = Layer.effect( replay = { info: msg.info, parts: msg.parts }
Service, messages = input.messages.slice(0, i)
Effect.gen(function* () { break
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
const plugin = yield* Plugin.Service
const processors = yield* SessionProcessor.Service
const provider = yield* Provider.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"]
model: Provider.Model
}) {
return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model })
})
// goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool
// calls, then erases output of older tool calls to free context space
const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) {
const cfg = yield* config.get()
if (cfg.compaction?.prune === false) return
log.info("pruning")
const msgs = yield* session
.messages({ sessionID: input.sessionID })
.pipe(Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)))
if (!msgs) return
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") 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--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
} }
} }
const hasContent =
log.info("found", { pruned, total }) replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (pruned > PRUNE_MINIMUM) { if (!hasContent) {
for (const part of toPrune) { replay = undefined
if (part.state.status === "completed") { messages = input.messages
part.state.time.compacted = Date.now()
yield* session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
} }
}) }
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: { const agent = yield* agents.get("compaction")
parentID: MessageID const model = agent.model
messages: MessageV2.WithParts[] ? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
sessionID: SessionID : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
auto: boolean // Allow plugins to inject context or replace compaction prompt.
overflow?: boolean const compacting = yield* plugin.trigger(
}) { "experimental.session.compacting",
const parent = input.messages.findLast((m) => m.info.id === input.parentID) { sessionID: input.sessionID },
if (!parent || parent.info.role !== "user") { { context: [], prompt: undefined },
throw new Error(`Compaction parent must be a user message: ${input.parentID}`) )
} const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
const userMessage = parent.info
let messages = input.messages
let replay:
| {
info: MessageV2.User
parts: MessageV2.Part[]
}
| undefined
if (input.overflow) {
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")) {
replay = { info: msg.info, parts: msg.parts }
messages = input.messages.slice(0, i)
break
}
}
const hasContent =
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
if (!hasContent) {
replay = undefined
messages = input.messages
}
}
const agent = yield* agents.get("compaction")
const model = agent.model
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work. The summary that you construct will be used so that another agent can read it and continue the work.
Do not call any tools. Respond only with the summary text. Do not call any tools. Respond only with the summary text.
@@ -213,200 +212,199 @@ When constructing the summary, try to stick to this template:
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.] [Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---` ---`
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages) const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = { const msg: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
role: "assistant", role: "assistant",
parentID: input.parentID, parentID: input.parentID,
sessionID: input.sessionID, sessionID: input.sessionID,
mode: "compaction", mode: "compaction",
agent: "compaction", agent: "compaction",
variant: userMessage.model.variant, variant: userMessage.model.variant,
summary: true, summary: true,
path: { path: {
cwd: ctx.directory, cwd: ctx.directory,
root: ctx.worktree, root: ctx.worktree,
},
cost: 0,
tokens: {
output: 0,
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
}
yield* session.updateMessage(msg)
const processor = yield* processors.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
})
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
}, },
cost: 0, ],
tokens: { model,
output: 0, })
input: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
modelID: model.id,
providerID: model.providerID,
time: {
created: Date.now(),
},
}
yield* session.updateMessage(msg)
const processor = yield* processors.create({
assistantMessage: msg,
sessionID: input.sessionID,
model,
})
const result = yield* processor.process({
user: userMessage,
agent,
sessionID: input.sessionID,
tools: {},
system: [],
messages: [
...modelMessages,
{
role: "user",
content: [{ type: "text", text: prompt }],
},
],
model,
})
if (result === "compact") { if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({ processor.message.error = new MessageV2.ContextOverflowError({
message: replay message: replay
? "Conversation history too large to compact - exceeds model context limit" ? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media", : "Session too large to compact - context exceeds model limit even after stripping media",
}).toObject() }).toObject()
processor.message.finish = "error" processor.message.finish = "error"
yield* session.updateMessage(processor.message) yield* session.updateMessage(processor.message)
return "stop" return "stop"
}
if (result === "continue" && input.auto) {
if (replay) {
const original = replay.info
const replayMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: original.agent,
model: original.model,
format: original.format,
tools: original.tools,
system: original.system,
})
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
} }
if (result === "continue" && input.auto) { if (!replay) {
if (replay) { const info = yield* provider.getProvider(userMessage.model.providerID)
const original = replay.info if (
const replayMsg = yield* session.updateMessage({ (yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(), id: MessageID.ascending(),
role: "user", role: "user",
sessionID: input.sessionID, sessionID: input.sessionID,
time: { created: Date.now() }, time: { created: Date.now() },
agent: original.agent, agent: userMessage.agent,
model: original.model, model: userMessage.model,
format: original.format, })
tools: original.tools, const text =
system: original.system, (input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so provider plugins
// can distinguish them from manual post-compaction user prompts.
// This is not a stable plugin contract and may change or disappear.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
}) })
for (const part of replay.parts) {
if (part.type === "compaction") continue
const replayPart =
part.type === "file" && MessageV2.isMedia(part.mime)
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
: part
yield* session.updatePart({
...replayPart,
id: PartID.ascending(),
messageID: replayMsg.id,
sessionID: input.sessionID,
})
}
}
if (!replay) {
const info = yield* provider.getProvider(userMessage.model.providerID)
if (
(yield* plugin.trigger(
"experimental.compaction.autocontinue",
{
sessionID: input.sessionID,
agent: userMessage.agent,
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
provider: {
source: info.source,
info,
options: info.options,
},
message: userMessage,
overflow: input.overflow === true,
},
{ enabled: true },
)).enabled
) {
const continueMsg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
time: { created: Date.now() },
agent: userMessage.agent,
model: userMessage.model,
})
const text =
(input.overflow
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
: "") +
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
yield* session.updatePart({
id: PartID.ascending(),
messageID: continueMsg.id,
sessionID: input.sessionID,
type: "text",
// Internal marker for auto-compaction followups so provider plugins
// can distinguish them from manual post-compaction user prompts.
// This is not a stable plugin contract and may change or disappear.
metadata: { compaction_continue: true },
synthetic: true,
text,
time: {
start: Date.now(),
end: Date.now(),
},
})
}
} }
} }
}
if (processor.message.error) return "stop" if (processor.message.error) return "stop"
if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
return result return result
})
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
auto: boolean
overflow?: boolean
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
}) })
yield* session.updatePart({
const create = Effect.fn("SessionCompaction.create")(function* (input: { id: PartID.ascending(),
sessionID: SessionID messageID: msg.id,
agent: string sessionID: msg.sessionID,
model: { providerID: ProviderID; modelID: ModelID } type: "compaction",
auto: boolean auto: input.auto,
overflow?: boolean overflow: input.overflow,
}) {
const msg = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
model: input.model,
sessionID: input.sessionID,
agent: input.agent,
time: { created: Date.now() },
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: msg.id,
sessionID: msg.sessionID,
type: "compaction",
auto: input.auto,
overflow: input.overflow,
})
}) })
})
return Service.of({ return Service.of({
isOverflow, isOverflow,
prune, prune,
process: processCompaction, process: processCompaction,
create, create,
}) })
}), }),
) )
export const defaultLayer = Layer.suspend(() => export const defaultLayer = Layer.suspend(() =>
layer.pipe( layer.pipe(
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Session.defaultLayer), Layer.provide(Session.defaultLayer),
Layer.provide(SessionProcessor.defaultLayer), Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer), Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
), ),
) )
}
+14
View File
@@ -1 +1,15 @@
export * as Session from "./session" export * as Session from "./session"
export * as SessionRunState from "./run-state"
export * as SystemPrompt from "./system"
export * as Message from "./message"
export * as SessionRetry from "./retry"
export * as SessionProcessor from "./processor"
export * as SessionRevert from "./revert"
export * as Instruction from "./instruction"
export * as SessionSummary from "./summary"
export * as Todo from "./todo"
export * as LLM from "./llm"
export * as SessionStatus from "./status"
export * as SessionCompaction from "./compaction"
export * as SessionPrompt from "./prompt"
export * as MessageV2 from "./message-v2"
+158 -160
View File
@@ -10,7 +10,7 @@ import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Log } from "../util/log" import { Log } from "../util/log"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "."
import type { MessageID } from "./schema" import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" }) const log = Log.create({ service: "instruction" })
@@ -50,194 +50,192 @@ function extract(messages: MessageV2.WithParts[]) {
return paths return paths
} }
export namespace Instruction { export interface Interface {
export interface Interface { readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly clear: (messageID: MessageID) => Effect.Effect<void> readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error> readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error> readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error> readonly resolve: (
readonly resolve: ( messages: MessageV2.WithParts[],
messages: MessageV2.WithParts[], filepath: string,
filepath: string, messageID: MessageID,
messageID: MessageID, ) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error> }
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> = export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const cfg = yield* Config.Service const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() => Effect.fn("Instruction.state")(() =>
Effect.succeed({ Effect.succeed({
// Track which instruction files have already been attached for a given assistant message. // Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(), claims: new Map<MessageID, Set<string>>(),
}), }),
), ),
) )
const relative = Effect.fnUntraced(function* (instruction: string) { const relative = Effect.fnUntraced(function* (instruction: string) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return yield* fs return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR) .globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[]))) .pipe(Effect.catch(() => Effect.succeed([] as string[])))
}) }
if (!Flag.OPENCODE_CONFIG_DIR) {
const read = Effect.fnUntraced(function* (filepath: string) { log.warn(
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(""))) `Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
) )
if (!res) return "" return []
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0)))) }
return new TextDecoder().decode(body) return yield* fs
}) .globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) { const read = Effect.fnUntraced(function* (filepath: string) {
const s = yield* InstanceState.get(state) return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
s.claims.delete(messageID) })
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { const fetch = Effect.fnUntraced(function* (url: string) {
const config = yield* cfg.get() const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
const paths = new Set<string>() Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { const s = yield* InstanceState.get(state)
for (const file of FILES) { s.claims.delete(messageID)
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree) })
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
for (const file of globalFiles()) { const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
if (yield* fs.existsSafe(file)) { const config = yield* cfg.get()
paths.add(path.resolve(file)) const paths = new Set<string>()
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break break
} }
} }
}
if (config.instructions) { for (const file of globalFiles()) {
for (const raw of config.instructions) { if (yield* fs.existsSafe(file)) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue paths.add(path.resolve(file))
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw break
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
} }
}
return paths if (config.instructions) {
}) for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const system = Effect.fn("Instruction.system")(function* () { const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const config = yield* cfg.get() const matches = yield* (
const paths = yield* systemPaths() path.isAbsolute(instruction)
const urls = (config.instructions ?? []).filter( ? fs.glob(path.basename(instruction), {
(item) => item.startsWith("https://") || item.startsWith("http://"), cwd: path.dirname(instruction),
) absolute: true,
include: "file",
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) : relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
return [ matches.forEach((item) => paths.add(path.resolve(item)))
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
} }
}) }
const resolve = Effect.fn("Instruction.resolve")(function* ( return paths
messages: MessageV2.WithParts[], })
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const target = path.resolve(filepath) const system = Effect.fn("Instruction.system")(function* () {
const root = path.resolve(Instance.directory) const config = yield* cfg.get()
let current = path.dirname(target) const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
// Walk upward from the file being read and attach nearby instruction files once per message. const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
while (current.startsWith(root) && current !== root) { const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID) return [
if (!set) { ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
set = new Set() ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
s.claims.set(messageID, set) ]
} })
if (set.has(found)) {
current = path.dirname(current)
continue
}
set.add(found) const find = Effect.fn("Instruction.find")(function* (dir: string) {
const content = yield* read(found) for (const file of FILES) {
if (content) { const filepath = path.resolve(path.join(dir, file))
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` }) if (yield* fs.existsSafe(filepath)) return filepath
} }
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const target = path.resolve(filepath)
const root = path.resolve(Instance.directory)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current) current = path.dirname(current)
continue
} }
return results let set = s.claims.get(messageID)
}) if (!set) {
set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
return Service.of({ clear, systemPaths, system, find, resolve }) set.add(found)
}), const content = yield* read(found)
) if (content) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
export const defaultLayer = layer.pipe( current = path.dirname(current)
Layer.provide(Config.defaultLayer), }
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer), return results
})
return Service.of({ clear, systemPaths, system, find, resolve })
}),
) )
export function loaded(messages: MessageV2.WithParts[]) { export const defaultLayer = layer.pipe(
return extract(messages) Layer.provide(Config.defaultLayer),
} Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages)
} }
+413 -415
View File
@@ -9,9 +9,9 @@ import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config" import { Config } from "@/config"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "."
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { SystemPrompt } from "./system" import { SystemPrompt } from "."
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
@@ -24,430 +24,428 @@ import { EffectBridge } from "@/effect"
import * as Option from "effect/Option" import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer" import * as OtelTracer from "@effect/opentelemetry/Tracer"
export namespace LLM { const log = Log.create({ service: "llm" })
const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX type Result = Awaited<ReturnType<typeof streamText>>
type Result = Awaited<ReturnType<typeof streamText>>
export type StreamInput = { export type StreamInput = {
user: MessageV2.User user: MessageV2.User
sessionID: string sessionID: string
parentSessionID?: string parentSessionID?: string
model: Provider.Model model: Provider.Model
agent: Agent.Info agent: Agent.Info
permission?: Permission.Ruleset permission?: Permission.Ruleset
system: string[] system: string[]
messages: ModelMessage[] messages: ModelMessage[]
small?: boolean small?: boolean
tools: Record<string, Tool> tools: Record<string, Tool>
retries?: number retries?: number
toolChoice?: "auto" | "required" | "none" toolChoice?: "auto" | "required" | "none"
} }
export type StreamRequest = StreamInput & { export type StreamRequest = StreamInput & {
abort: AbortSignal abort: AbortSignal
} }
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export interface Interface { export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown> readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
const live: Layer.Layer< const live: Layer.Layer<
Service, Service,
never, never,
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
> = Layer.effect( > = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.Service const auth = yield* Auth.Service
const config = yield* Config.Service const config = yield* Config.Service
const provider = yield* Provider.Service const provider = yield* Provider.Service
const plugin = yield* Plugin.Service const plugin = yield* Plugin.Service
const perm = yield* Permission.Service const perm = yield* Permission.Service
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
const l = log const l = log
.clone() .clone()
.tag("providerID", input.model.providerID) .tag("providerID", input.model.providerID)
.tag("modelID", input.model.id) .tag("modelID", input.model.id)
.tag("sessionID", input.sessionID) .tag("sessionID", input.sessionID)
.tag("small", (input.small ?? false).toString()) .tag("small", (input.small ?? false).toString())
.tag("agent", input.agent.name) .tag("agent", input.agent.name)
.tag("mode", input.agent.mode) .tag("mode", input.agent.mode)
l.info("stream", { l.info("stream", {
modelID: input.model.id, modelID: input.model.id,
providerID: input.model.providerID, providerID: input.model.providerID,
})
const [language, cfg, item, info] = yield* Effect.all(
[
provider.getLanguage(input.model),
config.get(),
provider.getProvider(input.model.providerID),
auth.get(input.model.providerID),
],
{ concurrency: "unbounded" },
)
// TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
const system: string[] = []
system.push(
[
// use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
const header = system[0]
yield* plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
// rejoin to maintain 2-part structure for caching if header unchanged
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await bridge.promise(
perm.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
}) })
const stream: Interface["stream"] = (input) => const [language, cfg, item, info] = yield* Effect.all(
Stream.scoped( [
Stream.unwrap( provider.getLanguage(input.model),
Effect.gen(function* () { config.get(),
const ctrl = yield* Effect.acquireRelease( provider.getProvider(input.model.providerID),
Effect.sync(() => new AbortController()), auth.get(input.model.providerID),
(ctrl) => Effect.sync(() => ctrl.abort()), ],
) { concurrency: "unbounded" },
)
const result = yield* run({ ...input, abort: ctrl.signal }) // TODO: move this to a proper hook
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))) const system: string[] = []
}), system.push(
), [
) // use agent prompt otherwise provider prompt
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
// any custom prompt passed into this call
...input.system,
// any custom prompt from last user message
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
)
return Service.of({ stream }) const header = system[0]
}), yield* plugin.trigger(
) "experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
export const layer = live.pipe(Layer.provide(Permission.defaultLayer)) { system },
)
export const defaultLayer = Layer.suspend(() => // rejoin to maintain 2-part structure for caching if header unchanged
layer.pipe( if (system.length > 2 && system[0] === header) {
Layer.provide(Auth.defaultLayer), const rest = system.slice(1)
Layer.provide(Config.defaultLayer), system.length = 0
Layer.provide(Provider.defaultLayer), system.push(header, rest.join("\n"))
Layer.provide(Plugin.defaultLayer),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
} }
}
return false const variant =
} !input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: item.options,
})
const options: Record<string, any> = pipe(
base,
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant),
)
if (isOpenaiOauth) {
options.instructions = system.join("\n")
}
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth
? input.messages
: isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model),
options,
},
)
const { headers } = yield* plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: item,
message: input.user,
},
{
headers: {},
},
)
const tools = resolveTools(input)
// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
item.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")
// LiteLLM/Bedrock rejects requests where the message history contains tool
// calls but no tools param is present. When there are no active tools (e.g.
// during compaction), inject a stub tool to satisfy the validation requirement.
// The stub description explicitly tells the model not to call it.
if (
(isLiteLLMProxy || input.model.providerID.includes("github-copilot")) &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
tools["_noop"] = tool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
// Wire up toolExecutor for DWS workflow models so that tool calls
// from the workflow service are executed via opencode's tool system
// and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language as GitLabWorkflowLanguageModel & {
sessionID?: string
sessionPreapprovedTools?: string[]
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
}
workflowModel.sessionID = input.sessionID
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName]
if (!t || !t.execute) {
return { result: "", error: `Unknown tool: ${toolName}` }
}
try {
const result = await t.execute!(JSON.parse(argsJson), {
toolCallId: _requestID,
messages: input.messages,
abortSignal: input.abort,
})
const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result))
return {
result: output,
metadata: typeof result === "object" ? result?.metadata : undefined,
title: typeof result === "object" ? result?.title : undefined,
}
} catch (e: any) {
return { result: "", error: e.message ?? String(e) }
}
}
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
return !match || match.action !== "ask"
})
const bridge = yield* EffectBridge.make()
const approvedToolsForSession = new Set<string>()
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
// Auto-approve tools that were already approved in this session
// (prevents infinite approval loops for server-side MCP tools)
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
return { approved: true }
}
const id = PermissionID.ascending()
let reply: Permission.Reply | undefined
let unsub: (() => void) | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) reply = evt.properties.reply
})
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
const title = (parsed?.title ?? parsed?.name ?? "") as string
return title ? `${t.name}: ${title}` : t.name
} catch {
return t.name
}
})
const uniquePatterns = [...new Set(toolPatterns)] as string[]
await bridge.promise(
perm.ask({
id,
sessionID: SessionID.make(input.sessionID),
permission: "workflow_tool_approval",
patterns: uniquePatterns,
metadata: { tools: approvalTools },
always: uniquePatterns,
ruleset: [],
}),
)
for (const name of uniqueNames) approvedToolsForSession.add(name)
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
return { approved: true }
} catch {
return { approved: false }
} finally {
unsub?.()
}
})
}
const tracer = cfg.experimental?.openTelemetry
? Option.getOrUndefined(yield* Effect.serviceOption(OtelTracer.OtelTracer))
: undefined
return streamText({
onError(error) {
l.error("stream error", {
error,
})
},
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && tools[lower]) {
l.info("repairing tool call", {
tool: failed.toolCall.toolName,
repaired: lower,
})
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: params.temperature,
topP: params.topP,
topK: params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
tools,
toolChoice: input.toolChoice,
maxOutputTokens: params.maxOutputTokens,
abortSignal: input.abort,
headers: {
...(input.model.providerID.startsWith("opencode")
? {
"x-opencode-project": Instance.project.id,
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"x-session-affinity": input.sessionID,
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers,
...headers,
},
maxRetries: input.retries ?? 0,
messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
})
})
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* run({ ...input, abort: ctrl.signal })
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
}),
),
)
return Service.of({ stream })
}),
)
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Plugin.defaultLayer),
),
)
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
}
}
return false
} }
File diff suppressed because it is too large Load Diff
+171 -173
View File
@@ -3,189 +3,187 @@ import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
export namespace Message { export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AuthError = NamedError.create(
export const AuthError = NamedError.create( "ProviderAuthError",
"ProviderAuthError", z.object({
z.object({ providerID: z.string(),
providerID: z.string(), message: z.string(),
message: z.string(), }),
}), )
)
export const ToolCall = z export const ToolCall = z
.object({ .object({
state: z.literal("call"), state: z.literal("call"),
step: z.number().optional(), step: z.number().optional(),
toolCallId: z.string(), toolCallId: z.string(),
toolName: z.string(), toolName: z.string(),
args: z.custom<Required<unknown>>(), args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.meta({
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.meta({
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
ref: "ToolInvocation",
}) })
export type ToolInvocation = z.infer<typeof ToolInvocation> .meta({
ref: "ToolCall",
})
export type ToolCall = z.infer<typeof ToolCall>
export const TextPart = z export const ToolPartialCall = z
.object({ .object({
type: z.literal("text"), state: z.literal("partial-call"),
text: z.string(), step: z.number().optional(),
}) toolCallId: z.string(),
.meta({ toolName: z.string(),
ref: "TextPart", args: z.custom<Required<unknown>>(),
}) })
export type TextPart = z.infer<typeof TextPart> .meta({
ref: "ToolPartialCall",
})
export type ToolPartialCall = z.infer<typeof ToolPartialCall>
export const ReasoningPart = z export const ToolResult = z
.object({ .object({
type: z.literal("reasoning"), state: z.literal("result"),
text: z.string(), step: z.number().optional(),
providerMetadata: z.record(z.string(), z.any()).optional(), toolCallId: z.string(),
}) toolName: z.string(),
.meta({ args: z.custom<Required<unknown>>(),
ref: "ReasoningPart", result: z.string(),
}) })
export type ReasoningPart = z.infer<typeof ReasoningPart> .meta({
ref: "ToolResult",
})
export type ToolResult = z.infer<typeof ToolResult>
export const ToolInvocationPart = z export const ToolInvocation = z.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult]).meta({
.object({ ref: "ToolInvocation",
type: z.literal("tool-invocation"), })
toolInvocation: ToolInvocation, export type ToolInvocation = z.infer<typeof ToolInvocation>
})
.meta({
ref: "ToolInvocationPart",
})
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
export const SourceUrlPart = z export const TextPart = z
.object({ .object({
type: z.literal("source-url"), type: z.literal("text"),
sourceId: z.string(), text: z.string(),
url: z.string(), })
title: z.string().optional(), .meta({
providerMetadata: z.record(z.string(), z.any()).optional(), ref: "TextPart",
}) })
.meta({ export type TextPart = z.infer<typeof TextPart>
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const FilePart = z export const ReasoningPart = z
.object({ .object({
type: z.literal("file"), type: z.literal("reasoning"),
mediaType: z.string(), text: z.string(),
filename: z.string().optional(), providerMetadata: z.record(z.string(), z.any()).optional(),
url: z.string(), })
}) .meta({
.meta({ ref: "ReasoningPart",
ref: "FilePart", })
}) export type ReasoningPart = z.infer<typeof ReasoningPart>
export type FilePart = z.infer<typeof FilePart>
export const StepStartPart = z export const ToolInvocationPart = z
.object({ .object({
type: z.literal("step-start"), type: z.literal("tool-invocation"),
}) toolInvocation: ToolInvocation,
.meta({ })
ref: "StepStartPart", .meta({
}) ref: "ToolInvocationPart",
export type StepStartPart = z.infer<typeof StepStartPart> })
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>
export const MessagePart = z export const SourceUrlPart = z
.discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart]) .object({
.meta({ type: z.literal("source-url"),
ref: "MessagePart", sourceId: z.string(),
}) url: z.string(),
export type MessagePart = z.infer<typeof MessagePart> title: z.string().optional(),
providerMetadata: z.record(z.string(), z.any()).optional(),
})
.meta({
ref: "SourceUrlPart",
})
export type SourceUrlPart = z.infer<typeof SourceUrlPart>
export const Info = z export const FilePart = z
.object({ .object({
id: z.string(), type: z.literal("file"),
role: z.enum(["user", "assistant"]), mediaType: z.string(),
parts: z.array(MessagePart), filename: z.string().optional(),
metadata: z url: z.string(),
.object({ })
time: z.object({ .meta({
created: z.number(), ref: "FilePart",
completed: z.number().optional(), })
}), export type FilePart = z.infer<typeof FilePart>
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema]) export const StepStartPart = z
.optional(), .object({
sessionID: SessionID.zod, type: z.literal("step-start"),
tool: z.record( })
z.string(), .meta({
z ref: "StepStartPart",
.object({ })
title: z.string(), export type StepStartPart = z.infer<typeof StepStartPart>
snapshot: z.string().optional(),
time: z.object({ export const MessagePart = z
start: z.number(), .discriminatedUnion("type", [TextPart, ReasoningPart, ToolInvocationPart, SourceUrlPart, FilePart, StepStartPart])
end: z.number(), .meta({
}), ref: "MessagePart",
}) })
.catchall(z.any()), export type MessagePart = z.infer<typeof MessagePart>
),
assistant: z export const Info = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
parts: z.array(MessagePart),
metadata: z
.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
error: z
.discriminatedUnion("name", [AuthError.Schema, NamedError.Unknown.Schema, OutputLengthError.Schema])
.optional(),
sessionID: SessionID.zod,
tool: z.record(
z.string(),
z
.object({ .object({
system: z.string().array(), title: z.string(),
modelID: ModelID.zod, snapshot: z.string().optional(),
providerID: ProviderID.zod, time: z.object({
path: z.object({ start: z.number(),
cwd: z.string(), end: z.number(),
root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}), }),
}) })
.optional(), .catchall(z.any()),
snapshot: z.string().optional(), ),
}) assistant: z
.meta({ ref: "MessageMetadata" }), .object({
}) system: z.string().array(),
.meta({ modelID: ModelID.zod,
ref: "Message", providerID: ProviderID.zod,
}) path: z.object({
export type Info = z.infer<typeof Info> cwd: z.string(),
} root: z.string(),
}),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
cache: z.object({
read: z.number(),
write: z.number(),
}),
}),
})
.optional(),
snapshot: z.string().optional(),
})
.meta({ ref: "MessageMetadata" }),
})
.meta({
ref: "Message",
})
export type Info = z.infer<typeof Info>
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Config } from "@/config" import type { Config } from "@/config"
import type { Provider } from "@/provider" import type { Provider } from "@/provider"
import { ProviderTransform } from "@/provider/transform" import { ProviderTransform } from "@/provider/transform"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "."
const COMPACTION_BUFFER = 20_000 const COMPACTION_BUFFER = 20_000
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
import { NotFoundError, eq, and } from "../storage/db" import { NotFoundError, eq, and } from "../storage/db"
import { SyncEvent } from "@/sync" import { SyncEvent } from "@/sync"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { SessionTable, MessageTable, PartTable } from "./session.sql" import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
File diff suppressed because it is too large Load Diff
+115 -117
View File
@@ -1,125 +1,123 @@
import type { NamedError } from "@opencode-ai/shared/util/error" import type { NamedError } from "@opencode-ai/shared/util/error"
import { Cause, Clock, Duration, Effect, Schedule } from "effect" import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
export namespace SessionRetry { export type Err = ReturnType<NamedError["toObject"]>
export type Err = ReturnType<NamedError["toObject"]>
// This exported message is shared with the TUI upsell detector. Matching on a // This exported message is shared with the TUI upsell detector. Matching on a
// literal error string kind of sucks, but it is the simplest for now. // literal error string kind of sucks, but it is the simplest for now.
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go" export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
export const RETRY_INITIAL_DELAY = 2000 export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
function cap(ms: number) { function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY) return Math.min(ms, RETRY_MAX_DELAY)
} }
export function delay(attempt: number, error?: MessageV2.APIError) { export function delay(attempt: number, error?: MessageV2.APIError) {
if (error) { if (error) {
const headers = error.data.responseHeaders const headers = error.data.responseHeaders
if (headers) { if (headers) {
const retryAfterMs = headers["retry-after-ms"] const retryAfterMs = headers["retry-after-ms"]
if (retryAfterMs) { if (retryAfterMs) {
const parsedMs = Number.parseFloat(retryAfterMs) const parsedMs = Number.parseFloat(retryAfterMs)
if (!Number.isNaN(parsedMs)) { if (!Number.isNaN(parsedMs)) {
return cap(parsedMs) return cap(parsedMs)
} }
} }
const retryAfter = headers["retry-after"] const retryAfter = headers["retry-after"]
if (retryAfter) { if (retryAfter) {
const parsedSeconds = Number.parseFloat(retryAfter) const parsedSeconds = Number.parseFloat(retryAfter)
if (!Number.isNaN(parsedSeconds)) { if (!Number.isNaN(parsedSeconds)) {
// convert seconds to milliseconds // convert seconds to milliseconds
return cap(Math.ceil(parsedSeconds * 1000)) return cap(Math.ceil(parsedSeconds * 1000))
} }
// Try parsing as HTTP date format // Try parsing as HTTP date format
const parsed = Date.parse(retryAfter) - Date.now() const parsed = Date.parse(retryAfter) - Date.now()
if (!Number.isNaN(parsed) && parsed > 0) { if (!Number.isNaN(parsed) && parsed > 0) {
return cap(Math.ceil(parsed)) return cap(Math.ceil(parsed))
} }
} }
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)) return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
} }
} }
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS)) return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
} }
export function retryable(error: Err) { export function retryable(error: Err) {
// context overflow errors should not be retried // context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) { if (MessageV2.APIError.isInstance(error)) {
const status = error.data.statusCode const status = error.data.statusCode
// 5xx errors are transient server failures and should always be retried, // 5xx errors are transient server failures and should always be retried,
// even when the provider SDK doesn't explicitly mark them as retryable. // even when the provider SDK doesn't explicitly mark them as retryable.
if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
} }
// Check for rate limit patterns in plain text error messages // Check for rate limit patterns in plain text error messages
const msg = error.data?.message const msg = error.data?.message
if (typeof msg === "string") { if (typeof msg === "string") {
const lower = msg.toLowerCase() const lower = msg.toLowerCase()
if ( if (
lower.includes("rate increased too quickly") || lower.includes("rate increased too quickly") ||
lower.includes("rate limit") || lower.includes("rate limit") ||
lower.includes("too many requests") lower.includes("too many requests")
) { ) {
return msg return msg
} }
} }
const json = iife(() => { const json = iife(() => {
try { try {
if (typeof error.data?.message === "string") { if (typeof error.data?.message === "string") {
const parsed = JSON.parse(error.data.message) const parsed = JSON.parse(error.data.message)
return parsed return parsed
} }
return JSON.parse(error.data.message) return JSON.parse(error.data.message)
} catch { } catch {
return undefined return undefined
} }
}) })
if (!json || typeof json !== "object") return undefined if (!json || typeof json !== "object") return undefined
const code = typeof json.code === "string" ? json.code : "" const code = typeof json.code === "string" ? json.code : ""
if (json.type === "error" && json.error?.type === "too_many_requests") { if (json.type === "error" && json.error?.type === "too_many_requests") {
return "Too Many Requests" return "Too Many Requests"
} }
if (code.includes("exhausted") || code.includes("unavailable")) { if (code.includes("exhausted") || code.includes("unavailable")) {
return "Provider is overloaded" return "Provider is overloaded"
} }
if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) {
return "Rate Limited" return "Rate Limited"
} }
return undefined return undefined
} }
export function policy(opts: { export function policy(opts: {
parse: (error: unknown) => Err parse: (error: unknown) => Err
set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void> set: (input: { attempt: number; message: string; next: number }) => Effect.Effect<void>
}) { }) {
return Schedule.fromStepWithMetadata( return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => { Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input) const error = opts.parse(meta.input)
const message = retryable(error) const message = retryable(error)
if (!message) return Cause.done(meta.attempt) if (!message) return Cause.done(meta.attempt)
return Effect.gen(function* () { return Effect.gen(function* () {
const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined) const wait = delay(meta.attempt, MessageV2.APIError.isInstance(error) ? error : undefined)
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
yield* opts.set({ attempt: meta.attempt, message, next: now + wait }) yield* opts.set({ attempt: meta.attempt, message, next: now + wait })
return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration] return [meta.attempt, Duration.millis(wait)] as [number, Duration.Duration]
}) })
}), }),
) )
}
} }
+146 -148
View File
@@ -6,156 +6,154 @@ import { Storage } from "@/storage/storage"
import { SyncEvent } from "../sync" import { SyncEvent } from "../sync"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { SessionRunState } from "./run-state" import { SessionRunState } from "."
import { SessionSummary } from "./summary" import { SessionSummary } from "."
export namespace SessionRevert { const log = Log.create({ service: "session.revert" })
const log = Log.create({ service: "session.revert" })
export const RevertInput = z.object({ export const RevertInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
messageID: MessageID.zod, messageID: MessageID.zod,
partID: PartID.zod.optional(), partID: PartID.zod.optional(),
}) })
export type RevertInput = z.infer<typeof RevertInput> export type RevertInput = z.infer<typeof RevertInput>
export interface Interface { export interface Interface {
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info> readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info> readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
readonly cleanup: (session: Session.Info) => Effect.Effect<void> readonly cleanup: (session: Session.Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (part.type === "patch") patches.push(part)
continue
}
if (!rev) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
}
}
}
if (!rev) return session
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const partID = session.revert.partID
const idx = target.parts.findIndex((part) => part.id === partID)
if (idx >= 0) {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
}
}
}
yield* sessions.clearRevert(sessionID)
})
return Service.of({ revert, unrevert, cleanup })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
)
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRevert") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snap = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const summary = yield* SessionSummary.Service
const state = yield* SessionRunState.Service
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
yield* state.assertNotBusy(input.sessionID)
const all = yield* sessions.messages({ sessionID: input.sessionID })
let lastUser: MessageV2.User | undefined
const session = yield* sessions.get(input.sessionID)
let rev: Session.Info["revert"]
const patches: Snapshot.Patch[] = []
for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info
const remaining = []
for (const part of msg.parts) {
if (rev) {
if (part.type === "patch") patches.push(part)
continue
}
if (!rev) {
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
rev = {
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
partID,
}
}
remaining.push(part)
}
}
}
if (!rev) return session
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
if (session.revert?.snapshot) yield* snap.restore(session.revert.snapshot)
yield* snap.revert(patches)
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
const diffs = yield* summary.computeDiff({ messages: range })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
yield* sessions.setRevert({
sessionID: input.sessionID,
revert: rev,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
})
return yield* sessions.get(input.sessionID)
})
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
log.info("unreverting", input)
yield* state.assertNotBusy(input.sessionID)
const session = yield* sessions.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
yield* sessions.clearRevert(input.sessionID)
return yield* sessions.get(input.sessionID)
})
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
const msgs = yield* sessions.messages({ sessionID })
const messageID = session.revert.messageID
const remove = [] as MessageV2.WithParts[]
let target: MessageV2.WithParts | undefined
for (const msg of msgs) {
if (msg.info.id < messageID) continue
if (msg.info.id > messageID) {
remove.push(msg)
continue
}
if (session.revert.partID) {
target = msg
continue
}
remove.push(msg)
}
for (const msg of remove) {
SyncEvent.run(MessageV2.Event.Removed, {
sessionID,
messageID: msg.info.id,
})
}
if (session.revert.partID && target) {
const partID = session.revert.partID
const idx = target.parts.findIndex((part) => part.id === partID)
if (idx >= 0) {
const removeParts = target.parts.slice(idx)
target.parts = target.parts.slice(0, idx)
for (const part of removeParts) {
SyncEvent.run(MessageV2.Event.PartRemoved, {
sessionID,
messageID: target.info.id,
partID: part.id,
})
}
}
}
yield* sessions.clearRevert(sessionID)
})
return Service.of({ revert, unrevert, cleanup })
}),
)
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(SessionRunState.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(SessionSummary.defaultLayer),
),
)
+99 -101
View File
@@ -2,107 +2,105 @@ import { InstanceState } from "@/effect"
import { Runner } from "@/effect/runner" import { Runner } from "@/effect/runner"
import { Effect, Layer, Scope, Context } from "effect" import { Effect, Layer, Scope, Context } from "effect"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { SessionID } from "./schema" import { SessionID } from "./schema"
import { SessionStatus } from "./status" import { SessionStatus } from "."
export namespace SessionRunState { export interface Interface {
export interface Interface { readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void>
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void> readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly cancel: (sessionID: SessionID) => Effect.Effect<void> readonly ensureRunning: (
readonly ensureRunning: ( sessionID: SessionID,
sessionID: SessionID, onInterrupt: Effect.Effect<MessageV2.WithParts>,
onInterrupt: Effect.Effect<MessageV2.WithParts>, work: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>, ) => Effect.Effect<MessageV2.WithParts>
) => Effect.Effect<MessageV2.WithParts> readonly startShell: (
readonly startShell: ( sessionID: SessionID,
sessionID: SessionID, onInterrupt: Effect.Effect<MessageV2.WithParts>,
onInterrupt: Effect.Effect<MessageV2.WithParts>, work: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>, ) => Effect.Effect<MessageV2.WithParts>
) => Effect.Effect<MessageV2.WithParts>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
)
const runner = Effect.fn("SessionRunState.runner")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
})
data.runners.set(sessionID, next)
return next
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunState") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionRunState.state")(function* () {
const scope = yield* Scope.Scope
const runners = new Map<SessionID, Runner<MessageV2.WithParts>>()
yield* Effect.addFinalizer(
Effect.fnUntraced(function* () {
yield* Effect.forEach(runners.values(), (runner) => runner.cancel, {
concurrency: "unbounded",
discard: true,
})
runners.clear()
}),
)
return { runners, scope }
}),
)
const runner = Effect.fn("SessionRunState.runner")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing) return existing
const next = Runner.make<MessageV2.WithParts>(data.scope, {
onIdle: Effect.gen(function* () {
data.runners.delete(sessionID)
yield* status.set(sessionID, { type: "idle" })
}),
onBusy: status.set(sessionID, { type: "busy" }),
onInterrupt,
busy: () => {
throw new Session.BusyError(sessionID)
},
})
data.runners.set(sessionID, next)
return next
})
const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (existing?.busy) throw new Session.BusyError(sessionID)
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing || !existing.busy) {
yield* status.set(sessionID, { type: "idle" })
return
}
yield* existing.cancel
})
const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work)
})
const startShell = Effect.fn("SessionRunState.startShell")(function* (
sessionID: SessionID,
onInterrupt: Effect.Effect<MessageV2.WithParts>,
work: Effect.Effect<MessageV2.WithParts>,
) {
return yield* (yield* runner(sessionID, onInterrupt)).startShell(work)
})
return Service.of({ assertNotBusy, cancel, ensureRunning, startShell })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
+1 -1
View File
@@ -1,6 +1,6 @@
import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "."
import type { SessionEntry } from "../v2/session-entry" import type { SessionEntry } from "../v2/session-entry"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import type { Permission } from "../permission" import type { Permission } from "../permission"
+1 -1
View File
@@ -16,7 +16,7 @@ import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { Log } from "../util/log" import { Log } from "../util/log"
import { updateSchema } from "../util/update-schema" import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
+76 -78
View File
@@ -5,84 +5,82 @@ import { SessionID } from "./schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, Context } from "effect"
import z from "zod" import z from "zod"
export namespace SessionStatus { export const Info = z
export const Info = z .union([
.union([ z.object({
z.object({ type: z.literal("idle"),
type: z.literal("idle"),
}),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: Info,
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
}),
),
}
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
return data.get(sessionID) ?? { type: "idle" as const }
})
const list = Effect.fn("SessionStatus.list")(function* () {
return new Map(yield* InstanceState.get(state))
})
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })
data.delete(sessionID)
return
}
data.set(sessionID, status)
})
return Service.of({ get, list, set })
}), }),
) z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
z.object({
type: z.literal("busy"),
}),
])
.meta({
ref: "SessionStatus",
})
export type Info = z.infer<typeof Info>
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const Event = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: Info,
}),
),
// deprecated
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
}),
),
} }
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
return data.get(sessionID) ?? { type: "idle" as const }
})
const list = Effect.fn("SessionStatus.list")(function* () {
return new Map(yield* InstanceState.get(state))
})
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* bus.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* bus.publish(Event.Idle, { sessionID })
data.delete(sessionID)
return
}
data.set(sessionID, status)
})
return Service.of({ get, list, set })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+137 -139
View File
@@ -4,162 +4,160 @@ import { Bus } from "@/bus"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
import { Session } from "." import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "."
import { SessionID, MessageID } from "./schema" import { SessionID, MessageID } from "./schema"
export namespace SessionSummary { function unquoteGitPath(input: string) {
function unquoteGitPath(input: string) { if (!input.startsWith('"')) return input
if (!input.startsWith('"')) return input if (!input.endsWith('"')) return input
if (!input.endsWith('"')) return input const body = input.slice(1, -1)
const body = input.slice(1, -1) const bytes: number[] = []
const bytes: number[] = []
for (let i = 0; i < body.length; i++) { for (let i = 0; i < body.length; i++) {
const char = body[i]! const char = body[i]!
if (char !== "\\") { if (char !== "\\") {
bytes.push(char.charCodeAt(0)) bytes.push(char.charCodeAt(0))
continue continue
}
const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
} }
return Buffer.from(bytes).toString() const next = body[i + 1]
if (!next) {
bytes.push("\\".charCodeAt(0))
continue
}
if (next >= "0" && next <= "7") {
const chunk = body.slice(i + 1, i + 4)
const match = chunk.match(/^[0-7]{1,3}/)
if (!match) {
bytes.push(next.charCodeAt(0))
i++
continue
}
bytes.push(parseInt(match[0], 8))
i += match[0].length
continue
}
const escaped =
next === "n"
? "\n"
: next === "r"
? "\r"
: next === "t"
? "\t"
: next === "b"
? "\b"
: next === "f"
? "\f"
: next === "v"
? "\v"
: next === "\\" || next === '"'
? next
: undefined
bytes.push((escaped ?? next).charCodeAt(0))
i++
} }
export interface Interface { return Buffer.from(bytes).toString()
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void> }
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {} export interface Interface {
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
}
export const layer = Layer.effect( export class Service extends Context.Service<Service, Interface>()("@opencode/SessionSummary") {}
Service,
Effect.gen(function* () {
const sessions = yield* Session.Service
const snapshot = yield* Snapshot.Service
const storage = yield* Storage.Service
const bus = yield* Bus.Service
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: { export const layer = Layer.effect(
messages: MessageV2.WithParts[] Service,
}) { Effect.gen(function* () {
let from: string | undefined const sessions = yield* Session.Service
let to: string | undefined const snapshot = yield* Snapshot.Service
for (const item of input.messages) { const storage = yield* Storage.Service
if (!from) { const bus = yield* Bus.Service
for (const part of item.parts) {
if (part.type === "step-start" && part.snapshot) { const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
from = part.snapshot messages: MessageV2.WithParts[]
break }) {
} let from: string | undefined
let to: string | undefined
for (const item of input.messages) {
if (!from) {
for (const part of item.parts) {
if (part.type === "step-start" && part.snapshot) {
from = part.snapshot
break
} }
} }
for (const part of item.parts) {
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
}
} }
if (from && to) return yield* snapshot.diffFull(from, to) for (const part of item.parts) {
return [] if (part.type === "step-finish" && part.snapshot) to = part.snapshot
}
}
if (from && to) return yield* snapshot.diffFull(from, to)
return []
})
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
sessionID: SessionID
messageID: MessageID
}) {
const all = yield* sessions.messages({ sessionID: input.sessionID })
if (!all.length) return
const diffs = yield* computeDiff({ messages: all })
yield* sessions.setSummary({
sessionID: input.sessionID,
summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
},
}) })
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: { const messages = all.filter(
sessionID: SessionID (m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
messageID: MessageID )
}) { const target = messages.find((m) => m.info.id === input.messageID)
const all = yield* sessions.messages({ sessionID: input.sessionID }) if (!target || target.info.role !== "user") return
if (!all.length) return const msgDiffs = yield* computeDiff({ messages })
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
yield* sessions.updateMessage(target.info)
})
const diffs = yield* computeDiff({ messages: all }) const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
yield* sessions.setSummary({ const diffs = yield* storage
sessionID: input.sessionID, .read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
summary: { .pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
additions: diffs.reduce((sum, x) => sum + x.additions, 0), const next = diffs.map((item) => {
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), const file = unquoteGitPath(item.file)
files: diffs.length, if (file === item.file) return item
}, return { ...item, file }
})
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
const messages = all.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const target = messages.find((m) => m.info.id === input.messageID)
if (!target || target.info.role !== "user") return
const msgDiffs = yield* computeDiff({ messages })
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
yield* sessions.updateMessage(target.info)
}) })
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
return next
})
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) { return Service.of({ summarize, diff, computeDiff })
const diffs = yield* storage }),
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]) )
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
const next = diffs.map((item) => {
const file = unquoteGitPath(item.file)
if (file === item.file) return item
return { ...item, file }
})
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
return next
})
return Service.of({ summarize, diff, computeDiff }) export const defaultLayer = Layer.suspend(() =>
}), layer.pipe(
) Layer.provide(Session.defaultLayer),
Layer.provide(Snapshot.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
),
)
export const defaultLayer = Layer.suspend(() => export const DiffInput = z.object({
layer.pipe( sessionID: SessionID.zod,
Layer.provide(Session.defaultLayer), messageID: MessageID.zod.optional(),
Layer.provide(Snapshot.defaultLayer), })
Layer.provide(Storage.defaultLayer),
Layer.provide(Bus.layer),
),
)
export const DiffInput = z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod.optional(),
})
}
+61 -63
View File
@@ -16,69 +16,67 @@ import type { Agent } from "@/agent/agent"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { Skill } from "@/skill" import { Skill } from "@/skill"
export namespace SystemPrompt { export function provider(model: Provider.Model) {
export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) return [PROMPT_BEAST]
return [PROMPT_BEAST] if (model.api.id.includes("gpt")) {
if (model.api.id.includes("gpt")) { if (model.api.id.includes("codex")) {
if (model.api.id.includes("codex")) { return [PROMPT_CODEX]
return [PROMPT_CODEX]
}
return [PROMPT_GPT]
} }
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] return [PROMPT_GPT]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
return [PROMPT_DEFAULT]
} }
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
export interface Interface { if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
readonly environment: (model: Provider.Model) => string[] if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined> if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
} return [PROMPT_DEFAULT]
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return Service.of({
environment(model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
} }
export interface Interface {
readonly environment: (model: Provider.Model) => string[]
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const skill = yield* Skill.Service
return Service.of({
environment(model) {
const project = Instance.project
return [
[
`You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`,
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
].join("\n"),
]
},
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
const list = yield* skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }),
].join("\n")
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
+72 -74
View File
@@ -6,80 +6,78 @@ import z from "zod"
import { Database, eq, asc } from "../storage/db" import { Database, eq, asc } from "../storage/db"
import { TodoTable } from "./session.sql" import { TodoTable } from "./session.sql"
export namespace Todo { export const Info = z
export const Info = z .object({
.object({ content: z.string().describe("Brief description of the task"),
content: z.string().describe("Brief description of the task"), status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), priority: z.string().describe("Priority level of the task: high, medium, low"),
priority: z.string().describe("Priority level of the task: high, medium, low"), })
}) .meta({ ref: "Todo" })
.meta({ ref: "Todo" }) export type Info = z.infer<typeof Info>
export type Info = z.infer<typeof Info>
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"todo.updated", "todo.updated",
z.object({ z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
todos: z.array(Info), todos: z.array(Info),
}),
),
}
export interface Interface {
readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all(),
),
)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}), }),
) ),
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
} }
export interface Interface {
readonly update: (input: { sessionID: SessionID; todos: Info[] }) => Effect.Effect<void>
readonly get: (sessionID: SessionID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionTodo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const update = Effect.fn("Todo.update")(function* (input: { sessionID: SessionID; todos: Info[] }) {
yield* Effect.sync(() =>
Database.transaction((db) => {
db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
if (input.todos.length === 0) return
db.insert(TodoTable)
.values(
input.todos.map((todo, position) => ({
session_id: input.sessionID,
content: todo.content,
status: todo.status,
priority: todo.priority,
position,
})),
)
.run()
}),
)
yield* bus.publish(Event.Updated, input)
})
const get = Effect.fn("Todo.get")(function* (sessionID: SessionID) {
const rows = yield* Effect.sync(() =>
Database.use((db) =>
db
.select()
.from(TodoTable)
.where(eq(TodoTable.session_id, sessionID))
.orderBy(asc(TodoTable.position))
.all(),
),
)
return rows.map((row) => ({
content: row.content,
status: row.status,
priority: row.priority,
}))
})
return Service.of({ update, get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
+1 -1
View File
@@ -7,7 +7,7 @@ import { InstanceState } from "@/effect"
import { Provider } from "@/provider" import { Provider } from "@/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session" import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
import { Config } from "@/config" import { Config } from "@/config"
+1 -1
View File
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Question } from "../question" import { Question } from "../question"
import { Session } from "../session" import { Session } from "../session"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session"
import { Provider } from "../provider" import { Provider } from "../provider"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { type SessionID, MessageID, PartID } from "../session/schema" import { type SessionID, MessageID, PartID } from "../session/schema"
+1 -1
View File
@@ -11,7 +11,7 @@ import { FileTime } from "../file/time"
import DESCRIPTION from "./read.txt" import DESCRIPTION from "./read.txt"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { assertExternalDirectoryEffect } from "./external-directory" import { assertExternalDirectoryEffect } from "./external-directory"
import { Instruction } from "../session/instruction" import { Instruction } from "../session"
const DEFAULT_READ_LIMIT = 2000 const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000 const MAX_LINE_LENGTH = 2000
+2 -2
View File
@@ -37,10 +37,10 @@ import { Ripgrep } from "../file/ripgrep"
import { Format } from "../format" import { Format } from "../format"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileTime } from "../file/time" import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction" import { Instruction } from "../session"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
+2 -2
View File
@@ -3,9 +3,9 @@ import DESCRIPTION from "./task.txt"
import z from "zod" import z from "zod"
import { Session } from "../session" import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import type { SessionPrompt } from "../session/prompt" import type { SessionPrompt } from "../session"
import { Config } from "../config" import { Config } from "../config"
import { Effect } from "effect" import { Effect } from "effect"
+1 -1
View File
@@ -2,7 +2,7 @@ import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION_WRITE from "./todowrite.txt" import DESCRIPTION_WRITE from "./todowrite.txt"
import { Todo } from "../session/todo" import { Todo } from "../session"
const parameters = z.object({ const parameters = z.object({
todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"),
+1 -1
View File
@@ -1,6 +1,6 @@
import z from "zod" import z from "zod"
import { Effect } from "effect" import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2" import type { MessageV2 } from "../session"
import type { Permission } from "../permission" import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema" import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate" import { Truncate } from "./truncate"
@@ -1,6 +1,6 @@
import { test, expect, describe } from "bun:test" import { test, expect, describe } from "bun:test"
import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github" import { extractResponseText, formatPromptTooLargeError } from "../../src/cli/cmd/github"
import type { MessageV2 } from "../../src/session/message-v2" import type { MessageV2 } from "../../src/session"
import { SessionID, MessageID, PartID } from "../../src/session/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema"
// Helper to create minimal valid parts // Helper to create minimal valid parts
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server" import { Server } from "../../src/server/server"
import { Session as SessionNs } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -6,8 +6,8 @@ import z from "zod"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Config } from "../../src/config" import { Config } from "../../src/config"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session"
import { Token } from "../../src/util/token" import { Token } from "../../src/util/token"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -15,13 +15,13 @@ import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture"
import { Session as SessionNs } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session"
import { SessionSummary } from "../../src/session/summary" import { SessionSummary } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import type { Provider } from "../../src/provider" import type { Provider } from "../../src/provider"
import * as SessionProcessorModule from "../../src/session/processor" import * as SessionProcessorModule from "../../src/session"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ProviderTest } from "../fake/provider" import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -2,8 +2,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session"
import type { MessageV2 } from "../../src/session/message-v2" import type { MessageV2 } from "../../src/session"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { Global } from "../../src/global" import { Global } from "../../src/global"
+2 -2
View File
@@ -4,7 +4,7 @@ import { tool, type ModelMessage } from "ai"
import { Cause, Effect, Exit, Stream } from "effect" import { Cause, Effect, Exit, Stream } from "effect"
import z from "zod" import z from "zod"
import { makeRuntime } from "../../src/effect/run-service" import { makeRuntime } from "../../src/effect/run-service"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider" import { Provider } from "../../src/provider"
import { ProviderTransform } from "../../src/provider/transform" import { ProviderTransform } from "../../src/provider/transform"
@@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import type { Agent } from "../../src/agent/agent" import type { Agent } from "../../src/agent/agent"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { APICallError } from "ai" import { APICallError } from "ai"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import type { Provider } from "../../src/provider" import type { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "path" import path from "path"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Session as SessionNs } from "../../src/session" import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@@ -11,12 +11,12 @@ import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider" import { Provider } from "../../src/provider"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { SessionProcessor } from "../../src/session/processor" import { SessionProcessor } from "../../src/session"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session"
import { SessionSummary } from "../../src/session/summary" import { SessionSummary } from "../../src/session"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
@@ -16,22 +16,22 @@ import { Provider as ProviderSvc } from "../../src/provider"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session"
import { SessionSummary } from "../../src/session/summary" import { SessionSummary } from "../../src/session"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session"
import { SessionProcessor } from "../../src/session/processor" import { SessionProcessor } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session"
import { SessionRevert } from "../../src/session/revert" import { SessionRevert } from "../../src/session"
import { SessionRunState } from "../../src/session/run-state" import { SessionRunState } from "../../src/session"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
@@ -6,8 +6,8 @@ import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
+3 -3
View File
@@ -3,12 +3,12 @@ import type { NamedError } from "@opencode-ai/shared/util/error"
import { APICallError } from "ai" import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Effect, Schedule } from "effect" import { Effect, Schedule } from "effect"
import { SessionRetry } from "../../src/session/retry" import { SessionRetry } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { ProviderID } from "../../src/provider/schema" import { ProviderID } from "../../src/provider/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { SessionID } from "../../src/session/schema" import { SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -4,8 +4,8 @@ import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionRevert } from "../../src/session/revert" import { SessionRevert } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageID, PartID, SessionID } from "../../src/session/schema"
@@ -4,7 +4,7 @@ import { Session as SessionNs } from "../../src/session"
import { Bus } from "../../src/bus" import { Bus } from "../../src/bus"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { AppRuntime } from "../../src/effect/app-runtime" import { AppRuntime } from "../../src/effect/app-runtime"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@@ -17,11 +17,11 @@ import { FetchHttpClient } from "effect/unstable/http"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm" import { LLM } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session"
import { SessionRevert } from "../../src/session/revert" import { SessionRevert } from "../../src/session"
import { SessionSummary } from "../../src/session/summary" import { SessionSummary } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { provideTmpdirServer } from "../fixture/fixture" import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -42,13 +42,13 @@ import { Provider as ProviderSvc } from "../../src/provider"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session"
import { SessionCompaction } from "../../src/session/compaction" import { SessionCompaction } from "../../src/session"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session"
import { SessionProcessor } from "../../src/session/processor" import { SessionProcessor } from "../../src/session"
import { SessionRunState } from "../../src/session/run-state" import { SessionRunState } from "../../src/session"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
@@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
const projectRoot = path.join(__dirname, "../..") const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false }) Log.init({ print: false })
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import { SessionPrompt } from "../../src/session/prompt" import { SessionPrompt } from "../../src/session"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
describe("structured-output.OutputFormat", () => { describe("structured-output.OutputFormat", () => {
@@ -3,7 +3,7 @@ import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system" import { SystemPrompt } from "../../src/session"
import { provideInstance, tmpdir } from "../fixture/fixture" import { provideInstance, tmpdir } from "../fixture/fixture"
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) { function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
+1 -1
View File
@@ -9,7 +9,7 @@ import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
import { Instruction } from "../../src/session/instruction" import { Instruction } from "../../src/session"
import { ReadTool } from "../../src/tool/read" import { ReadTool } from "../../src/tool/read"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Tool } from "../../src/tool/tool" import { Tool } from "../../src/tool/tool"
+2 -2
View File
@@ -5,8 +5,8 @@ import { Config } from "../../src/config"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session" import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2" import { MessageV2 } from "../../src/session"
import type { SessionPrompt } from "../../src/session/prompt" import type { SessionPrompt } from "../../src/session"
import { MessageID, PartID } from "../../src/session/schema" import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { TaskTool, type TaskPromptOps } from "../../src/tool/task"