diff --git a/packages/opencode/src/cli/cmd/logs.ts b/packages/opencode/src/cli/cmd/logs.ts index 737a950555c2..a290c4ef7085 100644 --- a/packages/opencode/src/cli/cmd/logs.ts +++ b/packages/opencode/src/cli/cmd/logs.ts @@ -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) 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. diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d199ccf5965d..4d87716c358d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -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() - const messageIDsToPreserve = new Set() - // 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", @@ -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() + const messageIDsToPreserve = new Set() + // 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, diff --git a/packages/opencode/src/session/reminders.ts b/packages/opencode/src/session/reminders.ts index f5484b8e9ba4..5915f949e49a 100644 --- a/packages/opencode/src/session/reminders.ts +++ b/packages/opencode/src/session/reminders.ts @@ -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, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4a4210cf08bc..22b6b558e049 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -195,7 +195,7 @@ function createCompactionMarker(sessionID: SessionID) { function fake( input: Parameters[0], - result: "continue" | "compact", + result: "continue" | "compact" | "stop", ) { const msg = input.assistantMessage return { @@ -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({ @@ -245,7 +255,7 @@ const compactionEnv = AppNodeBuilder.build( const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { - result?: "continue" | "compact" + result?: "continue" | "compact" | "stop" llm?: Layer.Layer plugin?: Layer.Layer provider?: ReturnType @@ -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 }) })), + ) + it.instance( "adds synthetic continue prompt when auto is enabled", Effect.gen(function* () {