mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
refactor(cli): narrow compaction fix to replay eligibility
This commit is contained in:
@@ -180,16 +180,14 @@ describe("durable PTY registry", () => {
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
const terminal = yield* Effect.scoped(
|
||||
const info = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const info = yield* pty.create({ command: "/bin/sh", args: ["-c", "read _; exit 7"], cwd: dir.path })
|
||||
return { info, exit: pty.write(info.id, "exit\r") }
|
||||
return yield* pty.create({ command: "/bin/sh", args: ["-c", "exit 7"], cwd: dir.path })
|
||||
}).pipe(Effect.provide(locations.get(target))),
|
||||
)
|
||||
yield* terminal.exit
|
||||
const exited = yield* Queue.take(queue).pipe(Effect.timeout("15 seconds"))
|
||||
expect(exited).toEqual({ id: terminal.info.id, location: target })
|
||||
const exited = yield* Queue.take(queue).pipe(Effect.timeout("5 seconds"))
|
||||
expect(exited).toEqual({ id: info.id, location: target })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,12 +98,6 @@ const exportSchema = z
|
||||
ctx.addIssue({ code: "custom", message: "Invalid message part" })
|
||||
if (part.type === "compaction" && part.tail_start_id !== undefined && typeof part.tail_start_id !== "string")
|
||||
ctx.addIssue({ code: "custom", message: "Invalid compaction tail" })
|
||||
if (
|
||||
part.type === "compaction" &&
|
||||
part.pending_user_id !== undefined &&
|
||||
typeof part.pending_user_id !== "string"
|
||||
)
|
||||
ctx.addIssue({ code: "custom", message: "Invalid compaction pending user" })
|
||||
if (pids.has(part.id)) ctx.addIssue({ code: "custom", message: "Duplicate part ID" })
|
||||
pids.add(part.id)
|
||||
|
||||
@@ -126,7 +120,8 @@ const exportSchema = z
|
||||
|
||||
for (const msg of data.messages) {
|
||||
const parent = msg.info.parentID
|
||||
if (parent !== undefined && !ids.has(parent)) ctx.addIssue({ code: "custom", message: "Dangling message parent" })
|
||||
if (parent !== undefined && !ids.has(parent))
|
||||
ctx.addIssue({ code: "custom", message: "Dangling message parent" })
|
||||
|
||||
const seen = new Set([msg.info.id])
|
||||
let current = parent
|
||||
@@ -140,11 +135,8 @@ const exportSchema = z
|
||||
}
|
||||
|
||||
for (const part of msg.parts) {
|
||||
if (part.type !== "compaction") continue
|
||||
if (typeof part.tail_start_id === "string" && !ids.has(part.tail_start_id))
|
||||
ctx.addIssue({ code: "custom", message: "Dangling compaction tail" })
|
||||
if (typeof part.pending_user_id === "string" && !ids.has(part.pending_user_id))
|
||||
ctx.addIssue({ code: "custom", message: "Dangling compaction pending user" })
|
||||
if (part.type !== "compaction" || typeof part.tail_start_id !== "string") continue
|
||||
if (!ids.has(part.tail_start_id)) ctx.addIssue({ code: "custom", message: "Dangling compaction tail" })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -295,10 +287,8 @@ export function prepareSessionImport(data: unknown, deps: PrepareDeps) {
|
||||
for (const part of msg.parts) {
|
||||
const partID = pids.get(part.id)!
|
||||
const tail =
|
||||
part.type === "compaction" && typeof part.tail_start_id === "string" ? ids.get(part.tail_start_id)! : undefined
|
||||
const pending =
|
||||
part.type === "compaction" && typeof part.pending_user_id === "string"
|
||||
? ids.get(part.pending_user_id)!
|
||||
part.type === "compaction" && typeof part.tail_start_id === "string"
|
||||
? ids.get(part.tail_start_id)!
|
||||
: undefined
|
||||
const data: Record<string, unknown> = {
|
||||
...part,
|
||||
@@ -306,7 +296,6 @@ export function prepareSessionImport(data: unknown, deps: PrepareDeps) {
|
||||
messageID: id,
|
||||
sessionID,
|
||||
...(tail ? { tail_start_id: tail } : {}),
|
||||
...(pending ? { pending_user_id: pending } : {}),
|
||||
}
|
||||
const state = completed(part)
|
||||
if (state?.attachments) {
|
||||
|
||||
@@ -133,7 +133,6 @@ describe("cloud session import preparation", () => {
|
||||
sessionID: "ses_cloud",
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
pending_user_id: "msg_cloud_parent",
|
||||
tail_start_id: "msg_cloud_child",
|
||||
},
|
||||
],
|
||||
@@ -218,7 +217,6 @@ describe("cloud session import preparation", () => {
|
||||
id: "prt_local_compaction",
|
||||
messageID: "msg_local_parent",
|
||||
sessionID: "ses_local",
|
||||
pending_user_id: "msg_local_parent",
|
||||
tail_start_id: "msg_local_child",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -114,7 +114,6 @@ export interface CompactionPart extends BasePart {
|
||||
type: "compaction"
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending_user_id?: string
|
||||
tail_start_id?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import type { MessageV2 } from "@/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "@/session/schema"
|
||||
import { KiloSessionPromptQueue } from "./prompt-queue"
|
||||
import { KiloSessionMessageOrder } from "./message-order"
|
||||
|
||||
export namespace KiloSessionCompaction {
|
||||
type Store = {
|
||||
@@ -12,68 +11,6 @@ export namespace KiloSessionCompaction {
|
||||
updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
||||
}
|
||||
|
||||
export function resolve(input: { part: MessageV2.CompactionPart; messages: MessageV2.WithParts[] }) {
|
||||
const marker = input.messages.find((msg) => msg.info.id === input.part.messageID)
|
||||
if (!marker) return { kind: "unresolved" as const, reason: "marker is missing" }
|
||||
const markerIndex = input.messages.indexOf(marker)
|
||||
const indexes = new Map(input.messages.map((msg, index) => [msg.info.id, index]))
|
||||
const compare = (a: MessageV2.WithParts, b: MessageV2.WithParts) =>
|
||||
KiloSessionMessageOrder.compare(a, b, indexes.get(a.info.id) ?? -1, indexes.get(b.info.id) ?? -1)
|
||||
const before = input.messages.filter(
|
||||
(msg, index) => KiloSessionMessageOrder.compare(msg, marker, index, markerIndex) < 0,
|
||||
)
|
||||
const users = before.filter(
|
||||
(msg) => msg.info.role === "user" && !msg.parts.some((part) => part.type === "compaction"),
|
||||
)
|
||||
if (input.part.pending_user_id) {
|
||||
const target = input.messages.find((msg) => msg.info.id === input.part.pending_user_id)
|
||||
if (target?.info.role !== "user" || target.parts.some((part) => part.type === "compaction"))
|
||||
return { kind: "unresolved" as const, reason: "pending user is missing" }
|
||||
if (compare(target, marker) >= 0) return { kind: "unresolved" as const, reason: "pending user is after marker" }
|
||||
return { kind: "found" as const, message: target }
|
||||
}
|
||||
const done = (id: MessageID) =>
|
||||
input.messages.some(
|
||||
(item) =>
|
||||
item.info.role === "assistant" &&
|
||||
item.info.parentID === id &&
|
||||
!!item.info.finish &&
|
||||
item.info.finish !== "tool-calls",
|
||||
)
|
||||
const open = users.filter((msg) => !done(msg.info.id))
|
||||
if (open.length === 1) return { kind: "found" as const, message: open[0]! }
|
||||
if (open.length === 0 && users.length === 1) return { kind: "stale" as const }
|
||||
return { kind: "unresolved" as const, reason: "pending user is ambiguous" }
|
||||
}
|
||||
|
||||
export function followups(input: { messages: MessageV2.WithParts[]; after: MessageV2.WithParts }) {
|
||||
const anchor = input.messages.find((msg) => msg.info.id === input.after.info.id)
|
||||
if (!anchor) return [] as MessageV2.User[]
|
||||
const indexes = new Map(input.messages.map((msg, index) => [msg.info.id, index]))
|
||||
const compare = (a: MessageV2.WithParts, b: MessageV2.WithParts) =>
|
||||
KiloSessionMessageOrder.compare(a, b, indexes.get(a.info.id) ?? -1, indexes.get(b.info.id) ?? -1)
|
||||
const done = (id: MessageID) =>
|
||||
input.messages.some(
|
||||
(msg) =>
|
||||
msg.info.role === "assistant" &&
|
||||
msg.info.parentID === id &&
|
||||
!!msg.info.finish &&
|
||||
msg.info.finish !== "tool-calls",
|
||||
)
|
||||
return input.messages
|
||||
.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
!msg.parts.some((part) => part.type === "compaction") &&
|
||||
msg.parts.some((part) => !("synthetic" in part) || !part.synthetic) &&
|
||||
compare(msg, anchor) > 0 &&
|
||||
!done(msg.info.id),
|
||||
)
|
||||
.sort(compare)
|
||||
.map((msg) => (msg.info.role === "user" ? msg.info : undefined))
|
||||
.filter((msg): msg is MessageV2.User => msg !== undefined)
|
||||
}
|
||||
|
||||
export function create(input: {
|
||||
session: Store
|
||||
sessionID: SessionID
|
||||
@@ -81,7 +18,6 @@ export namespace KiloSessionCompaction {
|
||||
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending_user_id?: MessageID
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const msg = yield* input.session.updateMessage({
|
||||
@@ -99,7 +35,6 @@ export namespace KiloSessionCompaction {
|
||||
type: "compaction",
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
pending_user_id: input.pending_user_id,
|
||||
})
|
||||
KiloSessionPromptQueue.retarget(input.sessionID, msg.id)
|
||||
return msg
|
||||
|
||||
@@ -116,7 +116,6 @@ function copy(input: { source: Session.Info; parentID: SessionID; ops: Ops }) {
|
||||
...(prepared.type === "step-finish" && { cost: 0 }),
|
||||
}
|
||||
if (next.type === "compaction" && next.tail_start_id) next.tail_start_id = ids.get(next.tail_start_id)
|
||||
if (next.type === "compaction" && next.pending_user_id) next.pending_user_id = ids.get(next.pending_user_id)
|
||||
yield* input.ops.updatePart(next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export namespace KiloSessionMessageOrder {
|
||||
}
|
||||
|
||||
/** Derive active messages by chronology while keeping queued tasks in model-facing projection order. */
|
||||
export function latest(msgs: MessageV2.WithParts[], focus?: MessageV2.User["id"]) {
|
||||
export function latest(msgs: MessageV2.WithParts[]) {
|
||||
let user: MessageV2.WithParts | undefined
|
||||
let assistant: MessageV2.WithParts | undefined
|
||||
let finished: MessageV2.WithParts | undefined
|
||||
@@ -53,35 +53,6 @@ export namespace KiloSessionMessageOrder {
|
||||
),
|
||||
)
|
||||
|
||||
if (focus) {
|
||||
const userMessage = msgs.find((msg) => msg.info.role === "user" && msg.info.id === focus)
|
||||
if (userMessage?.info.role === "user") {
|
||||
let assistantMessage: MessageV2.WithParts | undefined
|
||||
let assistantIndex = -1
|
||||
for (const [index, msg] of msgs.entries()) {
|
||||
if (
|
||||
msg.info.role === "assistant" &&
|
||||
msg.info.parentID === focus &&
|
||||
(!assistantMessage || compare(msg, assistantMessage, index, assistantIndex) > 0)
|
||||
) {
|
||||
assistantMessage = msg
|
||||
assistantIndex = index
|
||||
}
|
||||
}
|
||||
const finishedMessage =
|
||||
assistantMessage?.info.role === "assistant" && assistantMessage.info.finish ? assistantMessage : undefined
|
||||
return {
|
||||
user: userMessage.info,
|
||||
assistant: assistantMessage?.info.role === "assistant" ? assistantMessage.info : undefined,
|
||||
finished: finishedMessage?.info.role === "assistant" ? finishedMessage.info : undefined,
|
||||
userMessage,
|
||||
assistantMessage,
|
||||
finishedMessage,
|
||||
tasks,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: user?.info.role === "user" ? user.info : undefined,
|
||||
assistant: assistant?.info.role === "assistant" ? assistant.info : undefined,
|
||||
|
||||
@@ -24,7 +24,6 @@ import { KiloCompactionPayloadRecovery } from "@/kilocode/session/compaction-pay
|
||||
import { KiloCompactionChunks } from "@/kilocode/session/compaction-chunks"
|
||||
import { SessionExport } from "@/kilocode/session-export"
|
||||
import { KiloSession } from "@/kilocode/session"
|
||||
import { KiloSessionMessageOrder } from "@/kilocode/session/message-order" // kilocode_change
|
||||
// kilocode_change end
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
@@ -158,7 +157,6 @@ export interface Interface {
|
||||
sessionID: SessionID
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending?: MessageID // kilocode_change - resume this pending turn after compaction
|
||||
}) => Effect.Effect<"continue" | "stop">
|
||||
readonly create: (input: {
|
||||
sessionID: SessionID
|
||||
@@ -166,7 +164,6 @@ export interface Interface {
|
||||
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending_user_id?: MessageID // kilocode_change - persist the user turn resumed by this marker
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -327,7 +324,6 @@ const layer = Layer.effect(
|
||||
sessionID: SessionID
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending?: MessageID // kilocode_change
|
||||
}) {
|
||||
const parent = input.messages.findLast((m) => m.info.id === input.parentID)
|
||||
if (!parent || parent.info.role !== "user") {
|
||||
@@ -336,28 +332,41 @@ const layer = Layer.effect(
|
||||
const userMessage = parent.info
|
||||
const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
|
||||
|
||||
const pending = input.pending
|
||||
let messages = input.messages
|
||||
let replay:
|
||||
| {
|
||||
info: SessionV1.User
|
||||
parts: SessionV1.Part[]
|
||||
}
|
||||
| undefined
|
||||
let history = input.messages
|
||||
// kilocode_change start - replay only explicitly pending turns or provider-overflow requests
|
||||
if (pending || input.overflow === true) {
|
||||
const indexes = new Map(input.messages.map((msg, index) => [msg.info.id, index]))
|
||||
const compare = (a: SessionV1.WithParts, b: SessionV1.WithParts) =>
|
||||
KiloSessionMessageOrder.compare(a, b, indexes.get(a.info.id) ?? -1, indexes.get(b.info.id) ?? -1)
|
||||
const before = input.messages.filter((msg) => compare(msg, parent) < 0)
|
||||
const target = pending
|
||||
? before.find((msg) => msg.info.role === "user" && msg.info.id === pending)
|
||||
: [...before]
|
||||
.sort(compare)
|
||||
.findLast((msg) => msg.info.role === "user" && !msg.parts.some((part) => part.type === "compaction"))
|
||||
if (target?.info.role === "user") {
|
||||
replay = { info: target.info, parts: target.parts }
|
||||
history = input.messages.filter((msg) => compare(msg, target) < 0)
|
||||
// kilocode_change start - false is preflight replay; undefined disables replay
|
||||
if (input.overflow !== undefined) {
|
||||
const idx = input.messages.findIndex((m) => m.info.id === input.parentID)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const msg = input.messages[i]
|
||||
if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) {
|
||||
const progress = input.messages
|
||||
.slice(i + 1, idx)
|
||||
.some(
|
||||
(item) =>
|
||||
item.info.role === "assistant" &&
|
||||
(item.info.finish ||
|
||||
item.parts.some(
|
||||
(part) =>
|
||||
part.type === "tool" || ((part.type === "text" || part.type === "reasoning") && !!part.text),
|
||||
)),
|
||||
)
|
||||
if (progress) break
|
||||
replay = { info: msg.info, parts: msg.parts }
|
||||
messages = input.messages.slice(0, i)
|
||||
break
|
||||
}
|
||||
}
|
||||
const hasContent =
|
||||
replay && messages.some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"))
|
||||
if (!hasContent) {
|
||||
replay = undefined
|
||||
messages = input.messages
|
||||
}
|
||||
}
|
||||
// kilocode_change end
|
||||
@@ -367,14 +376,16 @@ const layer = Layer.effect(
|
||||
? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie)
|
||||
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie)
|
||||
const cfg = yield* config.get()
|
||||
if (compactionPart && history.at(-1)?.info.id === input.parentID) history = history.slice(0, -1)
|
||||
const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages
|
||||
const prior = completedCompactions(history)
|
||||
const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex]))
|
||||
const previousSummary = prior.at(-1)?.summary
|
||||
// kilocode_change start
|
||||
const available = history.filter((_, index) => !hidden.has(index))
|
||||
const selected = replay
|
||||
? { head: available, tail_start_id: undefined }
|
||||
: yield* select({ messages: available, cfg, model })
|
||||
// kilocode_change end
|
||||
// Allow plugins to inject context or replace compaction prompt.
|
||||
const compacting = yield* plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
@@ -494,7 +505,6 @@ const layer = Layer.effect(
|
||||
// kilocode_change end
|
||||
if (replay) {
|
||||
// kilocode_change start - compact oversized replay turns instead of looping into replay overflow
|
||||
const parts = replay.parts
|
||||
replay = yield* KiloCompactionChunks.replay({
|
||||
processors,
|
||||
session,
|
||||
@@ -513,12 +523,7 @@ const layer = Layer.effect(
|
||||
}).pipe(Effect.provideService(Database.Service, database)) // kilocode_change
|
||||
// kilocode_change end
|
||||
const original = replay.info
|
||||
KiloSessionPromptQueue.retarget(input.sessionID, original.id)
|
||||
const preserve = input.overflow !== true && replay.parts === parts
|
||||
if (compactionPart && preserve) {
|
||||
yield* session.updatePart({ ...compactionPart, tail_start_id: original.id })
|
||||
}
|
||||
const next = yield* session.updateMessage({
|
||||
const replayMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
@@ -528,40 +533,24 @@ const layer = Layer.effect(
|
||||
format: original.format,
|
||||
tools: original.tools,
|
||||
system: original.system,
|
||||
editorContext: original.editorContext, // kilocode_change - preserve editor context across continuation
|
||||
editorContext: original.editorContext, // kilocode_change
|
||||
})
|
||||
KiloSessionPromptQueue.retarget(input.sessionID, next.id)
|
||||
if (preserve) {
|
||||
KiloSessionPromptQueue.retarget(input.sessionID, replayMsg.id) // kilocode_change - expose replay to scope()
|
||||
for (const part of replay.parts) {
|
||||
if (part.type === "compaction") continue
|
||||
// kilocode_change start - preserve media for preflight replay but strip it after provider overflow
|
||||
const replayPart =
|
||||
input.overflow && part.type === "file" && MessageV2.isMedia(part.mime)
|
||||
? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
|
||||
: part
|
||||
yield* session.updatePart({
|
||||
...replayPart,
|
||||
...(replayPart.type === "text" && { synthetic: true }),
|
||||
id: PartID.ascending(),
|
||||
messageID: next.id,
|
||||
messageID: replayMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
metadata: { compaction_continue: true },
|
||||
synthetic: true,
|
||||
text: "Continue the pending user request from the compacted context.",
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
})
|
||||
} else {
|
||||
for (const part of replay.parts) {
|
||||
if (part.type === "compaction") continue
|
||||
const item =
|
||||
part.type === "file" && MessageV2.isMedia(part.mime) // kilocode_change - only replace media on overflow
|
||||
? {
|
||||
type: "text" as const,
|
||||
text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`,
|
||||
synthetic: true,
|
||||
}
|
||||
: part.type === "text"
|
||||
? { ...part, synthetic: true }
|
||||
: part
|
||||
yield* session.updatePart({
|
||||
...item,
|
||||
id: PartID.ascending(),
|
||||
messageID: next.id,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
// kilocode_change end
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,7 +676,6 @@ const layer = Layer.effect(
|
||||
model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending_user_id?: MessageID // kilocode_change - persist the turn resumed by this marker
|
||||
}) {
|
||||
const msg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -704,7 +692,6 @@ const layer = Layer.effect(
|
||||
type: "compaction",
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
pending_user_id: input.pending_user_id, // kilocode_change
|
||||
})
|
||||
// kilocode_change start - keep auto-compaction markers visible during queued turns
|
||||
KiloSessionPromptQueue.retarget(input.sessionID, msg.id)
|
||||
|
||||
@@ -61,7 +61,6 @@ export interface Handle {
|
||||
) => Effect.Effect<void>
|
||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||
readonly compactError?: () => ReturnType<typeof MessageV2.ContextOverflowError.prototype.toObject> | undefined // kilocode_change
|
||||
readonly compactProgress?: () => boolean // kilocode_change - whether the overflowing attempt produced durable progress
|
||||
}
|
||||
|
||||
type Input = {
|
||||
@@ -897,7 +896,6 @@ const layer = Layer.effect(
|
||||
// kilocode_change start
|
||||
const output = {
|
||||
compactError: () => ctx.compactionError,
|
||||
compactProgress: () => attempt.text || attempt.reasoning || attempt.tool,
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kil
|
||||
import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change
|
||||
import * as KiloWorkflowVariant from "@/kilocode/session/workflow-variant" // kilocode_change
|
||||
import { KiloSessionOverflow } from "@/kilocode/session/overflow" // kilocode_change
|
||||
import { KiloSessionCompaction } from "@/kilocode/session/compaction" // kilocode_change
|
||||
import { KiloReference } from "@/kilocode/reference/contains" // kilocode_change
|
||||
import { KiloReadObject } from "@/kilocode/tool/read-object" // kilocode_change
|
||||
import { isInterrupted } from "@/kilocode/effect/cause" // kilocode_change
|
||||
@@ -1474,8 +1473,6 @@ export const layer = Layer.effect(
|
||||
const memoryCache = KiloSessionPrompt.memoryCache() // kilocode_change
|
||||
closeReasons.delete(sessionID) // kilocode_change
|
||||
let compactionAttempts = 0 // kilocode_change - cap compaction attempts per turn to avoid infinite loops
|
||||
let focus: MessageID | undefined // kilocode_change - complete recovered turns before later prompts
|
||||
const handoffs: MessageID[] = [] // kilocode_change
|
||||
const ctx = yield* InstanceState.context
|
||||
let structured: unknown
|
||||
let step = 0
|
||||
@@ -1494,7 +1491,7 @@ export const layer = Layer.effect(
|
||||
msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) // kilocode_change - trim on any completed summary (e.g. manual /compact against a text user)
|
||||
|
||||
// kilocode_change start - select loop state by chronology after retained-tail projection
|
||||
const latest = KiloSessionMessageOrder.latest(msgs, focus)
|
||||
const latest = KiloSessionMessageOrder.latest(msgs)
|
||||
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest
|
||||
// kilocode_change end
|
||||
|
||||
@@ -1524,21 +1521,6 @@ export const layer = Layer.effect(
|
||||
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
|
||||
) ?? false
|
||||
|
||||
// kilocode_change start - complete recovered turns before later durable prompts
|
||||
if (
|
||||
focus &&
|
||||
handoffs.length > 0 &&
|
||||
latest.userMessage?.info.id === focus &&
|
||||
lastAssistant?.parentID === focus &&
|
||||
lastAssistant.finish &&
|
||||
lastAssistant.finish !== "tool-calls" &&
|
||||
!hasToolCalls
|
||||
) {
|
||||
focus = handoffs.shift()
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - plan_exit is a hard stop before another model call
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
@@ -1596,56 +1578,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (task?.type === "compaction") {
|
||||
// kilocode_change start - resolve the marker's persisted or unambiguous legacy pending turn
|
||||
const marker = msgs.find((item) => item.info.id === task.messageID)
|
||||
const target = marker?.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction")
|
||||
const resolved =
|
||||
target && (target.pending_user_id || (task.auto && task.overflow === false))
|
||||
? KiloSessionCompaction.resolve({ part: target, messages: msgs })
|
||||
: undefined
|
||||
if (resolved?.kind === "unresolved") {
|
||||
const error = new NamedError.Unknown({
|
||||
message: `Automatic compaction could not identify the pending user request: ${resolved.reason}.`,
|
||||
})
|
||||
closeReasons.set(sessionID, "error")
|
||||
yield* events.publish(Session.Event.Error, { sessionID, error: error.toObject() })
|
||||
break
|
||||
}
|
||||
if (resolved?.kind === "stale") {
|
||||
yield* sessions.removeMessage({ sessionID, messageID: task.messageID })
|
||||
continue
|
||||
}
|
||||
const pending =
|
||||
resolved?.kind === "found" && resolved.message.info.role === "user" ? resolved.message : undefined
|
||||
if (pending) {
|
||||
for (const item of KiloSessionCompaction.followups({ messages: msgs, after: pending })) {
|
||||
if (!handoffs.includes(item.id)) handoffs.push(item.id)
|
||||
}
|
||||
}
|
||||
const answered =
|
||||
pending &&
|
||||
msgs.some(
|
||||
(item) =>
|
||||
item.info.role === "assistant" &&
|
||||
item.info.parentID === pending.info.id &&
|
||||
!!item.info.finish &&
|
||||
!item.parts.some(
|
||||
(part) =>
|
||||
part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
|
||||
),
|
||||
)
|
||||
if (answered) {
|
||||
yield* sessions.removeMessage({ sessionID, messageID: task.messageID })
|
||||
continue
|
||||
}
|
||||
// kilocode_change end
|
||||
const result = yield* compaction.process({
|
||||
messages: msgs,
|
||||
parentID: task.messageID,
|
||||
parentID: task.messageID, // kilocode_change
|
||||
sessionID,
|
||||
auto: task.auto,
|
||||
overflow: task.overflow,
|
||||
pending: pending?.info.id,
|
||||
})
|
||||
// kilocode_change start - compaction.process only returns "stop" after
|
||||
// setting ContextOverflowError on the summary message; surface as turn error
|
||||
@@ -1653,14 +1591,6 @@ export const layer = Layer.effect(
|
||||
closeReasons.set(sessionID, "error")
|
||||
break
|
||||
}
|
||||
const continuation = (yield* sessions.messages({ sessionID }).pipe(Effect.orDie)).findLast(
|
||||
(item) =>
|
||||
item.info.role === "user" &&
|
||||
item.parts.some(
|
||||
(part) => part.type === "text" && part.synthetic && part.metadata?.compaction_continue === true,
|
||||
),
|
||||
)
|
||||
if (continuation) focus = continuation.info.id
|
||||
// kilocode_change end
|
||||
continue
|
||||
}
|
||||
@@ -1686,14 +1616,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
compactionAttempts++
|
||||
// kilocode_change end
|
||||
yield* compaction.create({
|
||||
sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: lastUser.model,
|
||||
auto: true,
|
||||
overflow: false,
|
||||
pending_user_id: lastUser.id, // kilocode_change - persist the preflight request identity
|
||||
}) // kilocode_change - compact around the pending turn, then resume it
|
||||
yield* compaction.create({ sessionID, agent: lastUser.agent, model: lastUser.model, auto: true, overflow: false }) // kilocode_change
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1921,9 +1844,7 @@ export const layer = Layer.effect(
|
||||
const tools = parts.some(
|
||||
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
|
||||
)
|
||||
if (handle.message.finish && handle.message.finish !== "tool-calls" && !tools) {
|
||||
yield* sessions.updateMessage(handle.message)
|
||||
} else {
|
||||
if (!handle.message.finish || ["tool-calls", "unknown"].includes(handle.message.finish) || tools) {
|
||||
const guard = KiloSessionPrompt.guardCompactionAttempt({
|
||||
sessionID,
|
||||
attempts: compactionAttempts,
|
||||
@@ -1936,15 +1857,12 @@ export const layer = Layer.effect(
|
||||
return "break" as const
|
||||
}
|
||||
compactionAttempts++
|
||||
const overflow = handle.compactError?.() !== undefined
|
||||
yield* compaction.create({
|
||||
sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: lastUser.model,
|
||||
auto: true,
|
||||
overflow: handle.message.finish === "tool-calls" || handle.compactProgress?.() ? undefined : overflow,
|
||||
pending_user_id:
|
||||
handle.message.finish === "tool-calls" || handle.compactProgress?.() ? undefined : lastUser.id, // kilocode_change
|
||||
overflow: handle.message.finish ? undefined : handle.compactError?.() !== undefined,
|
||||
})
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
@@ -900,11 +900,6 @@ export const layer: Layer.Layer<
|
||||
if (p.type === "compaction" && p.tail_start_id) {
|
||||
p.tail_start_id = idMap.get(p.tail_start_id)
|
||||
}
|
||||
// kilocode_change start - remap the persisted pending turn when forking a session
|
||||
if (p.type === "compaction" && p.pending_user_id) {
|
||||
p.pending_user_id = idMap.get(p.pending_user_id)
|
||||
}
|
||||
// kilocode_change end
|
||||
yield* updatePart(p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -692,19 +692,17 @@ describe("KiloCompactionChunks", () => {
|
||||
sessionID: session.id,
|
||||
auto: true,
|
||||
overflow: true,
|
||||
pending: large.id,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const all = await svc.messages({ sessionID: session.id })
|
||||
const next = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
|
||||
const part = next?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
const replay = all.findLast((msg) => msg.info.role === "user" && msg.info.id !== large.id)
|
||||
const part = replay?.parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
expect(calls[0]).toContain("Summarize conversation chunk")
|
||||
expect(part?.synthetic).toBe(true)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toContain("Summarize conversation chunk 1 of 1")
|
||||
expect(part?.text).toContain("compacted representation")
|
||||
expect(part?.text).toContain("replay summary")
|
||||
} finally {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { KiloSessionCompaction } from "../../src/kilocode/session/compaction"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
|
||||
const sessionID = SessionID.make("ses_safety")
|
||||
@@ -88,29 +87,6 @@ function compactionPart(messageID: string, tailStartID: string): MessageV2.Compa
|
||||
}
|
||||
}
|
||||
|
||||
test("fails closed for an ambiguous legacy compaction marker", () => {
|
||||
const marker: MessageV2.WithParts = {
|
||||
info: userInfo("msg_marker"),
|
||||
parts: [
|
||||
{
|
||||
id: PartID.make("prt_marker"),
|
||||
sessionID,
|
||||
messageID: MessageID.make("msg_marker"),
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
overflow: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
const root = { info: userInfo("msg_root"), parts: [] }
|
||||
const second = { info: userInfo("msg_second"), parts: [] }
|
||||
const result = KiloSessionCompaction.resolve({
|
||||
part: marker.parts[0] as MessageV2.CompactionPart,
|
||||
messages: [root, second, marker],
|
||||
})
|
||||
expect(result.kind).toBe("unresolved")
|
||||
})
|
||||
|
||||
function subtaskPart(messageID: string): MessageV2.SubtaskPart {
|
||||
return {
|
||||
id: PartID.make("prt_subtask_" + messageID),
|
||||
|
||||
@@ -254,12 +254,7 @@ const user = Effect.fn("prompt-safety.user")(function* (
|
||||
const assistant = Effect.fn("prompt-safety.assistant")(function* (
|
||||
sessionID: SessionID,
|
||||
parentID: MessageID,
|
||||
input?: {
|
||||
text?: string
|
||||
summary?: boolean
|
||||
tokens?: MessageV2.Assistant["tokens"]
|
||||
finish?: MessageV2.Assistant["finish"]
|
||||
},
|
||||
input?: { text?: string; summary?: boolean; tokens?: MessageV2.Assistant["tokens"]; finish?: string },
|
||||
) {
|
||||
const sessions = yield* Session.Service
|
||||
const msg = yield* sessions.updateMessage({
|
||||
@@ -369,376 +364,120 @@ describe("SessionPrompt compaction safety", () => {
|
||||
)
|
||||
|
||||
it.live(
|
||||
"answers a pending turn once after prior usage triggers compaction",
|
||||
"answers a pending request once after prior usage triggers compaction",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Pending turn compaction",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const old = yield* user(chat.id, "old prompt")
|
||||
const chat = yield* sessions.create({})
|
||||
const old = yield* user(chat.id, "old request")
|
||||
yield* assistant(chat.id, old.id, {
|
||||
text: "old answer",
|
||||
tokens: { input: 95_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
const editor = { activeFile: "src/app.ts", openTabs: ["src/app.ts", "src/lib.ts"] }
|
||||
const editor = { activeFile: "src/app.ts", openTabs: ["src/app.ts"] }
|
||||
const pending = yield* user(chat.id, "pending request", { editorContext: editor })
|
||||
yield* file(chat.id, pending.id, { mime: "image/png", name: "pending.png", body: "PENDINGIMAGE" })
|
||||
yield* llm.text("summary")
|
||||
yield* llm.text("pending answer")
|
||||
yield* llm.text("answer")
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "pending answer")).toBe(true)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "answer")).toBe(true)
|
||||
const body = JSON.stringify((yield* llm.inputs).at(-1)?.messages)
|
||||
expect(body).toContain("pending request")
|
||||
expect(body.match(/pending request/g)).toHaveLength(1)
|
||||
expect(body).toContain("PENDINGIMAGE")
|
||||
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
const messages = yield* sessions.messages({ sessionID: chat.id })
|
||||
const resumed = messages.findLast((msg) => msg.info.role === "user")
|
||||
expect(resumed?.info.role === "user" ? resumed.info.editorContext : undefined).toEqual(editor)
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "pending request"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
msgs
|
||||
messages
|
||||
.flatMap((msg) => msg.parts)
|
||||
.some(
|
||||
(part) =>
|
||||
part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"),
|
||||
),
|
||||
).toBe(false)
|
||||
const continuation = msgs.find(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.info.id !== pending.id &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text.includes("Continue the pending user request")),
|
||||
)
|
||||
expect(continuation?.info.role === "user" ? continuation.info.editorContext : undefined).toEqual(editor)
|
||||
.filter((part) => part.type === "text" && part.text === "pending request" && !part.synthetic),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"compacts an oversized first request instead of dropping it",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Oversized first request",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
for (const finish of ["stop", "tool_calls", "length"] as const) {
|
||||
it.live(
|
||||
`does not replay completed work after a ${finish} response exceeds the budget`,
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const tools = finish !== "stop"
|
||||
const chat = yield* sessions.create({ permission: [{ permission: "*", pattern: "*", action: "allow" }] })
|
||||
yield* llm.push(
|
||||
(tools ? reply().tool("glob", { pattern: "*.txt" }) : reply().text("answer"))
|
||||
.finish(finish)
|
||||
.usage({ input: 95_000, output: 100 }),
|
||||
)
|
||||
if (tools) {
|
||||
yield* llm.text("tool progress summary")
|
||||
yield* llm.text("answer")
|
||||
}
|
||||
yield* user(chat.id, "perform this once")
|
||||
|
||||
yield* user(chat.id, "first request " + "x".repeat(240_000))
|
||||
yield* llm.text("history summary")
|
||||
yield* llm.text("request summary")
|
||||
yield* llm.text("final answer")
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(3)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "final answer")).toBe(true)
|
||||
const body = JSON.stringify((yield* llm.inputs).at(-1)?.messages)
|
||||
expect(body).toContain("compacted representation")
|
||||
expect(body).toContain("request summary")
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text.startsWith("first request")),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: (url) => ({
|
||||
...providerCfg(url),
|
||||
compaction: { auto: true, threshold_percent: 70, tail_turns: 0, preserve_recent_tokens: 0 },
|
||||
expect(yield* llm.calls).toBe(tools ? 3 : 1)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "answer")).toBe(true)
|
||||
const parts = (yield* sessions.messages({ sessionID: chat.id })).flatMap((msg) => msg.parts)
|
||||
expect(parts.filter((part) => part.type === "text" && part.text === "perform this once")).toHaveLength(1)
|
||||
expect(parts.filter((part) => part.type === "tool")).toHaveLength(tools ? 1 : 0)
|
||||
}),
|
||||
},
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
}
|
||||
|
||||
it.live(
|
||||
"does not compact or replay a completed turn after a terminal response",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Completed turn compaction",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
yield* llm.text("completed answer", { usage: { input: 95_000, output: 100 } })
|
||||
yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "code",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "complete this once" }],
|
||||
})
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "completed answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(msgs.flatMap((msg) => msg.parts).some((part) => part.type === "compaction")).toBe(false)
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "complete this once"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"discards a persisted completed-turn preflight marker without replaying",
|
||||
"includes completed tool progress when a saved marker requests replay",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Persisted compaction marker",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const request = yield* user(chat.id, "already completed")
|
||||
yield* assistant(chat.id, request.id, { text: "completed answer" })
|
||||
yield* compaction.create({ sessionID: chat.id, agent: "code", model: ref, auto: true, overflow: false })
|
||||
yield* user(chat.id, "later queued request")
|
||||
yield* llm.text("later answer")
|
||||
const before = yield* sessions.messages({ sessionID: chat.id })
|
||||
const marker = before.find((msg) => msg.parts.some((part) => part.type === "compaction"))
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "later answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(msgs.some((msg) => msg.info.id === marker?.info.id)).toBe(false)
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "already completed"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"resumes the persisted preflight request when a later prompt exists after restart",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Persisted pending request",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const old = yield* user(chat.id, "old prompt")
|
||||
yield* assistant(chat.id, old.id, {
|
||||
text: "old answer",
|
||||
tokens: { input: 95_000, output: 100, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
const pending = yield* user(chat.id, "pending request")
|
||||
yield* compaction.create({
|
||||
sessionID: chat.id,
|
||||
agent: "code",
|
||||
model: ref,
|
||||
auto: true,
|
||||
overflow: false,
|
||||
pending_user_id: pending.id,
|
||||
})
|
||||
const stored = yield* sessions.messages({ sessionID: chat.id })
|
||||
const part = stored
|
||||
.flatMap((msg) => msg.parts)
|
||||
.find((item): item is MessageV2.CompactionPart => item.type === "compaction")
|
||||
expect(part?.pending_user_id).toBe(pending.id)
|
||||
yield* user(chat.id, "later queued request")
|
||||
yield* llm.text("pending summary")
|
||||
yield* llm.text("pending answer")
|
||||
yield* llm.text("later answer")
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(3)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "later answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "pending request"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "later queued request"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"does not replay a user request after a completed tool step triggers compaction",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Tool step compaction",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const old = yield* user(chat.id, "old prompt")
|
||||
yield* assistant(chat.id, old.id, { text: "old answer" })
|
||||
yield* user(chat.id, "find files once")
|
||||
yield* llm.push(reply().tool("glob", { pattern: "**/*.txt" }).usage({ input: 95_000, output: 100 }))
|
||||
yield* llm.text("tool progress summary")
|
||||
yield* llm.text("final answer")
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(3)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "final answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "find files once"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "tool" && part.tool === "glob"),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"compacts a non-tool finish when it contains tool progress",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Stop tool compaction",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
yield* llm.push(
|
||||
reply().tool("glob", { pattern: "**/*.txt" }).finish("length").usage({ input: 95_000, output: 100 }),
|
||||
)
|
||||
yield* llm.text("tool progress summary")
|
||||
yield* llm.text("final answer")
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: chat.id,
|
||||
agent: "code",
|
||||
noReply: true,
|
||||
parts: [{ type: "text", text: "find files once" }],
|
||||
})
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(3)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "final answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(
|
||||
msgs.filter(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((part) => part.type === "text" && part.text === "find files once"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
msgs.flatMap((msg) => msg.parts).filter((part) => part.type === "tool" && part.tool === "glob"),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
30_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"keeps a tool-bearing turn when recovering a stale preflight marker",
|
||||
() =>
|
||||
provideTmpdirServer(
|
||||
Effect.fnUntraced(function* ({ llm }) {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const chat = yield* sessions.create({
|
||||
title: "Tool stale marker",
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
})
|
||||
|
||||
const chat = yield* sessions.create({})
|
||||
const old = yield* user(chat.id, "old request")
|
||||
yield* assistant(chat.id, old.id)
|
||||
const request = yield* user(chat.id, "run the tool")
|
||||
const replyMessage = yield* assistant(chat.id, request.id, { finish: "tool-calls" })
|
||||
const response = yield* assistant(chat.id, request.id, { finish: "tool-calls" })
|
||||
yield* sessions.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: replyMessage.id,
|
||||
sessionID: chat.id,
|
||||
messageID: response.id,
|
||||
type: "tool",
|
||||
callID: "call_stale",
|
||||
callID: "call_glob",
|
||||
tool: "glob",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { pattern: "**/*.txt" },
|
||||
output: "files",
|
||||
input: { pattern: "*.txt" },
|
||||
output: "already ran glob",
|
||||
title: "glob",
|
||||
metadata: {},
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
},
|
||||
} satisfies MessageV2.ToolPart)
|
||||
})
|
||||
yield* compaction.create({ sessionID: chat.id, agent: "code", model: ref, auto: true, overflow: false })
|
||||
yield* llm.text("summary")
|
||||
yield* llm.text("final answer")
|
||||
yield* llm.text("tool progress summary")
|
||||
yield* llm.text("answer")
|
||||
|
||||
const result = yield* prompt.loop({ sessionID: chat.id })
|
||||
yield* prompt.loop({ sessionID: chat.id })
|
||||
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(result.parts.some((part) => part.type === "text" && part.text === "final answer")).toBe(true)
|
||||
const msgs = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(msgs.some((msg) => msg.parts.some((part) => part.type === "compaction"))).toBe(true)
|
||||
expect(JSON.stringify((yield* llm.inputs)[0]?.messages)).toContain("already ran glob")
|
||||
const messages = yield* sessions.messages({ sessionID: chat.id })
|
||||
expect(
|
||||
messages.flatMap((msg) => msg.parts).filter((part) => part.type === "text" && part.text === "run the tool"),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
{ git: true, config: providerCfg },
|
||||
),
|
||||
|
||||
@@ -1176,7 +1176,6 @@ describe("session.compaction.process", () => {
|
||||
}).pipe(withCompaction({ plugin: autocontinue(false) })),
|
||||
)
|
||||
|
||||
// kilocode_change start - preserve non-media attachments during overflow recovery
|
||||
it.instance(
|
||||
"replays the prior user turn on overflow when earlier context exists",
|
||||
Effect.gen(function* () {
|
||||
@@ -1193,15 +1192,6 @@ describe("session.compaction.process", () => {
|
||||
filename: "cat.png",
|
||||
url: "https://example.com/cat.png",
|
||||
})
|
||||
yield* ssn.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: replay.id,
|
||||
sessionID: session.id,
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "notes.txt",
|
||||
url: "https://example.com/notes.txt",
|
||||
})
|
||||
const msg = yield* createUserMessage(session.id, "current")
|
||||
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||
|
||||
@@ -1217,100 +1207,19 @@ describe("session.compaction.process", () => {
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.info.role).toBe("user")
|
||||
expect(last?.parts.some((part) => part.type === "file" && part.mime === "image/png")).toBe(false) // kilocode_change
|
||||
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)
|
||||
expect(
|
||||
last?.parts.some(
|
||||
(part) => part.type === "file" && part.mime === "text/plain" && part.url === "https://example.com/notes.txt",
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
it.instance(
|
||||
"replays a pending turn without stripping media when requested",
|
||||
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,
|
||||
pending: replay.id,
|
||||
})
|
||||
|
||||
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.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||
expect(
|
||||
all.filter(
|
||||
(item) =>
|
||||
item.info.role === "user" && item.parts.some((part) => part.type === "text" && part.text === "image"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
expect(all.find((item) => item.info.id === replay.id)?.parts.some((part) => part.type === "file")).toBe(true)
|
||||
}),
|
||||
)
|
||||
it.instance(
|
||||
"does not replay a completed turn when overflow is false",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
yield* createUserMessage(session.id, "root")
|
||||
yield* createUserMessage(session.id, "answered prompt")
|
||||
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: false,
|
||||
})
|
||||
|
||||
const all = yield* ssn.messages({ sessionID: session.id })
|
||||
const last = all.at(-1)
|
||||
|
||||
expect(result).toBe("continue")
|
||||
expect(last?.parts[0]).toMatchObject({
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
metadata: { compaction_continue: true },
|
||||
})
|
||||
expect(
|
||||
all.filter(
|
||||
(item) =>
|
||||
item.info.role === "user" &&
|
||||
item.parts.some((part) => part.type === "text" && part.text === "answered prompt"),
|
||||
),
|
||||
).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
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 })
|
||||
|
||||
@@ -1331,7 +1240,6 @@ describe("session.compaction.process", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
itCompaction.instance(
|
||||
"stops quickly when aborted during retry backoff",
|
||||
|
||||
@@ -115,7 +115,6 @@ const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* (
|
||||
sessionID: SessionID,
|
||||
messageID: MessageID,
|
||||
tailStartID?: MessageID,
|
||||
pendingUserID?: MessageID, // kilocode_change
|
||||
) {
|
||||
const session = yield* SessionNs.Service
|
||||
yield* session.updatePart({
|
||||
@@ -124,7 +123,6 @@ const addCompactionPart = Effect.fn("Test.addCompactionPart")(function* (
|
||||
messageID,
|
||||
type: "compaction",
|
||||
auto: true,
|
||||
pending_user_id: pendingUserID, // kilocode_change
|
||||
tail_start_id: tailStartID,
|
||||
} as any)
|
||||
})
|
||||
@@ -779,7 +777,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
})
|
||||
|
||||
const c1 = yield* addUser(created.id)
|
||||
yield* addCompactionPart(created.id, c1, u2, u2) // kilocode_change
|
||||
yield* addCompactionPart(created.id, c1, u2)
|
||||
const s1 = yield* addAssistant(created.id, c1, { summary: true, finish: "end_turn" })
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
@@ -811,8 +809,6 @@ describe("MessageV2.filterCompacted", () => {
|
||||
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
|
||||
expect(tailPart.tail_start_id).toBeDefined()
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
|
||||
expect(tailPart.pending_user_id).toBeDefined() // kilocode_change
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.pending_user_id)).toBe(true) // kilocode_change
|
||||
|
||||
yield* session.remove(forked.id)
|
||||
yield* session.remove(created.id)
|
||||
|
||||
@@ -197,9 +197,6 @@ export const CompactionPart = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
auto: Schema.Boolean,
|
||||
overflow: Schema.optional(Schema.Boolean),
|
||||
// kilocode_change start - identify the user turn resumed by automatic compaction
|
||||
pending_user_id: Schema.optional(MessageID),
|
||||
// kilocode_change end
|
||||
tail_start_id: Schema.optional(MessageID),
|
||||
}).annotate({ identifier: "CompactionPart" })
|
||||
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
|
||||
|
||||
@@ -976,7 +976,6 @@ export type CompactionPart = {
|
||||
type: "compaction"
|
||||
auto: boolean
|
||||
overflow?: boolean
|
||||
pending_user_id?: string
|
||||
tail_start_id?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -29158,10 +29158,6 @@
|
||||
"overflow": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"pending_user_id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg"
|
||||
},
|
||||
"tail_start_id": {
|
||||
"type": "string",
|
||||
"pattern": "^msg"
|
||||
|
||||
Reference in New Issue
Block a user