Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export const LogsCommand = effectCmd({
const lines = text.split("\n")
if (lines.at(-1) === "") lines.pop()
const count = Math.max(0, Math.floor(args.tail))
for (const line of lines.slice(-count)) console.log(line)
// slice(-0) === slice(0) returns the whole array, so guard 0 explicitly to
// mean "print no history" (e.g. `logs --tail 0 --follow` to stream only new lines).
for (const line of count === 0 ? [] : lines.slice(-count)) console.log(line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid using console in code that runs on the browser


It is considered a best practice to avoid the use of any console methods in JavaScript code that will run on the browser.

NOTE: If your repository contains a server side project, you can add "nodejs" to the environment property of analyzer meta in .deepsource.toml.
This will prevent this issue from getting raised.
Documentation for the analyzer meta can be found here.
Alternatively, you can silence this issue for your repository as shown here.

If a specific console call is meant to stay for other reasons, you can add a skipcq comment to that line.
This will inform other developers about the reason behind the log's presence, and prevent DeepSource from flagging it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: this is a CLI command (bolt logs) that intentionally writes log lines to the terminal via console.log; it never runs in a browser. The proper fix is adding "nodejs" to the analyzer environment in .deepsource.toml, not a code change.

if (!args.follow) return
// Follow by re-reading appended bytes whenever the file changes; the log
// is append-only so the previous size is always a valid resume offset.
Expand Down
47 changes: 26 additions & 21 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,27 +339,6 @@ const layer = Layer.effect(
cfg,
model,
})
// Remove old messages that have been summarized (head and hidden) to prevent context buildup
if (!input.overflow) {
const messageIDsToRemove = new Set<MessageID>()
const messageIDsToPreserve = new Set<MessageID>()
// Add head messages (to be summarized in this compaction)
for (const msg of selected.head) {
messageIDsToRemove.add(msg.info.id)
}
// Add previously summarized messages (hidden)
for (const index of hidden) {
messageIDsToRemove.add(history[index].info.id)
}
// Preserve the parent message (needed as parent of the new compaction message)
messageIDsToPreserve.add(input.parentID)
// Remove messages marked for removal but not preserved
for (const msgID of messageIDsToRemove) {
if (!messageIDsToPreserve.has(msgID)) {
yield* session.removeMessage({ sessionID: input.sessionID, messageID: msgID })
}
}
}
// Allow plugins to inject context or replace compaction prompt.
const compacting = yield* plugin.trigger(
"experimental.session.compacting",
Expand Down Expand Up @@ -433,6 +412,32 @@ const layer = Layer.effect(
return "stop"
}

// Remove old messages that have been summarized (head and hidden) to prevent
// context buildup. This must run only after the summary has been generated
// and persisted above: deleting first would permanently lose the history if
// summarization failed or the session was too large to compact. A "stop"
// result with an errored message means no valid summary exists either, so
// the history must survive in that case too.
if (!input.overflow && !processor.message.error) {
const messageIDsToRemove = new Set<MessageID>()
const messageIDsToPreserve = new Set<MessageID>()
// Add head messages (summarized in this compaction)
for (const msg of selected.head) {
messageIDsToRemove.add(msg.info.id)
}
// Add previously summarized messages (hidden)
for (const index of hidden) {
messageIDsToRemove.add(history[index].info.id)
}
// Preserve the parent message (needed as parent of the new compaction message)
messageIDsToPreserve.add(input.parentID)
for (const msgID of messageIDsToRemove) {
if (!messageIDsToPreserve.has(msgID)) {
yield* session.removeMessage({ sessionID: input.sessionID, messageID: msgID })
}
}
}

if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
yield* session.updatePart({
...compactionPart,
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/reminders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const apply = Effect.fn("SessionReminders.apply")(function* (input: {
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
if (wasPlan && input.agent.name === "code") {
userMessage.parts.push({
id: PartID.ascending(),
messageID: userMessage.info.id,
Expand Down
86 changes: 82 additions & 4 deletions packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ function createCompactionMarker(sessionID: SessionID) {

function fake(
input: Parameters<SessionProcessorModule.SessionProcessor.Interface["create"]>[0],
result: "continue" | "compact",
result: "continue" | "compact" | "stop",
) {
const msg = input.assistantMessage
return {
Expand All @@ -204,11 +204,21 @@ function fake(
},
updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)),
completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void),
process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)),
process: Effect.fn("TestSessionProcessor.process")(() =>
Effect.sync(() => {
// The real processor returns "stop" after recording an error on the
// assistant message (aborted, provider failure, ...), so mirror that.
if (result === "stop") {
msg.error = new SessionV1.AbortedError({ message: "processor failed" }).toObject()
msg.finish = "error"
}
return result
}),
),
} satisfies SessionProcessorModule.SessionProcessor.Handle
}

function processorLayer(result: "continue" | "compact") {
function processorLayer(result: "continue" | "compact" | "stop") {
return Layer.succeed(
SessionProcessorModule.SessionProcessor.Service,
SessionProcessorModule.SessionProcessor.Service.of({
Expand Down Expand Up @@ -245,7 +255,7 @@ const compactionEnv = AppNodeBuilder.build(
const itCompaction = testEffect(compactionEnv)

type CompactionProcessOptions = {
result?: "continue" | "compact"
result?: "continue" | "compact" | "stop"
llm?: Layer.Layer<LLM.Service>
plugin?: Layer.Layer<Plugin.Service>
provider?: ReturnType<typeof wide>
Expand Down Expand Up @@ -890,6 +900,74 @@ describe("session.compaction.process", () => {
}).pipe(withCompaction({ result: "compact" })),
)

itCompaction.instance(
"keeps summarized history when compaction fails",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "third")
yield* createSummaryCompaction(session.id)

const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
if (!parent) return

// Non-overflow auto compaction whose summarization fails ("compact"): the
// old head messages must survive, since deletion must happen only after a
// summary has been produced. Regression for history-loss on failed compaction.
const result = yield* SessionCompaction.use.process({
parentID: parent,
messages: msgs,
sessionID: session.id,
auto: true,
})

expect(result).toBe("stop")
const texts = (yield* ssn.messages({ sessionID: session.id }))
.flatMap((msg) => msg.parts)
.flatMap((part) => (part.type === "text" ? [part.text] : []))
expect(texts).toContain("first")
expect(texts).toContain("second")
}).pipe(withCompaction({ result: "compact", config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })),
)

itCompaction.instance(
"keeps summarized history when processing stops with an error",
Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "first")
yield* createUserMessage(session.id, "second")
yield* createUserMessage(session.id, "third")
yield* createSummaryCompaction(session.id)

const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
if (!parent) return

// Processing that stops with an errored assistant message ("stop" +
// message.error) produced no valid summary either, so the summarized
// head messages must survive. Regression for history-loss on errored stop.
const result = yield* SessionCompaction.use.process({
parentID: parent,
messages: msgs,
sessionID: session.id,
auto: true,
})

expect(result).toBe("stop")
const texts = (yield* ssn.messages({ sessionID: session.id }))
.flatMap((msg) => msg.parts)
.flatMap((part) => (part.type === "text" ? [part.text] : []))
expect(texts).toContain("first")
expect(texts).toContain("second")
}).pipe(withCompaction({ result: "stop", config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })),
Comment on lines +945 to +968

@coderabbitai coderabbitai Bot Aug 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 25 '\bwithCompaction\b|\bprocessorLayer\b|\bcreateSummaryCompaction\b' packages/opencode --glob '*.ts'

Repository: bolt-builder/bolt-cli

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '180,305p' packages/opencode/test/session/compaction.test.ts
printf '\n--- SessionCompaction implementation references ---\n'
rg -n -C 12 'create:|process:|SessionProcessor|\.create\(' packages/opencode/src --glob '*.ts' | head -n 240

Repository: bolt-builder/bolt-cli

Length of output: 22071


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'compaction' packages/opencode/src packages/opencode/test | head -n 80
rg -n -C 18 'SessionProcessor|processor\.create|processor\.process|createSummaryCompaction|summary' packages/opencode/src/session packages/opencode/src --glob '*compaction*.ts' --glob '*.ts' | head -n 320

Repository: bolt-builder/bolt-cli

Length of output: 25684


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l packages/opencode/src/session/compaction.ts
ast-grep outline packages/opencode/src/session/compaction.ts
sed -n '1,260p' packages/opencode/src/session/compaction.ts

Repository: bolt-builder/bolt-cli

Length of output: 9728


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '260,588p' packages/opencode/src/session/compaction.ts

Repository: bolt-builder/bolt-cli

Length of output: 12234


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

test = Path("packages/opencode/test/session/compaction.test.ts").read_text()
impl = Path("packages/opencode/src/session/compaction.ts").read_text()

helper = test[test.index("function createSummaryCompaction"):test.index("function readCompactionPart")]
create = impl[impl.index("const create = Effect.fn"):impl.index("return Service.of", impl.index("const create = Effect.fn"))]
process = impl[impl.index("const processCompaction"):impl.index("const create = Effect.fn")]

print("helper calls SessionCompaction.use.create:", "SessionCompaction.use.create" in helper)
print("helper calls SessionCompaction.use.process:", "SessionCompaction.use.process" in helper)
print("create invokes processors.create:", "processors.create" in create)
print("process invokes processors.create:", "processors.create" in process)
print("stop path preserves messages:", 'if (!input.overflow && !processor.message.error)' in process)
PY

Repository: bolt-builder/bolt-cli

Length of output: 372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -i 'processor' packages/opencode/src/session | head -n 40
rg -n -C 12 'finish\s*=|finish:|return "continue"|return "stop"|interface Interface|create:' packages/opencode/src/session/processor* packages/opencode/src/session --glob '*.ts' | head -n 260

Repository: bolt-builder/bolt-cli

Length of output: 20578


Seed a completed summary before testing the stop path.

createSummaryCompaction only creates a compaction marker. It does not create a summary. Add a completed summary message before invoking process with "stop", and make the fake processor return "stop" only for that second call.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/test/session/compaction.test.ts` around lines 945 - 968,
Update the test around createSummaryCompaction and SessionCompaction.use.process
to first execute a completed summary-generation call, ensuring the fake
processor returns its normal successful result for that call. Then invoke the
stop-path process as the second call and configure the fake processor to return
"stop" only on that second invocation, preserving the assertions that summarized
messages remain available.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test exercises the cleanup guard within a single process call: the fake sets message.error and returns "stop", and the deletion candidates are the seeded head turns from selected.head, not a prior summary. Seeding a completed summary first and stopping only on a second call would exercise the same guard with extra machinery, mirroring the adjacent pre-verified regression test's seeding pattern.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped: comment is from another GitHub bot.

)

it.instance(
"adds synthetic continue prompt when auto is enabled",
Effect.gen(function* () {
Expand Down
Loading