refactor(cli): narrow compaction fix to replay eligibility

This commit is contained in:
marius-kilocode
2026-08-27 12:01:34 +02:00
parent eb3a1ab571
commit ae00c228d4
19 changed files with 132 additions and 736 deletions
@@ -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 })
}),
)
+6 -17
View File
@@ -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,
+46 -59
View File
@@ -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
+5 -87
View File
@@ -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
-5
View File
@@ -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)
-3
View File
@@ -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>>
-1
View File
@@ -976,7 +976,6 @@ export type CompactionPart = {
type: "compaction"
auto: boolean
overflow?: boolean
pending_user_id?: string
tail_start_id?: string
}
-4
View File
@@ -29158,10 +29158,6 @@
"overflow": {
"type": "boolean"
},
"pending_user_id": {
"type": "string",
"pattern": "^msg"
},
"tail_start_id": {
"type": "string",
"pattern": "^msg"