mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 06:46:24 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390bff2ecb | ||
|
|
1a19d33d57 | ||
|
|
b81cb5cc3e |
@@ -184,7 +184,6 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
|
||||
assistantMessageID: message.id,
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
started: 1700000002000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type EventApi = Client["event"]
|
||||
export type GenerateApi = Client["generate"]
|
||||
export type IntegrationApi = Client["integration"]
|
||||
export type McpApi = Client["mcp"]
|
||||
export type MessageApi = Client["message"]
|
||||
export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type PermissionApi = Client["permission"]
|
||||
|
||||
@@ -223,12 +223,17 @@ describe("OpenAPI.fromSpec", () => {
|
||||
const spec = await opencodeSpec()
|
||||
const result = OpenAPI.fromSpec({ spec, baseUrl })
|
||||
|
||||
expect(result.skipped).toHaveLength(5)
|
||||
expect(result.skipped).toHaveLength(6)
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "GET",
|
||||
path: "/api/pty/{ptyID}/connect",
|
||||
reason: "WebSocket operations are not supported",
|
||||
})
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "POST",
|
||||
path: "/api/experimental/fs/write",
|
||||
reason: "request body has no JSON content (declared: application/octet-stream)",
|
||||
})
|
||||
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
|
||||
expect(result.skipped).toContainEqual({
|
||||
method: "GET",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
export * as NativeCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.compaction.native",
|
||||
effect: Effect.fn("NativeCompactionPlugin")(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => {
|
||||
editor.native((input) => {
|
||||
const request = input.request
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
|
||||
return Effect.gen(function* () {
|
||||
const retained = yield* input.retained
|
||||
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
})
|
||||
if (LLMClient.canCompact(request))
|
||||
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -19,6 +19,7 @@ import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Mcp } from "../mcp/index.js"
|
||||
import { Session } from "../session.js"
|
||||
import { SessionMessage } from "../session/message.js"
|
||||
import { PersistentPty } from "../persistent-pty.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Reference } from "../reference.js"
|
||||
@@ -392,6 +393,30 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
})
|
||||
}),
|
||||
},
|
||||
message: {
|
||||
list: Effect.fn("PluginHost.messages")(function* (input) {
|
||||
if (input.cursor !== undefined && input.order !== undefined)
|
||||
return yield* Effect.fail(new Error("Cursor cannot be combined with order"))
|
||||
const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor)
|
||||
const order = decoded?.order ?? input.order ?? "desc"
|
||||
const messages = yield* sessions.messages({
|
||||
sessionID: input.sessionID,
|
||||
limit: input.limit ?? DefaultMessagesLimit,
|
||||
order,
|
||||
type: input.type,
|
||||
cursor: decoded ? { id: decoded.id, direction: decoded.direction } : undefined,
|
||||
})
|
||||
const first = messages[0]
|
||||
const last = messages.at(-1)
|
||||
return {
|
||||
data: messages,
|
||||
cursor: {
|
||||
previous: first ? encodeMessageCursor(first, order, "previous") : undefined,
|
||||
next: last ? encodeMessageCursor(last, order, "next") : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
@@ -547,6 +572,14 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
.pipe(Effect.map((interrupted) => ({ interrupted }))),
|
||||
wait: (input) => sessions.wait(input.sessionID),
|
||||
context: (input) => sessions.context(input.sessionID),
|
||||
message: {
|
||||
get: Effect.fn(function* (input) {
|
||||
yield* sessions.get(input.sessionID)
|
||||
const message = yield* sessions.message(input)
|
||||
if (!message) return yield* Effect.fail(new Error(`Message not found: ${input.messageID}`))
|
||||
return message
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
return context
|
||||
@@ -655,3 +688,29 @@ function methodImplementation(input: IntegrationMethodRegistration): Integration
|
||||
function credential(value: Credential.OAuth) {
|
||||
return Credential.OAuth.make({ ...value, methodID: Integration.MethodID.make(value.methodID) })
|
||||
}
|
||||
|
||||
const DefaultMessagesLimit = 50
|
||||
|
||||
const MessageCursor = Schema.Struct({
|
||||
id: SessionMessage.ID,
|
||||
order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
|
||||
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
|
||||
})
|
||||
|
||||
function encodeMessageCursor(
|
||||
message: SessionMessage.Info,
|
||||
order: "asc" | "desc",
|
||||
direction: "previous" | "next",
|
||||
) {
|
||||
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
|
||||
}
|
||||
|
||||
const decodeMessageCursor = Effect.fn("PluginHost.decodeMessageCursor")(function* (cursor: string) {
|
||||
const parsed = yield* Effect.try({
|
||||
try: () => JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")),
|
||||
catch: () => new Error("Invalid cursor"),
|
||||
})
|
||||
return yield* Schema.decodeUnknownEffect(MessageCursor)(parsed).pipe(
|
||||
Effect.mapError(() => new Error("Invalid cursor")),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as PluginInternal from "./internal.js"
|
||||
|
||||
import { LLMClient } from "@opencode/ai"
|
||||
import type { Plugin } from "@opencode/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -13,7 +12,6 @@ import { Provider } from "../provider.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
|
||||
@@ -86,7 +84,6 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { NativeCompactionPlugin } from "./compaction.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -123,7 +120,6 @@ const services = [
|
||||
Integration.Service,
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
@@ -175,7 +171,6 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
@@ -217,7 +212,6 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type Usage,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -93,25 +91,8 @@ export type Settings = {
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type NativeInput = {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
|
||||
readonly retained: Effect.Effect<ReadonlyArray<Message>>
|
||||
}
|
||||
|
||||
export type NativeResult = {
|
||||
readonly replacement: ReadonlyArray<Message>
|
||||
readonly usage?: Usage
|
||||
}
|
||||
|
||||
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
|
||||
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
|
||||
|
||||
export type Editor = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
/** Later registrations take precedence. */
|
||||
native: (strategy: NativeStrategy) => void
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -399,18 +380,15 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
|
||||
const state = State.create<Settings, Editor>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
editor: (editor) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) editor.auto = settings.auto
|
||||
if (settings.buffer !== undefined) editor.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) editor.tokens = settings.tokens
|
||||
},
|
||||
native: (strategy) => {
|
||||
editor.native.push(strategy)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
|
||||
@@ -526,23 +504,6 @@ export const layer = Layer.effect(
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
const native = state
|
||||
.get()
|
||||
.native.toReversed()
|
||||
.map((strategy) =>
|
||||
strategy({
|
||||
request,
|
||||
options: prepared.options,
|
||||
retained: original(context.session.id).pipe(
|
||||
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.find((effect) => effect !== undefined)
|
||||
if (!native)
|
||||
return yield* reject(
|
||||
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
|
||||
)
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
model: context.model.ref,
|
||||
@@ -553,7 +514,25 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
// Transient provider failures retry like any other request; only a known automatic overflow permits
|
||||
// local recovery, and nothing is installed until the provider returns a checkpoint.
|
||||
const result = yield* restore(native.pipe(transient))
|
||||
const result = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
|
||||
const result = yield* llm
|
||||
.compact(request, { ...prepared.options, mechanism: "trigger" })
|
||||
.pipe(transient)
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
}
|
||||
if (LLMClient.canCompact(request))
|
||||
return yield* llm
|
||||
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
|
||||
.pipe(transient)
|
||||
// Model resolution admits provider policies only for routes with a compaction operation.
|
||||
return yield* Effect.die(
|
||||
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
it.effect("exposes session messages through the plugin host", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const session = yield* host.session.create({})
|
||||
|
||||
const empty = yield* host.message.list({ sessionID: session.id })
|
||||
expect(empty.data).toEqual([])
|
||||
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello" })
|
||||
const listed = yield* host.message.list({ sessionID: session.id })
|
||||
expect(listed.data.length).toBe(1)
|
||||
const first = listed.data[0]
|
||||
if (!first || first.type !== "synthetic") return yield* Effect.die("expected synthetic message")
|
||||
expect(first.text).toBe("hello")
|
||||
expect(listed.cursor.previous).toBeDefined()
|
||||
expect(listed.cursor.next).toBeDefined()
|
||||
|
||||
const fetched = yield* host.session.message.get({ sessionID: session.id, messageID: first.id })
|
||||
expect(fetched.id).toBe(first.id)
|
||||
|
||||
const missing = yield* host.session.message
|
||||
.get({ sessionID: session.id, messageID: first.id.replace(/^msg_/, "msg_missing_") as typeof first.id })
|
||||
.pipe(Effect.flip)
|
||||
expect(String(missing)).toContain("Message not found")
|
||||
|
||||
const invalid = yield* host.message.list({ sessionID: session.id, cursor: "invalid" }).pipe(Effect.flip)
|
||||
expect(String(invalid)).toContain("Invalid cursor")
|
||||
|
||||
const combined = yield* host.message
|
||||
.list({ sessionID: session.id, cursor: listed.cursor.next, order: "asc" })
|
||||
.pipe(Effect.flip)
|
||||
expect(String(combined)).toContain("Cursor cannot be combined")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes session messages to promise plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const session = yield* host.session.create({})
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID: session.id, text: "hello promise" })
|
||||
|
||||
const seen: string[] = []
|
||||
const definition = fromPromise({
|
||||
id: "message-list",
|
||||
async setup(ctx) {
|
||||
const listed = await ctx.message.list({ sessionID: session.id })
|
||||
const first = listed.data[0]
|
||||
if (first?.type === "synthetic") seen.push(first.text)
|
||||
if (first) {
|
||||
const fetched = await ctx.session.message.get({ sessionID: session.id, messageID: first.id })
|
||||
if (fetched.type === "synthetic") seen.push(fetched.text)
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* definition.effect(host)
|
||||
|
||||
expect(seen).toEqual(["hello promise", "hello promise"])
|
||||
}),
|
||||
)
|
||||
@@ -102,6 +102,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
message: overrides.message ?? {
|
||||
list: () => Effect.die("unused message.list"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
@@ -175,6 +178,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
|
||||
message: overrides.session?.message ?? {
|
||||
get: () => Effect.die("unused session.message.get"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
|
||||
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
@@ -7,7 +7,6 @@ import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { llmClient } from "@opencode/core/effect/app-node-platform"
|
||||
import { Instructions } from "@opencode/core/instructions/index"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
@@ -27,7 +26,6 @@ import { SessionStore } from "@opencode/core/session/store"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
@@ -46,8 +44,7 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
|
||||
const endpoint = options.endpoint ?? false
|
||||
const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
@@ -188,7 +185,6 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
yield* InstructionState.prepare(db, bus, instructions, sessionID)
|
||||
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.headers["x-test-hook"] = event.kind
|
||||
@@ -265,7 +261,6 @@ const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin
|
||||
store,
|
||||
hooks,
|
||||
model,
|
||||
compaction,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -353,7 +348,7 @@ it.live(
|
||||
|
||||
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({ endpoint: true })
|
||||
const fixture = yield* setup(true)
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(yield* fixture.automatic).toEqual({ status: "completed" })
|
||||
@@ -450,31 +445,6 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* setup({ plugin: false })
|
||||
yield* fixture.prompt("Original user")
|
||||
expect(yield* fixture.compact).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
|
||||
})
|
||||
yield* fixture.compaction.transform((editor) => {
|
||||
editor.native(() =>
|
||||
Effect.succeed({
|
||||
replacement: [Message.assistant("plugin window")],
|
||||
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* fixture.compact).toEqual({ status: "completed" })
|
||||
expect(fixture.state.calls).toBe(0)
|
||||
const installed = yield* fixture.checkpoint
|
||||
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
|
||||
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
|
||||
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
|
||||
}),
|
||||
)
|
||||
|
||||
test("retained user budget counts attachments and drops whole oldest messages", () => {
|
||||
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -56,7 +56,6 @@ import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
|
||||
import { IdentityPlugin } from "@opencode/core/plugin/identity"
|
||||
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Config } from "@opencode/core/config"
|
||||
@@ -471,7 +470,6 @@ const layer = Layer.unwrap(
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionCompaction.node,
|
||||
LayerNodePlatform.llmClient,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
@@ -525,7 +523,6 @@ const setup = Effect.gen(function* () {
|
||||
discard: true,
|
||||
})
|
||||
yield* IdentityPlugin.Plugin.effect(pluginHost)
|
||||
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("build"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { MessageApi } from "@opencode/client/effect/api"
|
||||
|
||||
export interface MessageDomain extends MessageApi<unknown> {}
|
||||
@@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { MessageDomain } from "./message.js"
|
||||
import type { ModelDomain } from "./model.js"
|
||||
import type { PermissionDomain } from "./permission.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
@@ -36,6 +37,7 @@ export interface Context {
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly message: MessageDomain
|
||||
readonly model: ModelDomain
|
||||
readonly generate: GenerateApi<unknown>
|
||||
readonly permission: PermissionDomain
|
||||
|
||||
@@ -165,6 +165,7 @@ export type SessionDomain = Pick<
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
| "message"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
const GenerateEndpoints = ClientApi.groups["server.generate"].endpoints
|
||||
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
|
||||
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
|
||||
const MessageEndpoints = ClientApi.groups["server.message"].endpoints
|
||||
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
|
||||
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
|
||||
const PermissionEndpoints = ClientApi.groups["server.permission"].endpoints
|
||||
@@ -433,6 +434,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
transform: transform(host.mcp),
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
message: {
|
||||
list: adaptApiMethod(MessageEndpoints["session.messages"], host.message.list),
|
||||
},
|
||||
permission: {
|
||||
hook: (name, callback) =>
|
||||
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
@@ -586,6 +590,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
|
||||
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
|
||||
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
|
||||
message: {
|
||||
get: adaptApiMethod(SessionEndpoints["session.message"], host.session.message.get),
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) =>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { MessageApi } from "@opencode/client/promise/api"
|
||||
|
||||
export interface MessageDomain extends MessageApi {}
|
||||
@@ -9,6 +9,7 @@ import type { CommandDomain } from "./command.js"
|
||||
import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { MCPDomain } from "./mcp.js"
|
||||
import type { MessageDomain } from "./message.js"
|
||||
import type { ModelDomain } from "./model.js"
|
||||
import type { PermissionDomain } from "./permission.js"
|
||||
import type { ProviderDomain } from "./provider.js"
|
||||
@@ -36,6 +37,7 @@ export interface Context {
|
||||
}
|
||||
readonly integration: IntegrationDomain
|
||||
readonly mcp: MCPDomain
|
||||
readonly message: MessageDomain
|
||||
readonly model: ModelDomain
|
||||
readonly generate: GenerateApi
|
||||
readonly permission: PermissionDomain
|
||||
|
||||
@@ -165,6 +165,7 @@ export type SessionDomain = Pick<
|
||||
| "move"
|
||||
| "wait"
|
||||
| "context"
|
||||
| "message"
|
||||
> & {
|
||||
readonly hook: ModelHooks<SessionHooks>
|
||||
}
|
||||
|
||||
@@ -968,8 +968,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
{
|
||||
name: "opencode.update",
|
||||
title: "Update OpenCode",
|
||||
description: "Update OpenCode (upgrade)",
|
||||
slash: { name: "update" },
|
||||
slash: { name: "update", aliases: ["upgrade"] },
|
||||
run: () => updater.open?.("manual"),
|
||||
category: "System",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user