mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-28 03:27:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b58f08e65 |
@@ -540,6 +540,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
yield* sessions.setPermissions({ sessionID: input.sessionID, permissions: input.permissions })
|
||||
}),
|
||||
move: sessions.move,
|
||||
compact: sessions.compact,
|
||||
synthetic: sessions.synthetic,
|
||||
interrupt: (input) =>
|
||||
sessions
|
||||
|
||||
@@ -3,11 +3,41 @@ export * as OpenCodeTools from "./opencode.js"
|
||||
import { SystemPart, ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const SessionInfoInput = Schema.Struct({
|
||||
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to inspect the current session." }),
|
||||
})
|
||||
|
||||
const SessionInfoOutput = Schema.Struct({
|
||||
sessionID: Session.ID,
|
||||
parentID: Schema.NullOr(Session.ID),
|
||||
title: Schema.NullOr(Schema.String),
|
||||
directory: AbsolutePath,
|
||||
agent: Schema.NullOr(Agent.ID),
|
||||
model: Schema.NullOr(Model.Ref),
|
||||
limits: Schema.NullOr(Model.Info.fields.limit).annotate({
|
||||
description: "Model token limits. Null when the model is unavailable in this Location's catalog.",
|
||||
}),
|
||||
cost: Session.Info.fields.cost.annotate({ description: "Cumulative session cost in USD." }),
|
||||
context: Schema.Struct({
|
||||
tokens: Schema.NullOr(Schema.Finite),
|
||||
percent: Schema.NullOr(Schema.Finite),
|
||||
remaining: Schema.NullOr(Schema.Finite),
|
||||
source: Schema.Literals(["last_completed_step", "unavailable"]),
|
||||
messageID: Schema.NullOr(SessionMessage.ID),
|
||||
}).annotate({
|
||||
description:
|
||||
"Latest completed step's input (including cache), output, and reasoning tokens; excludes subsequent messages and tool results. Not cumulative usage. Null values mean unknown, including after compaction or a model switch until fresh usage is recorded. Percentage and remaining tokens use limits.context.",
|
||||
}),
|
||||
})
|
||||
|
||||
export const RenameInput = Schema.Struct({
|
||||
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to rename the current session." }),
|
||||
title: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "New session title." }),
|
||||
@@ -24,6 +54,12 @@ export const MoveInput = Schema.Struct({
|
||||
|
||||
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
|
||||
|
||||
export const CompactInput = Schema.Struct({
|
||||
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to compact the current session." }),
|
||||
})
|
||||
|
||||
const CompactOutput = Schema.Struct({ sessionID: Session.ID, id: SessionMessage.ID })
|
||||
|
||||
export const ModelsInput = Schema.Struct({
|
||||
query: Schema.optionalKey(Schema.String).annotate({
|
||||
description: "Text to search for in model names and IDs.",
|
||||
@@ -86,6 +122,65 @@ export const Plugin = {
|
||||
description:
|
||||
"Tools for managing OpenCode itself, such as working with sessions and searching the available models.",
|
||||
})
|
||||
draft.add({
|
||||
name: "session_info",
|
||||
description:
|
||||
"Get session identity, model limits, cost, and the latest measured context usage. Omit sessionID for the current session. Check this before deciding to compact; low usage generally does not warrant compaction. Usage excludes work since the source message; unavailable does not mean zero.",
|
||||
input: SessionInfoInput,
|
||||
output: SessionInfoOutput,
|
||||
options: { namespace: "opencode", codemode: true, pinned: true },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = input.sessionID ?? context.sessionID
|
||||
const session = yield* ctx.session.get({ sessionID })
|
||||
const messages = yield* ctx.session.context({ sessionID })
|
||||
const latest = messages.findLast((message) => message.type === "assistant")
|
||||
const model = session.model ?? latest?.model
|
||||
const limits =
|
||||
session.location.directory === ctx.location.directory &&
|
||||
session.location.workspaceID === ctx.location.workspaceID
|
||||
? (yield* ctx.model.list()).data.find(
|
||||
(item) => item.providerID === model?.providerID && item.id === model?.id,
|
||||
)?.limit
|
||||
: undefined
|
||||
const last = messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" &&
|
||||
message.time.completed !== undefined &&
|
||||
!message.error &&
|
||||
message.tokens !== undefined &&
|
||||
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
|
||||
)
|
||||
const measured =
|
||||
!session.revert &&
|
||||
last?.type === "assistant" &&
|
||||
last.model.providerID === model?.providerID &&
|
||||
last.model.id === model?.id
|
||||
? last
|
||||
: undefined
|
||||
const tokens = measured?.tokens ? TokenUsage.total(measured.tokens) : null
|
||||
const capacity = limits && limits.context > 0 ? limits.context : undefined
|
||||
return {
|
||||
output: {
|
||||
sessionID,
|
||||
parentID: session.parentID ?? null,
|
||||
title: session.title ?? null,
|
||||
directory: session.location.directory,
|
||||
agent: session.agent ?? (sessionID === context.sessionID ? context.agent : latest?.agent) ?? null,
|
||||
model: model ?? null,
|
||||
limits: limits ?? null,
|
||||
cost: session.cost,
|
||||
context: {
|
||||
tokens,
|
||||
percent: tokens !== null && capacity ? Math.round((tokens / capacity) * 1000) / 10 : null,
|
||||
remaining: tokens !== null && capacity ? Math.max(0, capacity - tokens) : null,
|
||||
source: tokens === null ? ("unavailable" as const) : ("last_completed_step" as const),
|
||||
messageID: measured?.id ?? null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to get session info", error }))),
|
||||
})
|
||||
draft.add({
|
||||
name: "session_rename",
|
||||
description:
|
||||
@@ -131,6 +226,26 @@ export const Plugin = {
|
||||
),
|
||||
),
|
||||
})
|
||||
draft.add({
|
||||
name: "session_compact",
|
||||
description:
|
||||
"Request compaction of a session, or omit sessionID to compact the current session. Check session_info first; avoid compaction at low context usage unless the user explicitly requests it. Returns after the request is admitted; compaction runs at the next step boundary, after current tool calls finish.",
|
||||
input: CompactInput,
|
||||
output: CompactOutput,
|
||||
options: { namespace: "opencode", codemode: true, pinned: true },
|
||||
execute: (input, context) => {
|
||||
const sessionID = input.sessionID ?? context.sessionID
|
||||
return ctx.session.compact({ sessionID, delivery: "steer" }).pipe(
|
||||
Effect.map((request) => ({
|
||||
output: { sessionID, id: request.id },
|
||||
content: `Requested compaction of session ${sessionID}. It will run at the next step boundary.`,
|
||||
})),
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to request compaction of session ${sessionID}`, error }),
|
||||
),
|
||||
)
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "models",
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
import { Schema } from "effect"
|
||||
|
||||
// Public-API-only prototype for evaluating phase-aware compaction plugins.
|
||||
export const PhaseCompactionPlugin = Plugin.define({
|
||||
id: "phase-compaction-prototype",
|
||||
async setup(ctx) {
|
||||
const options = Schema.decodeUnknownSync(
|
||||
Schema.Struct({
|
||||
minimumPercent: Schema.optionalKey(Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||
}),
|
||||
)(ctx.options)
|
||||
|
||||
await ctx.session.hook("context", (event) => {
|
||||
event.system.push({
|
||||
type: "text",
|
||||
text: "At a meaningful phase boundary, use tools.phase.checkpoint with the next action and facts to preserve. It checks context usage before requesting compaction. Continue working after the tool returns; do not checkpoint after every small step.",
|
||||
})
|
||||
})
|
||||
await ctx.session.hook("compaction", (event) => {
|
||||
event.system.push({
|
||||
type: "text",
|
||||
text: "When summarizing a phase checkpoint, preserve its next action and essential handoff facts in the checkpoint. Treat completed phases as completed work.",
|
||||
})
|
||||
})
|
||||
await ctx.tool.transform((editor) => {
|
||||
editor.namespace({ name: "phase", description: "Checkpoint completed phases of a task." })
|
||||
editor.add({
|
||||
name: "checkpoint",
|
||||
description: "Record a phase handoff and request compaction when measured context usage is high enough.",
|
||||
input: Schema.Struct({
|
||||
next: Schema.String.check(Schema.isMinLength(1)),
|
||||
preserve: Schema.Array(Schema.String),
|
||||
}),
|
||||
output: Schema.Struct({
|
||||
status: Schema.Literals(["skipped", "requested"]),
|
||||
reason: Schema.String,
|
||||
percent: Schema.NullOr(Schema.Finite),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
next: Schema.String,
|
||||
preserve: Schema.Array(Schema.String),
|
||||
}),
|
||||
options: { namespace: "phase", codemode: true, pinned: true },
|
||||
async execute(input, tool) {
|
||||
const session = await ctx.session.get({ sessionID: tool.sessionID })
|
||||
const messages = await ctx.session.context({ sessionID: tool.sessionID })
|
||||
const last = messages.findLast(
|
||||
(message) =>
|
||||
message.type === "assistant" && message.time.completed !== undefined && !message.error && message.tokens,
|
||||
)
|
||||
const selected = session.model ?? (last?.type === "assistant" ? last.model : undefined)
|
||||
const model = (await ctx.model.list()).data.find(
|
||||
(model) => model.providerID === selected?.providerID && model.id === selected?.id,
|
||||
)
|
||||
const usage =
|
||||
!session.revert &&
|
||||
last?.type === "assistant" &&
|
||||
last.model.providerID === selected?.providerID &&
|
||||
last.model.id === selected?.id
|
||||
? last.tokens
|
||||
: undefined
|
||||
const percent =
|
||||
usage && usage.input + usage.cache.read + usage.cache.write > 0 && model && model.limit.context > 0
|
||||
? ((usage.input + usage.cache.read + usage.cache.write + usage.output + usage.reasoning) /
|
||||
model.limit.context) *
|
||||
100
|
||||
: null
|
||||
if (percent === null || percent < (options.minimumPercent ?? 50))
|
||||
return {
|
||||
output: {
|
||||
...input,
|
||||
status: "skipped" as const,
|
||||
percent,
|
||||
reason: percent === null ? "Usage unavailable" : "Low context usage",
|
||||
},
|
||||
}
|
||||
|
||||
const request = await ctx.session.compact({ sessionID: tool.sessionID })
|
||||
// The handoff remains in the durable tool exchange; the API has no per-request summary prompt.
|
||||
return {
|
||||
output: {
|
||||
...input,
|
||||
status: "requested" as const,
|
||||
percent,
|
||||
id: request.id,
|
||||
reason: "Phase checkpoint admitted",
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -171,6 +171,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
|
||||
update: overrides.session?.update ?? (() => Effect.die("unused session.update")),
|
||||
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
|
||||
compact: overrides.session?.compact ?? (() => Effect.die("unused session.compact")),
|
||||
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
|
||||
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
|
||||
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AnthropicMessages, OpenAIResponses } from "@opencode/ai/protocols"
|
||||
import { compileRequest } from "@opencode/ai/route/client"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { fromPromise } from "@opencode/plugin/promise/adapter"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
@@ -57,6 +58,7 @@ import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
|
||||
import { IdentityPlugin } from "@opencode/core/plugin/identity"
|
||||
import { QuestionTool } from "@opencode/core/tool/plugin/question"
|
||||
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
@@ -80,7 +82,9 @@ import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Sc
|
||||
import { TestClock } from "effect/testing"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { registerToolPlugin } from "./lib/tool"
|
||||
import { promptLocationNode } from "./fixture/prompt-location"
|
||||
import { PhaseCompactionPlugin } from "./fixture/phase-compaction-plugin"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Expected } from "./lib/session-message"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
@@ -568,6 +572,34 @@ const setup = Effect.gen(function* () {
|
||||
})
|
||||
|
||||
type Scenario = Effect.Success<typeof setup>
|
||||
|
||||
const installSessionPlugin = Effect.fn(function* (s: Scenario, plugin: Parameters<typeof fromPromise>[0]) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const models = yield* Model.Service
|
||||
const session = yield* s.session.get(sessionID)
|
||||
const info = new Location.Info({
|
||||
...session.location,
|
||||
project: { id: session.projectID, directory: session.location.directory, canonical: session.location.directory },
|
||||
})
|
||||
yield* registerToolPlugin(fromPromise(plugin), {
|
||||
location: info,
|
||||
model: {
|
||||
...modelHost(models),
|
||||
list: () =>
|
||||
Effect.succeed({
|
||||
location: info,
|
||||
data: [Model.Info.default(Provider.ID.make(s.currentModel.provider), Model.ID.make(s.currentModel.id))],
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
get: (input) => s.session.get(input.sessionID),
|
||||
context: (input) => s.session.context(input.sessionID),
|
||||
compact: s.session.compact,
|
||||
hook: (name, callback) => hooks.register("session", name, callback),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const scenario = (
|
||||
name: string,
|
||||
body: (s: Scenario) => Effect.gen.Return<void, unknown, Layer.Success<typeof layer> | Scope.Scope>,
|
||||
@@ -2437,6 +2469,116 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
})
|
||||
|
||||
scenario("lets an agent compact its own session through Code Mode and continue", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* registerToolPlugin(OpenCodeTools.Plugin, { session: { compact: s.session.compact } })
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-self-compact", "execute", { code: "return await tools.opencode.session_compact({})" }),
|
||||
[LLMEvent.textDelta({ id: "summary", text: "## Objective\n- Continue after self-compaction" })],
|
||||
TestLLM.text("Continued after compaction", "text-self-compact-continued"),
|
||||
)
|
||||
|
||||
yield* s.runPrompt("Compact this session and continue working")
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
const messages = (yield* s.messages).toReversed()
|
||||
expect(messages.filter((message) => message.type === "assistant" || message.type === "compaction")).toMatchObject([
|
||||
Expected.assistant({}, [Expected.completedTool({ id: "call-self-compact" }, {})]),
|
||||
{
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "## Objective\n- Continue after self-compaction",
|
||||
},
|
||||
Expected.assistant({}, [Expected.text("Continued after compaction")]),
|
||||
])
|
||||
expect(JSON.stringify(s.requests[2].messages)).toContain("Continue after self-compaction")
|
||||
})
|
||||
|
||||
for (const inputTokens of [undefined, 4_000, 120_000]) {
|
||||
scenario(`public Promise phase plugin: ${inputTokens ?? "unknown"} input tokens`, function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
inputTokens === undefined
|
||||
? TestLLM.text("Research finished", "phase-research")
|
||||
: TestLLM.textWithUsage("Research finished", "phase-research", inputTokens),
|
||||
)
|
||||
yield* s.runPrompt("Research the change")
|
||||
yield* installSessionPlugin(s, PhaseCompactionPlugin)
|
||||
s.requests.length = 0
|
||||
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-phase-checkpoint", "execute", {
|
||||
code: 'return await tools.phase.checkpoint({ next: "Implement validation", preserve: ["Reuse the existing parser"] })',
|
||||
}),
|
||||
...(inputTokens === 120_000 ? [TestLLM.text("## Objective\n- Complete the change", "phase-summary")] : []),
|
||||
TestLLM.text("Implementation continued", "phase-continued"),
|
||||
)
|
||||
yield* s.runPrompt("Proceed to implementation")
|
||||
|
||||
expect(s.requests).toHaveLength(inputTokens === 120_000 ? 3 : 2)
|
||||
expect(s.requests[0].system.some((part) => part.text.includes("At a meaningful phase boundary"))).toBe(true)
|
||||
const messages = yield* s.messages
|
||||
const checkpoint = messages.find((message) => message.type === "compaction")
|
||||
const call = messages
|
||||
.flatMap((message) => (message.type === "assistant" ? message.content : []))
|
||||
.find((part) => part.type === "tool" && part.id === "call-phase-checkpoint")
|
||||
expect(call).toMatchObject(
|
||||
Expected.completedTool(
|
||||
{ id: "call-phase-checkpoint" },
|
||||
{
|
||||
content: [Expected.text(expect.stringContaining(inputTokens === 120_000 ? '"requested"' : '"skipped"'))],
|
||||
},
|
||||
),
|
||||
)
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
if (inputTokens !== 120_000) {
|
||||
expect(checkpoint).toBeUndefined()
|
||||
return
|
||||
}
|
||||
expect(checkpoint).toMatchObject({ type: "compaction", status: "completed" })
|
||||
expect(s.requests[1].system.some((part) => part.text.includes("essential handoff facts"))).toBe(true)
|
||||
expect(JSON.stringify(s.requests[2].messages)).toContain("Implement validation")
|
||||
expect(JSON.stringify(s.requests[2].messages)).toContain("Reuse the existing parser")
|
||||
})
|
||||
}
|
||||
|
||||
scenario("compaction requested by a Promise context hook follows the prepared step", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(TestLLM.textWithUsage("Research finished", "hook-research", 120_000))
|
||||
yield* s.runPrompt("Research the change")
|
||||
let requested = false
|
||||
yield* installSessionPlugin(s, {
|
||||
id: "context-compaction-probe",
|
||||
async setup(ctx) {
|
||||
await ctx.session.hook("context", async (event) => {
|
||||
if (requested) return
|
||||
requested = true
|
||||
await ctx.session.compact({ sessionID: event.sessionID })
|
||||
})
|
||||
},
|
||||
})
|
||||
s.requests.length = 0
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("The prepared step still ran", "before-hook-checkpoint"),
|
||||
TestLLM.text("## Objective\n- Continue the task", "hook-checkpoint"),
|
||||
)
|
||||
|
||||
yield* s.runPrompt("Continue the task")
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(
|
||||
(yield* s.messages)
|
||||
.toReversed()
|
||||
.filter((message) => message.type === "assistant" || message.type === "compaction"),
|
||||
).toMatchObject([
|
||||
Expected.assistant({}, [Expected.text("Research finished")]),
|
||||
Expected.assistant({}, [Expected.text("The prepared step still ran")]),
|
||||
{ type: "compaction", status: "completed" },
|
||||
])
|
||||
})
|
||||
|
||||
scenario("preserves provider errors from manual compaction", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "text-manual-provider-history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, toolIdentity } from "./lib/tool"
|
||||
import { PluginTestLayer } from "./plugin/fixture"
|
||||
@@ -26,6 +31,116 @@ const gammaOld = {
|
||||
status: "active",
|
||||
}
|
||||
|
||||
it.live("reports the latest context measurement separately from cumulative session usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sessionInfoFixture
|
||||
expect(yield* fixture.get()).toMatchObject({
|
||||
sessionID: fixture.session.id,
|
||||
title: "Context info",
|
||||
directory: fixture.session.location.directory,
|
||||
agent: "build",
|
||||
model: fixture.model,
|
||||
limits: { context: 100_000, input: 80_000, output: 10_000 },
|
||||
cost: 0,
|
||||
context: { tokens: null, percent: null, remaining: null, source: "unavailable", messageID: null },
|
||||
})
|
||||
|
||||
yield* fixture.record({ input: 50_000, output: 1_000, reasoning: 0, cache: { read: 0, write: 0 } })
|
||||
const messageID = yield* fixture.record({
|
||||
input: 200,
|
||||
output: 100,
|
||||
reasoning: 100,
|
||||
cache: { read: 1_500, write: 100 },
|
||||
})
|
||||
// An in-flight step has no settled usage yet; its presence must not turn the reading into zero.
|
||||
yield* fixture.bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: fixture.session.id,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
agent: toolIdentity.agent,
|
||||
model: fixture.model,
|
||||
})
|
||||
expect(yield* fixture.get()).toMatchObject({
|
||||
cost: 0.2,
|
||||
context: { tokens: 2_000, percent: 2, remaining: 98_000, source: "last_completed_step", messageID },
|
||||
})
|
||||
|
||||
yield* fixture.catalog.transform((editor) =>
|
||||
editor.models.update(fixture.model.providerID, fixture.model.id, (model) => {
|
||||
model.limit.context = 0
|
||||
}),
|
||||
)
|
||||
expect(yield* fixture.get({ sessionID: fixture.session.id })).toMatchObject({
|
||||
context: { tokens: 2_000, percent: null, remaining: null, source: "last_completed_step", messageID },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("invalidates context usage after compaction and a model switch", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* sessionInfoFixture
|
||||
const tokens = { input: 70_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
yield* fixture.record(tokens)
|
||||
yield* fixture.bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: fixture.session.id,
|
||||
reason: "manual",
|
||||
recent: "",
|
||||
})
|
||||
yield* fixture.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: fixture.session.id,
|
||||
reason: "manual",
|
||||
model: fixture.model,
|
||||
text: "## Objective\n- Keep working",
|
||||
recent: "",
|
||||
tokens,
|
||||
})
|
||||
expect(yield* fixture.get()).toMatchObject({
|
||||
context: { tokens: null, percent: null, remaining: null, source: "unavailable", messageID: null },
|
||||
})
|
||||
|
||||
yield* fixture.record({ ...tokens, input: 2_000 })
|
||||
const other = Model.Ref.make({ ...fixture.model, id: Model.ID.make("other") })
|
||||
yield* fixture.sessions.switchModel({ sessionID: fixture.session.id, model: other })
|
||||
expect(yield* fixture.get()).toMatchObject({
|
||||
model: other,
|
||||
limits: null,
|
||||
context: { tokens: null, percent: null, remaining: null, source: "unavailable", messageID: null },
|
||||
})
|
||||
yield* fixture.record(tokens, other)
|
||||
yield* fixture.sessions.switchModel({ sessionID: fixture.session.id, model: fixture.model })
|
||||
// Switching back must not resurrect an older sample from before the intervening model's work.
|
||||
expect(yield* fixture.get()).toMatchObject({ context: { tokens: null, source: "unavailable" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reports an unknown compaction target through the plugin host", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const sessions = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* Tool.Service
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* OpenCodeTools.Plugin.effect(pluginHost)
|
||||
const session = yield* sessions.create({ location: Location.Ref.make({ directory: location.directory }) })
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID: session.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-compact-missing",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.opencode.session_compact({ sessionID: "ses_missing" })' },
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
metadata: { error: true },
|
||||
content: [{ type: "text", text: "Unable to request compaction of session ses_missing" }],
|
||||
})
|
||||
expect(yield* sessions.inbox(session.id)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("groups available models by provider with paging", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Provider.Service
|
||||
@@ -132,3 +247,67 @@ it.effect("groups available models by provider with paging", () =>
|
||||
expect(yield* run({ provider: "other", query: "alpha" })).toEqual({ providers: [], total: 0, next: null })
|
||||
}),
|
||||
)
|
||||
|
||||
const sessionInfoFixture = Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Provider.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const sessions = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const registry = yield* Tool.Service
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* OpenCodeTools.Plugin.effect(pluginHost)
|
||||
const model = Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("alpha") })
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.models.update(model.providerID, model.id, (model) => {
|
||||
model.limit = { context: 100_000, input: 80_000, output: 10_000 }
|
||||
}),
|
||||
)
|
||||
const session = yield* sessions.create({
|
||||
title: "Context info",
|
||||
model,
|
||||
location: Location.Ref.make({ directory: location.directory }),
|
||||
})
|
||||
return {
|
||||
bus,
|
||||
catalog,
|
||||
sessions,
|
||||
session,
|
||||
model,
|
||||
get: (input: { sessionID?: string } = {}) =>
|
||||
executeTool(registry, {
|
||||
sessionID: session.id,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-session-info",
|
||||
name: "execute",
|
||||
input: { code: `return await tools.opencode.session_info(${JSON.stringify(input)})` },
|
||||
},
|
||||
}).pipe(
|
||||
Effect.map((result) => {
|
||||
expect(result.metadata?.error).not.toBe(true)
|
||||
return Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
result.content?.[0]?.type === "text" ? result.content[0].text : "",
|
||||
)
|
||||
}),
|
||||
),
|
||||
record: Effect.fn(function* (tokens: TokenUsage.Info, selected = model) {
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
agent: toolIdentity.agent,
|
||||
model: selected,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: session.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
cost: Money.USD.make(0.1),
|
||||
tokens,
|
||||
})
|
||||
return assistantMessageID
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -136,6 +136,7 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "update"
|
||||
| "move"
|
||||
| "compact"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
@@ -584,6 +584,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
|
||||
update: adaptApiMethod(SessionEndpoints["session.update"], host.session.update),
|
||||
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
|
||||
compact: adaptApiMethod(SessionEndpoints["session.compact"], host.session.compact),
|
||||
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
|
||||
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
|
||||
},
|
||||
|
||||
@@ -136,6 +136,7 @@ export type SessionDomain = Pick<
|
||||
| "interrupt"
|
||||
| "update"
|
||||
| "move"
|
||||
| "compact"
|
||||
| "wait"
|
||||
| "context"
|
||||
> & {
|
||||
|
||||
Reference in New Issue
Block a user