mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
2088 lines
69 KiB
TypeScript
2088 lines
69 KiB
TypeScript
import { afterEach, describe, expect, mock, test } from "bun:test"
|
|
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { APICallError } from "ai"
|
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect"
|
|
import * as Stream from "effect/Stream"
|
|
import { Config } from "@/config/config"
|
|
import { Image } from "@/image/image"
|
|
import { Agent } from "../../src/agent/agent"
|
|
import { LLM } from "../../src/session/llm"
|
|
import { SessionCompaction } from "../../src/session/compaction"
|
|
import { Token } from "@/util/token"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { Permission } from "../../src/permission"
|
|
import { Plugin } from "../../src/plugin"
|
|
import { provideTmpdirInstance, TestInstance } from "../fixture/fixture"
|
|
import { Session as SessionNs } from "@/session/session"
|
|
import { MessageV2 } from "../../src/session/message-v2"
|
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|
import { SessionStatus } from "../../src/session/status"
|
|
import { SessionSummary } from "../../src/session/summary"
|
|
import { SessionV2 } from "@opencode-ai/core/session"
|
|
|
|
import type { Provider } from "@/provider/provider"
|
|
import * as SessionProcessorModule from "../../src/session/processor"
|
|
import { Snapshot } from "../../src/snapshot"
|
|
import { ProviderTest } from "../fake/provider"
|
|
import { testEffect } from "../lib/effect"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { TestConfig } from "../fixture/config"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { LLMEvent, Usage } from "@opencode-ai/llm"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
|
|
void Log.init({ print: false })
|
|
|
|
const summary = Layer.succeed(
|
|
SessionSummary.Service,
|
|
SessionSummary.Service.of({
|
|
summarize: () => Effect.void,
|
|
diff: () => Effect.succeed([]),
|
|
computeDiff: () => Effect.succeed([]),
|
|
}),
|
|
)
|
|
|
|
const ref = {
|
|
providerID: ProviderV2.ID.make("test"),
|
|
modelID: ModelV2.ID.make("test-model"),
|
|
}
|
|
|
|
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
|
|
|
|
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
|
|
|
|
afterEach(() => {
|
|
mock.restore()
|
|
})
|
|
|
|
function createModel(opts: {
|
|
context: number
|
|
output: number
|
|
input?: number
|
|
cost?: Provider.Model["cost"]
|
|
npm?: string
|
|
}): Provider.Model {
|
|
return {
|
|
id: "test-model",
|
|
providerID: "test",
|
|
name: "Test",
|
|
limit: {
|
|
context: opts.context,
|
|
input: opts.input,
|
|
output: opts.output,
|
|
},
|
|
cost: opts.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
capabilities: {
|
|
toolcall: true,
|
|
attachment: false,
|
|
reasoning: false,
|
|
temperature: true,
|
|
input: { text: true, image: false, audio: false, video: false },
|
|
output: { text: true, image: false, audio: false, video: false },
|
|
},
|
|
api: { npm: opts.npm ?? "@ai-sdk/anthropic" },
|
|
options: {},
|
|
} as Provider.Model
|
|
}
|
|
|
|
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
|
|
|
|
function createUserMessage(sessionID: SessionID, text: string) {
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const msg = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID,
|
|
type: "text",
|
|
text,
|
|
})
|
|
return msg
|
|
})
|
|
}
|
|
|
|
function createAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string) {
|
|
return SessionNs.Service.use((ssn) =>
|
|
ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
sessionID,
|
|
mode: "build",
|
|
agent: "build",
|
|
path: { cwd: root, root },
|
|
cost: 0,
|
|
tokens: {
|
|
output: 0,
|
|
input: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
parentID,
|
|
time: { created: Date.now() },
|
|
finish: "end_turn",
|
|
}),
|
|
)
|
|
}
|
|
|
|
function createSummaryAssistantMessage(sessionID: SessionID, parentID: MessageID, root: string, text: string) {
|
|
return SessionNs.Service.use((ssn) =>
|
|
Effect.gen(function* () {
|
|
const msg = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
sessionID,
|
|
mode: "compaction",
|
|
agent: "compaction",
|
|
path: { cwd: root, root },
|
|
cost: 0,
|
|
tokens: {
|
|
output: 0,
|
|
input: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
parentID,
|
|
summary: true,
|
|
time: { created: Date.now() },
|
|
finish: "end_turn",
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID,
|
|
type: "text",
|
|
text,
|
|
})
|
|
return msg
|
|
}),
|
|
)
|
|
}
|
|
|
|
function createCompactionMarker(sessionID: SessionID) {
|
|
return SessionNs.Service.use((ssn) =>
|
|
Effect.gen(function* () {
|
|
const msg = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
model: ref,
|
|
sessionID,
|
|
agent: "build",
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID: msg.sessionID,
|
|
type: "compaction",
|
|
auto: false,
|
|
})
|
|
}),
|
|
)
|
|
}
|
|
|
|
function fake(
|
|
input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0],
|
|
result: "continue" | "compact",
|
|
) {
|
|
const msg = input.assistantMessage
|
|
return {
|
|
get message() {
|
|
return msg
|
|
},
|
|
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
|
|
metadata: Effect.fn("TestSessionProcessor.metadata")(() => Effect.void), // kilocode_change
|
|
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
|
|
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
|
|
} satisfies SessionProcessorModule.SessionProcessor.Handle
|
|
}
|
|
|
|
function layer(result: "continue" | "compact") {
|
|
return Layer.succeed(
|
|
SessionProcessorModule.SessionProcessor.Service,
|
|
SessionProcessorModule.SessionProcessor.Service.of({
|
|
create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result))),
|
|
}),
|
|
)
|
|
}
|
|
|
|
function cfg(compaction?: ConfigV1.Info["compaction"]) {
|
|
const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info
|
|
return TestConfig.layer({
|
|
get: () => Effect.succeed({ ...base, compaction }),
|
|
})
|
|
}
|
|
|
|
const deps = Layer.mergeAll(
|
|
wide().layer,
|
|
layer("continue"),
|
|
Agent.defaultLayer,
|
|
Plugin.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
Config.defaultLayer,
|
|
RuntimeFlags.layer({ experimentalEventSystem: true }),
|
|
Database.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
)
|
|
|
|
const env = Layer.mergeAll(
|
|
SessionNs.defaultLayer,
|
|
Database.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
CrossSpawnSpawner.defaultLayer,
|
|
SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)),
|
|
)
|
|
|
|
const it = testEffect(env)
|
|
|
|
const compactionEnv = Layer.mergeAll(
|
|
SessionNs.defaultLayer,
|
|
Database.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
CrossSpawnSpawner.defaultLayer,
|
|
)
|
|
const itCompaction = testEffect(compactionEnv)
|
|
|
|
type CompactionProcessOptions = {
|
|
result?: "continue" | "compact"
|
|
llm?: Layer.Layer<LLM.Service>
|
|
plugin?: Layer.Layer<Plugin.Service>
|
|
provider?: ReturnType<typeof ProviderTest.fake>
|
|
config?: Layer.Layer<Config.Service>
|
|
flags?: Partial<RuntimeFlags.Info> // kilocode_change
|
|
snapshot?: Layer.Layer<Snapshot.Service> // kilocode_change
|
|
}
|
|
|
|
function withCompaction(options?: CompactionProcessOptions) {
|
|
return Effect.provide(compactionProcessLayer(options))
|
|
}
|
|
|
|
function compactionProcessLayer(options?: CompactionProcessOptions) {
|
|
const events = EventV2Bridge.defaultLayer
|
|
const status = SessionStatus.layer.pipe(Layer.provide(events))
|
|
const processor = options?.llm
|
|
? SessionProcessorModule.SessionProcessor.layer.pipe(
|
|
Layer.provide(summary),
|
|
Layer.provide(Image.defaultLayer),
|
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })),
|
|
Layer.provide(status),
|
|
)
|
|
: layer(options?.result ?? "continue")
|
|
return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe(
|
|
Layer.provide(SessionNs.defaultLayer),
|
|
Layer.provide((options?.provider ?? wide()).layer),
|
|
Layer.provide(options?.snapshot ?? Snapshot.defaultLayer), // kilocode_change
|
|
Layer.provide(options?.llm ?? LLM.defaultLayer),
|
|
Layer.provide(Permission.defaultLayer),
|
|
Layer.provide(Agent.defaultLayer),
|
|
Layer.provide(options?.plugin ?? Plugin.defaultLayer),
|
|
Layer.provide(status),
|
|
Layer.provide(events),
|
|
Layer.provide(options?.config ?? Config.defaultLayer),
|
|
Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true, ...options?.flags })), // kilocode_change
|
|
Layer.provide(EventV2Bridge.defaultLayer),
|
|
)
|
|
}
|
|
|
|
// kilocode_change start - keep retry-backoff cancellation tests independent of git snapshot cleanup latency
|
|
const snap = Layer.succeed(
|
|
Snapshot.Service,
|
|
Snapshot.Service.of({
|
|
init: () => Effect.void,
|
|
cleanup: () => Effect.void,
|
|
track: () => Effect.succeed(undefined),
|
|
patch: (hash) => Effect.succeed({ hash, files: [] }),
|
|
restore: () => Effect.void,
|
|
revert: () => Effect.void,
|
|
diff: () => Effect.succeed(""),
|
|
diffFull: () => Effect.succeed([]),
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
function createSummaryCompaction(sessionID: SessionID) {
|
|
return SessionCompaction.use.create({ sessionID, agent: "build", model: ref, auto: false })
|
|
}
|
|
|
|
function readCompactionPart(sessionID: SessionID) {
|
|
return SessionNs.use
|
|
.messages({ sessionID })
|
|
.pipe(
|
|
Effect.map((messages) =>
|
|
messages.at(-2)?.parts.find((item): item is SessionV1.CompactionPart => item.type === "compaction"),
|
|
),
|
|
)
|
|
}
|
|
|
|
function llm() {
|
|
const queue: Array<
|
|
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
|
|
> = []
|
|
|
|
return {
|
|
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
|
|
queue.push(stream)
|
|
},
|
|
layer: Layer.succeed(
|
|
LLM.Service,
|
|
LLM.Service.of({
|
|
stream: (input) => {
|
|
const item = queue.shift() ?? Stream.empty
|
|
const stream = typeof item === "function" ? item(input) : item
|
|
return stream.pipe(Stream.mapEffect((event) => Effect.succeed(event)))
|
|
},
|
|
}),
|
|
),
|
|
}
|
|
}
|
|
|
|
function reply(
|
|
text: string,
|
|
capture?: (input: LLM.StreamInput) => void,
|
|
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
|
|
return (input) => {
|
|
capture?.(input)
|
|
return Stream.make(
|
|
LLMEvent.textStart({ id: "txt-0" }),
|
|
LLMEvent.textDelta({ id: "txt-0", text }),
|
|
LLMEvent.textEnd({ id: "txt-0" }),
|
|
LLMEvent.stepFinish({
|
|
index: 0,
|
|
reason: "stop",
|
|
usage: basicUsage(),
|
|
}),
|
|
LLMEvent.finish({
|
|
reason: "stop",
|
|
usage: basicUsage(),
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
function plugin(ready: Deferred.Deferred<void>) {
|
|
return Layer.mock(Plugin.Service)({
|
|
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
|
|
if (name !== "experimental.session.compacting") return Effect.succeed(output)
|
|
return Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)).pipe(
|
|
Effect.andThen(Effect.never),
|
|
Effect.as(output),
|
|
)
|
|
},
|
|
list: () => Effect.succeed([]),
|
|
init: () => Effect.void,
|
|
})
|
|
}
|
|
|
|
function autocontinue(enabled: boolean) {
|
|
return Layer.mock(Plugin.Service)({
|
|
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
|
|
if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output)
|
|
return Effect.sync(() => {
|
|
;(output as { enabled: boolean }).enabled = enabled
|
|
return output
|
|
})
|
|
},
|
|
list: () => Effect.succeed([]),
|
|
init: () => Effect.void,
|
|
})
|
|
}
|
|
|
|
describe("session.compaction.isOverflow", () => {
|
|
it.live(
|
|
"returns true when token count exceeds usable context",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"returns false when token count within usable context",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 200_000, output: 32_000 })
|
|
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"includes cache.read in token count",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"respects input limit for input caps",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
|
|
const tokens = { input: 271_000, output: 1_000, reasoning: 0, cache: { read: 2_000, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"returns false when input/output are within input caps",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 400_000, input: 272_000, output: 128_000 })
|
|
const tokens = { input: 200_000, output: 20_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"returns false when output within limit with input caps",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 200_000, input: 120_000, output: 10_000 })
|
|
const tokens = { input: 50_000, output: 9_999, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
// ─── Bug reproduction tests ───────────────────────────────────────────
|
|
// These tests demonstrate that when limit.input is set, isOverflow()
|
|
// does not subtract any headroom for the next model response. This means
|
|
// compaction only triggers AFTER we've already consumed the full input
|
|
// budget, leaving zero room for the next API call's output tokens.
|
|
//
|
|
// Compare: without limit.input, usable = context - output (reserves space).
|
|
// With limit.input, usable = limit.input (reserves nothing).
|
|
//
|
|
// Related issues: #10634, #8089, #11086, #12621
|
|
// Open PRs: #6875, #12924
|
|
|
|
it.live(
|
|
"BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
|
|
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
|
|
|
|
// We've used 198K tokens total. Only 2K under the input limit.
|
|
// On the next turn, the full conversation (198K) becomes input,
|
|
// plus the model needs room to generate output — this WILL overflow.
|
|
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
|
|
// count = 180K + 3K + 15K = 198K
|
|
// usable = limit.input = 200K (no output subtracted!)
|
|
// 198K > 200K = false → no compaction triggered
|
|
|
|
// WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓
|
|
// WITH limit.input: usable = 200K, and 198K > 200K = false ✗
|
|
|
|
// With 198K used and only 2K headroom, the next turn will overflow.
|
|
// Compaction MUST trigger here.
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(true)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"BUG: without limit.input, same token count correctly triggers compaction",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
// Same model but without limit.input — uses context - output instead
|
|
const model = createModel({ context: 200_000, output: 32_000 })
|
|
|
|
// Same token usage as above
|
|
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
|
|
// count = 198K
|
|
// usable = context - output = 200K - 32K = 168K
|
|
// 198K > 168K = true → compaction correctly triggered
|
|
|
|
const result = yield* compact.isOverflow({ tokens, model })
|
|
expect(result).toBe(true) // ← Correct: headroom is reserved
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
// Two models with identical context/output limits, differing only in limit.input
|
|
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
|
|
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
|
|
|
|
// 170K total tokens — well above context-output (168K) but below input limit (200K)
|
|
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
|
|
|
|
const withLimit = yield* compact.isOverflow({ tokens, model: withInputLimit })
|
|
const withoutLimit = yield* compact.isOverflow({ tokens, model: withoutInputLimit })
|
|
|
|
// Both models have identical real capacity — they should agree:
|
|
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
|
|
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"returns false when model context limit is 0",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 0, output: 32_000 })
|
|
const tokens = { input: 100_000, output: 10_000, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"returns false when compaction.auto is disabled",
|
|
provideTmpdirInstance(
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const tokens = { input: 75_000, output: 5_000, reasoning: 0, cache: { read: 0, write: 0 } }
|
|
expect(yield* compact.isOverflow({ tokens, model })).toBe(false)
|
|
}),
|
|
{
|
|
config: {
|
|
compaction: { auto: false },
|
|
},
|
|
},
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("session.compaction.create", () => {
|
|
it.live(
|
|
"creates a compaction user message and part",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const ssn = yield* SessionNs.Service
|
|
|
|
const info = yield* ssn.create({})
|
|
|
|
yield* compact.create({
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
auto: true,
|
|
overflow: true,
|
|
})
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: info.id })
|
|
expect(msgs).toHaveLength(1)
|
|
expect(msgs[0].info.role).toBe("user")
|
|
expect(msgs[0].parts).toHaveLength(1)
|
|
expect(msgs[0].parts[0]).toMatchObject({
|
|
type: "compaction",
|
|
auto: true,
|
|
overflow: true,
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
|
|
it.live.skip(
|
|
"projects a compaction message to v2 (v2 projector disabled)",
|
|
provideTmpdirInstance(() =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const ssn = yield* SessionNs.Service
|
|
const info = yield* ssn.create({})
|
|
|
|
yield* compact.create({
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
auto: true,
|
|
overflow: true,
|
|
})
|
|
|
|
const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe(
|
|
Effect.provide(SessionV2.defaultLayer),
|
|
)
|
|
expect(v2.at(-1)).toMatchObject({
|
|
type: "compaction",
|
|
reason: "auto",
|
|
summary: "",
|
|
})
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("session.compaction.prune", () => {
|
|
it.live(
|
|
"compacts old completed tool output",
|
|
provideTmpdirInstance(
|
|
(dir) =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const ssn = yield* SessionNs.Service
|
|
const info = yield* ssn.create({})
|
|
const a = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: a.id,
|
|
sessionID: info.id,
|
|
type: "text",
|
|
text: "first",
|
|
})
|
|
const b: SessionV1.Assistant = {
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
sessionID: info.id,
|
|
mode: "build",
|
|
agent: "build",
|
|
path: { cwd: dir, root: dir },
|
|
cost: 0,
|
|
tokens: {
|
|
output: 0,
|
|
input: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
parentID: a.id,
|
|
time: { created: Date.now() },
|
|
finish: "end_turn",
|
|
}
|
|
yield* ssn.updateMessage(b)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: b.id,
|
|
sessionID: info.id,
|
|
type: "tool",
|
|
callID: crypto.randomUUID(),
|
|
tool: "bash",
|
|
state: {
|
|
status: "completed",
|
|
input: {},
|
|
output: "x".repeat(200_000),
|
|
title: "done",
|
|
metadata: {},
|
|
time: { start: Date.now(), end: Date.now() },
|
|
},
|
|
})
|
|
for (const text of ["second", "third"]) {
|
|
const msg = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID: info.id,
|
|
type: "text",
|
|
text,
|
|
})
|
|
}
|
|
|
|
yield* compact.prune({ sessionID: info.id })
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: info.id })
|
|
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
|
expect(part?.type).toBe("tool")
|
|
expect(part?.state.status).toBe("completed")
|
|
if (part?.type === "tool" && part.state.status === "completed") {
|
|
expect(part.state.time.compacted).toBeNumber()
|
|
}
|
|
}),
|
|
|
|
{
|
|
config: {
|
|
compaction: { prune: true },
|
|
},
|
|
},
|
|
),
|
|
)
|
|
|
|
it.live(
|
|
"skips protected skill tool output",
|
|
provideTmpdirInstance((dir) =>
|
|
Effect.gen(function* () {
|
|
const compact = yield* SessionCompaction.Service
|
|
const ssn = yield* SessionNs.Service
|
|
const info = yield* ssn.create({})
|
|
const a = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: a.id,
|
|
sessionID: info.id,
|
|
type: "text",
|
|
text: "first",
|
|
})
|
|
const b: SessionV1.Assistant = {
|
|
id: MessageID.ascending(),
|
|
role: "assistant",
|
|
sessionID: info.id,
|
|
mode: "build",
|
|
agent: "build",
|
|
path: { cwd: dir, root: dir },
|
|
cost: 0,
|
|
tokens: {
|
|
output: 0,
|
|
input: 0,
|
|
reasoning: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
modelID: ref.modelID,
|
|
providerID: ref.providerID,
|
|
parentID: a.id,
|
|
time: { created: Date.now() },
|
|
finish: "end_turn",
|
|
}
|
|
yield* ssn.updateMessage(b)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: b.id,
|
|
sessionID: info.id,
|
|
type: "tool",
|
|
callID: crypto.randomUUID(),
|
|
tool: "skill",
|
|
state: {
|
|
status: "completed",
|
|
input: {},
|
|
output: "x".repeat(200_000),
|
|
title: "done",
|
|
metadata: {},
|
|
time: { start: Date.now(), end: Date.now() },
|
|
},
|
|
})
|
|
for (const text of ["second", "third"]) {
|
|
const msg = yield* ssn.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: info.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: msg.id,
|
|
sessionID: info.id,
|
|
type: "text",
|
|
text,
|
|
})
|
|
}
|
|
|
|
yield* compact.prune({ sessionID: info.id })
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: info.id })
|
|
const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool")
|
|
expect(part?.type).toBe("tool")
|
|
if (part?.type === "tool" && part.state.status === "completed") {
|
|
expect(part.state.time.compacted).toBeUndefined()
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
|
|
describe("session.compaction.process", () => {
|
|
it.instance(
|
|
"throws when parent is not a user message",
|
|
Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const reply = yield* createAssistantMessage(session.id, msg.id, test.directory)
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const exit = yield* Effect.exit(
|
|
SessionCompaction.use.process({
|
|
parentID: reply.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
}),
|
|
)
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) {
|
|
const error = Cause.squash(exit.cause)
|
|
expect(error).toBeInstanceOf(Error)
|
|
if (error instanceof Error) {
|
|
expect(error.message).toContain(`Compaction parent must be a user message: ${reply.id}`)
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"publishes compacted event on continue",
|
|
Effect.gen(function* () {
|
|
const events = yield* EventV2Bridge.Service
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const done = yield* Deferred.make<void, Error>()
|
|
let seen = false
|
|
const unsub = yield* events.listen((evt) => {
|
|
if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void
|
|
if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id)
|
|
return Effect.void
|
|
seen = true
|
|
Deferred.doneUnsafe(done, Effect.void)
|
|
return Effect.void
|
|
})
|
|
yield* Effect.addFinalizer(() => unsub)
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
yield* Deferred.await(done).pipe(Effect.timeout("500 millis"))
|
|
expect(result).toBe("continue")
|
|
expect(seen).toBe(true)
|
|
}),
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"marks summary message as errored on compact result",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
|
(msg) => msg.info.role === "assistant" && msg.info.summary,
|
|
)
|
|
|
|
expect(result).toBe("stop")
|
|
expect(summary?.info.role).toBe("assistant")
|
|
if (summary?.info.role === "assistant") {
|
|
expect(summary.info.finish).toBe("error")
|
|
expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact")
|
|
}
|
|
}).pipe(withCompaction({ result: "compact" })),
|
|
)
|
|
|
|
it.instance(
|
|
"adds synthetic continue prompt when auto is enabled",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: true,
|
|
})
|
|
|
|
const all = yield* ssn.messages({ sessionID: session.id })
|
|
const last = all.at(-1)
|
|
|
|
expect(result).toBe("continue")
|
|
expect(last?.info.role).toBe("user")
|
|
expect(last?.parts[0]).toMatchObject({
|
|
type: "text",
|
|
synthetic: true,
|
|
metadata: { compaction_continue: true },
|
|
})
|
|
if (last?.parts[0]?.type === "text") {
|
|
expect(last.parts[0].text).toContain("Continue if you have next steps")
|
|
}
|
|
}),
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"persists tail_start_id for retained recent turns",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "first")
|
|
const keep = yield* createUserMessage(session.id, "second")
|
|
yield* createUserMessage(session.id, "third")
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({
|
|
parentID: parent!,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBe(keep.id)
|
|
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
|
|
)
|
|
|
|
// kilocode_change start - configured output ceiling controls automatic tail budgeting
|
|
itCompaction.instance(
|
|
"uses the configured output token ceiling for retained tail budgeting",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "first")
|
|
const keep = yield* createUserMessage(session.id, "x".repeat(12_000))
|
|
yield* createUserMessage(session.id, "third")
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
if (!parent) return yield* Effect.die(new Error("compaction parent not found"))
|
|
yield* SessionCompaction.use.process({
|
|
parentID: parent,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.tail_start_id).toBe(keep.id)
|
|
}).pipe(
|
|
withCompaction({
|
|
config: cfg({ tail_turns: 2 }),
|
|
provider: ProviderTest.fake({ model: createModel({ context: 20_000, output: 10_000 }) }),
|
|
flags: { outputTokenMax: 2_000 },
|
|
}),
|
|
),
|
|
)
|
|
// kilocode_change end
|
|
|
|
itCompaction.instance(
|
|
"shrinks retained tail to fit preserve token budget",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "first")
|
|
yield* createUserMessage(session.id, "x".repeat(2_000))
|
|
const keep = yield* createUserMessage(session.id, "tiny")
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({
|
|
parentID: parent!,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBe(keep.id)
|
|
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"falls back to full summary when even one recent turn exceeds preserve token budget",
|
|
() => {
|
|
const stub = llm()
|
|
let captured = ""
|
|
stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "first")
|
|
yield* createUserMessage(session.id, "y".repeat(2_000))
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBeUndefined()
|
|
expect(captured).toContain("yyyy")
|
|
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"falls back to full summary when retained tail media exceeds preserve token budget",
|
|
() => {
|
|
const stub = llm()
|
|
let captured = ""
|
|
stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "older")
|
|
const recent = yield* createUserMessage(session.id, "recent image turn")
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: recent.id,
|
|
sessionID: session.id,
|
|
type: "file",
|
|
mime: "image/png",
|
|
filename: "big.png",
|
|
url: `data:image/png;base64,${"a".repeat(4_000)}`,
|
|
})
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBeUndefined()
|
|
expect(captured).toContain("recent image turn")
|
|
expect(captured).toContain("Attached image/png: big.png")
|
|
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"retains a split turn suffix when a later message fits the preserve token budget",
|
|
() => {
|
|
const stub = llm()
|
|
let captured = ""
|
|
stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages))))
|
|
return Effect.gen(function* () {
|
|
const test = yield* TestInstance
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "older")
|
|
const recent = yield* createUserMessage(session.id, "recent turn")
|
|
const large = yield* createAssistantMessage(session.id, recent.id, test.directory)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: large.id,
|
|
sessionID: session.id,
|
|
type: "text",
|
|
text: "z".repeat(2_000),
|
|
})
|
|
const keep = yield* createAssistantMessage(session.id, recent.id, test.directory)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: keep.id,
|
|
sessionID: session.id,
|
|
type: "text",
|
|
text: "keep tail",
|
|
})
|
|
yield* createSummaryCompaction(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBe(keep.id)
|
|
expect(captured).toContain("zzzz")
|
|
expect(captured).not.toContain("keep tail")
|
|
|
|
const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id))
|
|
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
|
|
expect(filtered[1]?.info.role).toBe("assistant")
|
|
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
|
|
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
|
|
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"allows plugins to disable synthetic continue prompt",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: true,
|
|
})
|
|
|
|
const all = yield* ssn.messages({ sessionID: session.id })
|
|
const last = all.at(-1)
|
|
|
|
expect(result).toBe("continue")
|
|
expect(last?.info.role).toBe("assistant")
|
|
expect(
|
|
all.some(
|
|
(msg) =>
|
|
msg.info.role === "user" &&
|
|
msg.parts.some(
|
|
(part) => part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
|
|
),
|
|
),
|
|
).toBe(false)
|
|
}).pipe(withCompaction({ plugin: autocontinue(false) })),
|
|
)
|
|
|
|
it.instance(
|
|
"replays the prior user turn on overflow when earlier context exists",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "root")
|
|
const replay = yield* createUserMessage(session.id, "image")
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: replay.id,
|
|
sessionID: session.id,
|
|
type: "file",
|
|
mime: "image/png",
|
|
filename: "cat.png",
|
|
url: "https://example.com/cat.png",
|
|
})
|
|
const msg = yield* createUserMessage(session.id, "current")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: true,
|
|
overflow: true,
|
|
})
|
|
|
|
const last = (yield* ssn.messages({ sessionID: session.id })).at(-1)
|
|
|
|
expect(result).toBe("continue")
|
|
expect(last?.info.role).toBe("user")
|
|
expect(last?.parts.some((part) => part.type === "file")).toBe(false)
|
|
expect(
|
|
last?.parts.some((part) => part.type === "text" && part.text.includes("Attached image/png: cat.png")),
|
|
).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"falls back to overflow guidance when no replayable turn exists",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "earlier")
|
|
const msg = yield* createUserMessage(session.id, "current")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
const result = yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: true,
|
|
overflow: true,
|
|
})
|
|
|
|
const last = (yield* ssn.messages({ sessionID: session.id })).at(-1)
|
|
|
|
expect(result).toBe("continue")
|
|
expect(last?.info.role).toBe("user")
|
|
if (last?.parts[0]?.type === "text") {
|
|
expect(last.parts[0].text).toContain("previous request exceeded the provider's size limit")
|
|
}
|
|
}),
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"stops quickly when aborted during retry backoff",
|
|
() => {
|
|
const stub = llm()
|
|
stub.push(
|
|
Stream.fromAsyncIterable(
|
|
{
|
|
async *[Symbol.asyncIterator]() {
|
|
yield LLMEvent.stepStart({ index: 0 })
|
|
throw new APICallError({
|
|
message: "boom",
|
|
url: "https://example.com/v1/chat/completions",
|
|
requestBodyValues: {},
|
|
statusCode: 503,
|
|
responseHeaders: { "retry-after-ms": "10000" },
|
|
responseBody: '{"error":"boom"}',
|
|
isRetryable: true,
|
|
})
|
|
},
|
|
},
|
|
(err) => err,
|
|
),
|
|
)
|
|
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const events = yield* EventV2Bridge.Service
|
|
const ready = yield* Deferred.make<void>()
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const off = yield* events.listen((evt) => {
|
|
if (evt.type !== SessionStatus.Event.Status.type) return Effect.void
|
|
const data = evt.data as typeof SessionStatus.Event.Status.data.Type
|
|
if (data.sessionID !== session.id || data.status.type !== "retry") return Effect.void
|
|
Deferred.doneUnsafe(ready, Effect.void)
|
|
return Effect.void
|
|
})
|
|
yield* Effect.addFinalizer(() => off)
|
|
|
|
const fiber = yield* SessionCompaction.use
|
|
.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
|
|
// kilocode_change start - avoid a scheduler-sensitive inner deadline on loaded CI runners
|
|
yield* Fiber.interrupt(fiber)
|
|
const exit = yield* Fiber.await(fiber)
|
|
// kilocode_change end
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) {
|
|
expect(Cause.hasInterrupts(exit.cause)).toBe(true)
|
|
}
|
|
}).pipe(withCompaction({ llm: stub.layer, snapshot: snap })) // kilocode_change
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"does not leave a summary assistant when aborted before processor setup",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const ready = yield* Deferred.make<void>()
|
|
return yield* Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const fiber = yield* SessionCompaction.use
|
|
.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* Deferred.await(ready).pipe(Effect.timeout("1 second"))
|
|
yield* Fiber.interrupt(fiber)
|
|
const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis"))
|
|
const all = yield* ssn.messages({ sessionID: session.id })
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
if (Exit.isFailure(exit)) expect(Cause.hasInterrupts(exit.cause)).toBe(true)
|
|
expect(all.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(false)
|
|
}).pipe(withCompaction({ plugin: plugin(ready) }))
|
|
}),
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"silently drops reasoning-delta arriving without prior reasoning-start",
|
|
() => {
|
|
// Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
|
|
// reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
|
|
// change to processor.ts reasoning-delta handling triggers this test.
|
|
const stub = llm()
|
|
stub.push(
|
|
Stream.make(
|
|
LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
|
|
LLMEvent.textStart({ id: "txt-0" }),
|
|
LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
|
|
LLMEvent.textEnd({ id: "txt-0" }),
|
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
|
|
LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
|
|
),
|
|
)
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
yield* SessionCompaction.use.process({
|
|
parentID: msg.id,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
|
(item) => item.info.role === "assistant" && item.info.summary,
|
|
)
|
|
expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
|
|
// Sanity: the text part still got through.
|
|
expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
|
|
}).pipe(withCompaction({ llm: stub.layer }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"does not allow tool calls while generating the summary",
|
|
() => {
|
|
const stub = llm()
|
|
stub.push(
|
|
Stream.make(
|
|
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
|
|
LLMEvent.stepFinish({
|
|
index: 0,
|
|
reason: "tool-calls",
|
|
usage: basicUsage(),
|
|
}),
|
|
LLMEvent.finish({
|
|
reason: "tool-calls",
|
|
usage: basicUsage(),
|
|
}),
|
|
),
|
|
)
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const msg = yield* createUserMessage(session.id, "hello")
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
yield* SessionCompaction.use.process({ parentID: msg.id, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
|
(item) => item.info.role === "assistant" && item.info.summary,
|
|
)
|
|
|
|
expect(summary?.info.role).toBe("assistant")
|
|
expect(summary?.parts.some((part) => part.type === "tool")).toBe(false)
|
|
}).pipe(withCompaction({ llm: stub.layer }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"summarizes only the head while keeping recent tail out of summary input",
|
|
() => {
|
|
const stub = llm()
|
|
let captured = ""
|
|
stub.push(
|
|
reply("summary", (input) => {
|
|
captured = JSON.stringify(input.messages)
|
|
}),
|
|
)
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "older context")
|
|
yield* createUserMessage(session.id, "keep this turn")
|
|
yield* createUserMessage(session.id, "and this one too")
|
|
yield* createCompactionMarker(session.id)
|
|
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({
|
|
parentID: parent!,
|
|
messages: msgs,
|
|
sessionID: session.id,
|
|
auto: false,
|
|
})
|
|
|
|
expect(captured).toContain("older context")
|
|
expect(captured).not.toContain("keep this turn")
|
|
expect(captured).not.toContain("and this one too")
|
|
expect(captured).not.toContain("What did we do so far?")
|
|
}).pipe(withCompaction({ llm: stub.layer }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance(
|
|
"anchors repeated compactions with the previous summary",
|
|
() => {
|
|
const stub = llm()
|
|
let captured = ""
|
|
stub.push(reply("summary one"))
|
|
stub.push(
|
|
reply("summary two", (input) => {
|
|
captured = JSON.stringify(input.messages)
|
|
}),
|
|
)
|
|
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "older context")
|
|
yield* createUserMessage(session.id, "keep this turn")
|
|
yield* createCompactionMarker(session.id)
|
|
|
|
let msgs = yield* ssn.messages({ sessionID: session.id })
|
|
let parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
yield* createUserMessage(session.id, "latest turn")
|
|
yield* createCompactionMarker(session.id)
|
|
|
|
msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id))
|
|
parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
expect(captured).toContain("<previous-summary>")
|
|
expect(captured).toContain("summary one")
|
|
expect(captured.match(/summary one/g)?.length).toBe(1)
|
|
expect(captured).toContain("## Constraints & Preferences")
|
|
expect(captured).toContain("## Progress")
|
|
}).pipe(withCompaction({ llm: stub.layer }))
|
|
},
|
|
{ git: true },
|
|
)
|
|
|
|
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
|
|
const stub = llm()
|
|
stub.push(reply("summary one"))
|
|
stub.push(reply("summary two"))
|
|
|
|
return Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const session = yield* ssn.create({})
|
|
const u1 = yield* createUserMessage(session.id, "one")
|
|
const u2 = yield* createUserMessage(session.id, "two")
|
|
const u3 = yield* createUserMessage(session.id, "three")
|
|
yield* createCompactionMarker(session.id)
|
|
|
|
let msgs = yield* ssn.messages({ sessionID: session.id })
|
|
let parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const u4 = yield* createUserMessage(session.id, "four")
|
|
yield* createCompactionMarker(session.id)
|
|
|
|
msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id))
|
|
parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const filtered = MessageV2.filterCompacted(yield* MessageV2.stream(session.id))
|
|
const ids = filtered.map((msg) => msg.info.id)
|
|
|
|
expect(ids).not.toContain(u1.id)
|
|
expect(ids).not.toContain(u2.id)
|
|
expect(ids).toContain(u3.id)
|
|
expect(ids).toContain(u4.id)
|
|
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
|
|
expect(
|
|
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
|
|
).toBe(true)
|
|
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) }))
|
|
})
|
|
|
|
itCompaction.instance(
|
|
"ignores previous summaries when sizing the retained tail",
|
|
Effect.gen(function* () {
|
|
const ssn = yield* SessionNs.Service
|
|
const test = yield* TestInstance
|
|
const session = yield* ssn.create({})
|
|
yield* createUserMessage(session.id, "older")
|
|
const keep = yield* createUserMessage(session.id, "keep this turn")
|
|
const keepReply = yield* createAssistantMessage(session.id, keep.id, test.directory)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: keepReply.id,
|
|
sessionID: session.id,
|
|
type: "text",
|
|
text: "keep reply",
|
|
})
|
|
|
|
yield* createCompactionMarker(session.id)
|
|
const firstCompaction = (yield* ssn.messages({ sessionID: session.id })).at(-1)?.info.id
|
|
expect(firstCompaction).toBeTruthy()
|
|
yield* createSummaryAssistantMessage(session.id, firstCompaction!, test.directory, "summary ".repeat(800))
|
|
|
|
const recent = yield* createUserMessage(session.id, "recent turn")
|
|
const recentReply = yield* createAssistantMessage(session.id, recent.id, test.directory)
|
|
yield* ssn.updatePart({
|
|
id: PartID.ascending(),
|
|
messageID: recentReply.id,
|
|
sessionID: session.id,
|
|
type: "text",
|
|
text: "recent reply",
|
|
})
|
|
|
|
yield* createCompactionMarker(session.id)
|
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
|
const parent = msgs.at(-1)?.info.id
|
|
expect(parent).toBeTruthy()
|
|
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
|
|
|
|
const part = yield* readCompactionPart(session.id)
|
|
expect(part?.type).toBe("compaction")
|
|
expect(part?.tail_start_id).toBe(keep.id)
|
|
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
|
|
)
|
|
})
|
|
|
|
describe("util.token.estimate", () => {
|
|
test("estimates tokens from text (4 chars per token)", () => {
|
|
const text = "x".repeat(4000)
|
|
expect(Token.estimate(text)).toBe(1000)
|
|
})
|
|
|
|
test("estimates tokens from larger text", () => {
|
|
const text = "y".repeat(20_000)
|
|
expect(Token.estimate(text)).toBe(5000)
|
|
})
|
|
|
|
test("returns 0 for empty string", () => {
|
|
expect(Token.estimate("")).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("SessionNs.getUsage", () => {
|
|
test("normalizes standard usage to token format", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(1000)
|
|
expect(result.tokens.output).toBe(500)
|
|
expect(result.tokens.reasoning).toBe(0)
|
|
expect(result.tokens.cache.read).toBe(0)
|
|
expect(result.tokens.cache.write).toBe(0)
|
|
})
|
|
|
|
test("extracts cached tokens to cache.read", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(800)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
})
|
|
|
|
test("handles anthropic cache write metadata", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
|
metadata: {
|
|
anthropic: {
|
|
cacheCreationInputTokens: 300,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.tokens.cache.write).toBe(300)
|
|
})
|
|
|
|
test("subtracts cached tokens for anthropic provider", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
|
metadata: {
|
|
anthropic: {},
|
|
},
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(800)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
})
|
|
|
|
test("separates reasoning tokens from output tokens", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(1000)
|
|
expect(result.tokens.output).toBe(400)
|
|
expect(result.tokens.reasoning).toBe(100)
|
|
expect(result.tokens.total).toBe(1500)
|
|
})
|
|
|
|
test("does not double count reasoning tokens in cost", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 0,
|
|
output: 15,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
|
|
})
|
|
|
|
expect(result.tokens.output).toBe(750_000)
|
|
expect(result.tokens.reasoning).toBe(250_000)
|
|
expect(result.cost).toBe(15)
|
|
})
|
|
|
|
test("handles undefined optional values gracefully", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000 })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(0)
|
|
expect(result.tokens.output).toBe(0)
|
|
expect(result.tokens.reasoning).toBe(0)
|
|
expect(result.tokens.cache.read).toBe(0)
|
|
expect(result.tokens.cache.write).toBe(0)
|
|
expect(Number.isNaN(result.cost)).toBe(false)
|
|
})
|
|
|
|
test("calculates cost correctly", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
})
|
|
|
|
expect(result.cost).toBe(3 + 1.5)
|
|
})
|
|
|
|
test("uses authoritative Copilot billed cost when provided", () => {
|
|
const result = SessionNs.getUsage({
|
|
model: createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: { input: 3, output: 15, cache: { read: 0.3, write: 0.3 } },
|
|
}),
|
|
usage: usage({ inputTokens: 11_774, outputTokens: 39, totalTokens: 11_813 }),
|
|
metadata: { copilot: { totalNanoAiu: 4_473_525_000 } },
|
|
})
|
|
|
|
expect(result.cost).toBe(0.04473525)
|
|
})
|
|
|
|
test("uses matching context cost tier before over-200k fallback", () => {
|
|
const model = createModel({
|
|
context: 1_000_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 1,
|
|
output: 2,
|
|
cache: { read: 0.1, write: 0.5 },
|
|
tiers: [
|
|
{
|
|
input: 3,
|
|
output: 4,
|
|
cache: { read: 0.3, write: 1.5 },
|
|
tier: { type: "context", size: 200_000 },
|
|
},
|
|
{
|
|
input: 5,
|
|
output: 6,
|
|
cache: { read: 0.5, write: 2.5 },
|
|
tier: { type: "context", size: 500_000 },
|
|
},
|
|
],
|
|
experimentalOver200K: {
|
|
input: 100,
|
|
output: 100,
|
|
cache: { read: 100, write: 100 },
|
|
},
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({
|
|
inputTokens: 650_000,
|
|
outputTokens: 100_000,
|
|
totalTokens: 750_000,
|
|
cacheReadInputTokens: 100_000,
|
|
}),
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(550_000)
|
|
expect(result.cost).toBe(2.75 + 0.6 + 0.05)
|
|
})
|
|
|
|
test("falls back to over-200k pricing when no cost tier matches", () => {
|
|
const model = createModel({
|
|
context: 1_000_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 1,
|
|
output: 2,
|
|
cache: { read: 0.1, write: 0.5 },
|
|
tiers: [
|
|
{
|
|
input: 5,
|
|
output: 6,
|
|
cache: { read: 0.5, write: 2.5 },
|
|
tier: { type: "context", size: 500_000 },
|
|
},
|
|
],
|
|
experimentalOver200K: {
|
|
input: 3,
|
|
output: 4,
|
|
cache: { read: 0.3, write: 1.5 },
|
|
},
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
|
|
})
|
|
|
|
expect(result.cost).toBe(0.9 + 0.4)
|
|
})
|
|
|
|
// kilocode_change start - Test for OpenRouter provider cost
|
|
test("uses openrouter provider cost when available", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
cost: 0.42, // Provider-reported cost should be used instead of calculated
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should use the provider cost (0.42) instead of calculated cost (4.5)
|
|
expect(result.cost).toBe(0.42)
|
|
})
|
|
|
|
test("falls back to calculated cost when openrouter cost is not available", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
// cost is undefined
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should fall back to calculated cost
|
|
expect(result.cost).toBe(3 + 1.5)
|
|
})
|
|
|
|
test("falls back to calculated cost when openrouter metadata is empty", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {},
|
|
},
|
|
})
|
|
|
|
// Should fall back to calculated cost
|
|
expect(result.cost).toBe(3 + 1.5)
|
|
})
|
|
|
|
test("uses upstreamInferenceCost for Kilo provider", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const provider = { id: "kilo" } as Provider.Info
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
provider,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
cost: 0.01, // OpenRouter 5% fee
|
|
costDetails: {
|
|
upstreamInferenceCost: 0.2, // Actual inference cost
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should use upstreamInferenceCost for Kilo provider (BYOK)
|
|
expect(result.cost).toBe(0.2)
|
|
})
|
|
|
|
test("uses regular cost for OpenRouter provider", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const provider = { id: "openrouter" } as Provider.Info
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
provider,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
cost: 0.5, // Regular OpenRouter cost
|
|
costDetails: {
|
|
upstreamInferenceCost: 0.45,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should use regular cost for OpenRouter provider
|
|
expect(result.cost).toBe(0.5)
|
|
})
|
|
|
|
test("falls back to regular cost when provider is not specified", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
cost: 0.3,
|
|
costDetails: {
|
|
upstreamInferenceCost: 0.25,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Should use regular cost when provider is not specified
|
|
expect(result.cost).toBe(0.3)
|
|
})
|
|
|
|
test("uses regular cost when upstreamInferenceCost is missing for Kilo", () => {
|
|
const model = createModel({
|
|
context: 100_000,
|
|
output: 32_000,
|
|
cost: {
|
|
input: 3,
|
|
output: 15,
|
|
cache: { read: 0.3, write: 3.75 },
|
|
},
|
|
})
|
|
const provider = { id: "kilo" } as Provider.Info
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
provider,
|
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
|
metadata: {
|
|
openrouter: {
|
|
usage: {
|
|
cost: 0.01,
|
|
// costDetails is missing
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// When upstream cost is missing for Kilo, fall back to regular cost field
|
|
expect(result.cost).toBe(0.01)
|
|
})
|
|
|
|
// Tests for Anthropic Messages / OpenAI Responses / Vercel AI Gateway cost extraction
|
|
// live in test/kilocode/provider-cost.test.ts (kilocode_change).
|
|
// kilocode_change end
|
|
|
|
test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
|
|
"computes total from components for %s models",
|
|
(npm) => {
|
|
const model = createModel({ context: 100_000, output: 32_000, npm })
|
|
// AI SDK v6: inputTokens includes cached tokens for all providers
|
|
const item = usage({
|
|
inputTokens: 1000,
|
|
outputTokens: 500,
|
|
totalTokens: 1500,
|
|
cacheReadInputTokens: 200,
|
|
})
|
|
if (npm === "@ai-sdk/amazon-bedrock") {
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: item,
|
|
metadata: {
|
|
bedrock: {
|
|
usage: {
|
|
cacheWriteInputTokens: 300,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
|
|
expect(result.tokens.input).toBe(500)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
expect(result.tokens.cache.write).toBe(300)
|
|
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
|
|
expect(result.tokens.total).toBe(1500)
|
|
return
|
|
}
|
|
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: item,
|
|
metadata: {
|
|
anthropic: {
|
|
cacheCreationInputTokens: 300,
|
|
},
|
|
},
|
|
})
|
|
|
|
// inputTokens (1000) includes cache, so adjusted = 1000 - 200 - 300 = 500
|
|
expect(result.tokens.input).toBe(500)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
expect(result.tokens.cache.write).toBe(300)
|
|
// total = adjusted (500) + output (500) + cacheRead (200) + cacheWrite (300)
|
|
expect(result.tokens.total).toBe(1500)
|
|
},
|
|
)
|
|
|
|
test("extracts cache write tokens from vertex metadata key", () => {
|
|
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
|
const result = SessionNs.getUsage({
|
|
model,
|
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
|
metadata: {
|
|
vertex: {
|
|
cacheCreationInputTokens: 300,
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(result.tokens.input).toBe(500)
|
|
expect(result.tokens.cache.read).toBe(200)
|
|
expect(result.tokens.cache.write).toBe(300)
|
|
})
|
|
})
|