Skip to content

Commit 09e9581

Browse files
anandgupta42claude
andcommitted
fix(analyst): publish a recovered committed answer on a fresh text part when the summary already streamed
A compaction summariser that streams partial text and then fails recoverably had already sent deltas for its text part; reusing that part id published the committed answer as another delta on it, and clients that do not treat a blank update as a reset showed the partial summary concatenated with the answer. - `finishCommittedAnswer` reuses a part id only for buffered (`deferText`) echo text; when the stored text was streamed it removes every stored text part and publishes under a fresh id - `run --output`: the collector tracks part ids and drops removed parts from the reply, the previous reply and the fallback; `run.ts` forwards `message.part.removed` - ACP: compaction-summary message text is no longer forwarded as agent chunks (ACP cannot retract chunks); clearing the summary flag on recovery lets the committed delta through exactly once; subscriptions that join mid-stream pick the flag up from the fetched message - tests: streamed partial summary + 503, and two empty summaries after streamed text, each deliver exactly the committed text to ACP and run output; collector removal cases; ordinary buffered echo still reuses its id and publishes once Co-Authored-By: Claude Fable 5.1 <[email protected]>
1 parent 56602cd commit 09e9581

5 files changed

Lines changed: 194 additions & 12 deletions

File tree

‎packages/opencode/src/acp/event.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export class Subscription {
4040
private readonly abort = new AbortController()
4141
private readonly shellSnapshots = new Map<string, string>()
4242
private readonly toolStarts = new Set<string>()
43+
// altimate_change start — ACP chunks cannot retract provisional compaction text
44+
private readonly summaryMessages = new Set<string>()
45+
// altimate_change end
4346
private readonly permission: ACPPermission.Handler
4447
private started = false
4548

@@ -67,6 +70,15 @@ export class Subscription {
6770

6871
async handle(event: Event) {
6972
switch (event.type) {
73+
// altimate_change start — recovery clears summary before publishing its authoritative answer
74+
case "message.updated": {
75+
const message = event.properties.info
76+
if (message.role !== "assistant") return
77+
if (message.summary) this.summaryMessages.add(message.id)
78+
else this.summaryMessages.delete(message.id)
79+
return
80+
}
81+
// altimate_change end
7082
case "permission.asked":
7183
this.permission.handle(event)
7284
return
@@ -169,6 +181,9 @@ export class Subscription {
169181
: await this.fetchPartMetadata(session.id, session.cwd, props.messageID, props.partID)
170182
if (metadata?.role !== "assistant") return
171183
if (metadata.partType === "text" && props.field === "text" && metadata.ignored !== true) {
184+
// altimate_change start — internal summaries may be removed or replaced after streaming
185+
if (this.summaryMessages.has(props.messageID)) return
186+
// altimate_change end
172187
await this.input.connection.sessionUpdate({
173188
sessionId: session.id,
174189
update: {
@@ -218,6 +233,9 @@ export class Subscription {
218233
}
219234

220235
private async recordFetchedPart(sessionId: string, message: SessionMessageResponse, part: Part) {
236+
// altimate_change start — cover subscriptions that join after the initial message update
237+
if (message.info.role === "assistant" && message.info.summary) this.summaryMessages.add(message.info.id)
238+
// altimate_change end
221239
return await Effect.runPromise(
222240
this.input.session.recordPartMetadata({
223241
sessionId,

‎packages/opencode/src/cli/cmd/run-output.ts‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// altimate_change — what `altimate-code run --output <file>` writes.
1+
// altimate_change start — what `altimate-code run --output <file>` writes.
22
//
33
// The file is the answer, not the transcript. A model often narrates between tool
44
// calls ("Mixed time formats. Let me check all formats.") and that text is superseded
@@ -14,10 +14,12 @@ export const NO_TEXT_OUTPUT = "(no text output — tool-only response)"
1414
// token when the deliverable is already complete; that acknowledgement is not a new reply.
1515
const ACKNOWLEDGEMENT = new RegExp(`^${DONE_TOKEN}\\.?$`, "i")
1616

17+
type CollectedText = { text: string; partID?: string }
18+
1719
export class OutputCollector {
18-
private readonly all: string[] = []
19-
private final: string[] = []
20-
private previous: string[] = []
20+
private all: CollectedText[] = []
21+
private final: CollectedText[] = []
22+
private previous: CollectedText[] = []
2123

2224
/** A new model step starts; text before it is no longer the reply. Compaction steps do not count. */
2325
stepStart(compaction = false) {
@@ -27,16 +29,24 @@ export class OutputCollector {
2729
}
2830

2931
/** Completed assistant text. Synthetic/compaction text is kept for the fallback only. */
30-
text(text: string, opts: { synthetic?: boolean; compaction?: boolean } = {}) {
32+
text(text: string, opts: { synthetic?: boolean; compaction?: boolean; partID?: string } = {}) {
3133
const trimmed = text.trim()
3234
if (!trimmed) return
33-
this.all.push(trimmed)
35+
const part = { text: trimmed, partID: opts.partID }
36+
this.all.push(part)
3437
if (opts.synthetic || opts.compaction) return
3538
if (ACKNOWLEDGEMENT.test(trimmed) && this.final.length === 0) {
3639
this.final = this.previous
3740
return
3841
}
39-
this.final.push(trimmed)
42+
this.final.push(part)
43+
}
44+
45+
/** Removed text must disappear from the reply and every fallback. */
46+
remove(partID: string) {
47+
this.all = this.all.filter((part) => part.partID !== partID)
48+
this.final = this.final.filter((part) => part.partID !== partID)
49+
this.previous = this.previous.filter((part) => part.partID !== partID)
4050
}
4151

4252
private readonly notes: string[] = []
@@ -51,7 +61,12 @@ export class OutputCollector {
5161
* an answer and would read as one); otherwise everything that was said.
5262
*/
5363
content(): string {
54-
const chosen = this.final.length ? this.final : this.notes.length ? this.notes : this.all
64+
const chosen = this.final.length
65+
? this.final.map((part) => part.text)
66+
: this.notes.length
67+
? this.notes
68+
: this.all.map((part) => part.text)
5569
return chosen.join("\n\n") || NO_TEXT_OUTPUT
5670
}
5771
}
72+
// altimate_change end

‎packages/opencode/src/cli/cmd/run.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,6 +1028,11 @@ You are speaking to a non-technical business executive. Follow these rules stric
10281028
toggles.set("start", true)
10291029
}
10301030

1031+
// altimate_change start — discarded streamed text must not survive in --output fallbacks
1032+
if (event.type === "message.part.removed" && event.properties.sessionID === sessionID) {
1033+
outputParts.remove(event.properties.partID)
1034+
}
1035+
// altimate_change end
10311036
if (event.type === "message.part.updated") {
10321037
const part = event.properties.part
10331038
if (part.sessionID !== sessionID) continue
@@ -1171,6 +1176,7 @@ You are speaking to a non-technical business executive. Follow these rules stric
11711176
const text = part.text.trim()
11721177
if (args.output && text) {
11731178
outputParts.text(text, {
1179+
partID: part.id,
11741180
synthetic: part.synthetic === true,
11751181
compaction: accounting.isCompactionStep(part.messageID),
11761182
})

‎packages/opencode/src/session/prompt.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,9 @@ export namespace SessionPrompt {
916916
// Once the echo has started, this answer still belongs to the earlier request.
917917
// Publish it before the loop continues to any user request queued during generation.
918918
const storedText = MessageV2.parts(message.id).filter((part) => part.type === "text")
919-
const first = deferredText[0] ?? storedText[0]
919+
// Only buffered parts are safe to reuse: stored parts may already have
920+
// streamed deltas, and a blank update does not reset client text.
921+
const first = deferredText[0]
920922
const part: MessageV2.TextPart = {
921923
id: first?.id ?? PartID.ascending(),
922924
sessionID,

0 commit comments

Comments
 (0)