fix(core): keep the hook context block on the turn that compacts

A suppressed UserPromptSubmit block is decided against pre-compaction
history, but automatic compaction replaces that history inside the same
send, so the block's only copy is dropped before the request is built
and the model sees it zero times on that turn. Pass the suppressed
payload down and re-append it when the send compacts.

Also restore the unconditional append in the ACP path when the prompt
resolved to no parts: `parts.length > 0` gated the append as well as the
suppression, so an empty-parts turn reached `toParts([])` and threw.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvec3c4pgi
This commit is contained in:
jinjing.zzjandQwen-Coder committed 2026-10-19 06:37:31 +08:00
1 parent 4449af7f75
commit bdb593edb3
5 files changed
+58 -5

No files matched your search

@@ -6826,10 +6826,10 @@ export class Session implements SessionContext {
const additionalContext = hookOutput?.getAdditionalContext();
if (
additionalContext &&
parts.length > 0 &&
!this.#getCurrentChat().findLastHistoryEntry((entry) =>
hasUserPromptSubmitContext(entry, additionalContext),
)
(parts.length === 0 ||
!this.#getCurrentChat().findLastHistoryEntry((entry) =>
hasUserPromptSubmitContext(entry, additionalContext),
))
) {
parts = [
...parts,
+5
View File
@@ -3996,6 +3996,8 @@ export class LlmClient {
let userPromptRecordPayload: UserPromptRecordPayload | undefined;
/** Hook context appended to this turn's request, if any. */
let appendedHookContext: string | undefined;
/** Hook context suppressed as already delivered; compression may need it back. */
let suppressedHookContext: string | undefined;
let hooksEnabled: boolean;
let messageBus: ReturnType<Config['getMessageBus']>;
let userPromptSubmitFailureMessage = 'UserPromptSubmit hook failed';
@@ -4122,6 +4124,8 @@ export class LlmClient {
createUserPromptSubmitContextPart(additionalContext),
];
appendedHookContext = additionalContext;
} else {
suppressedHookContext = additionalContext;
}
if (messageType === SendMessageType.UserQuery) {
// A suppressed turn appends nothing, so it records no
@@ -4680,6 +4684,7 @@ export class LlmClient {
messageType === SendMessageType.UserQuery ? prompt_id : undefined,
options?.retractDeliveredOutputOnRetry,
options?.onPreparedRequest,
suppressedHookContext,
);
// Assemble the outgoing request. IDE context is merged into the
+25
View File
@@ -3576,6 +3576,31 @@ describe('LlmChat', async () => {
);
});
it('re-appends a suppressed hook block when the send compacts', async () => {
mockCompressOnce(
compressed(1000, 200, [userText('summary'), modelText('ok')]),
);
mockStream(textStream('answer'));
await drain(
await chat.sendMessageStream(
'test-model',
{ message: 'go' },
'prompt-id-hook-compaction',
undefined,
{ suppressedHookContext: 'CTX' },
),
);
expect(
chat
.getHistory()
.some((entry) =>
entry.parts?.some((part) => part.text?.includes('CTX')),
),
).toBe(true);
});
it('yields a COMPRESSED stream event as the first event after auto-compression succeeds', async () => {
mockCompressOnce(
compressed(1000, 200, [userText('summary'), modelText('ok')]),
+18
View File
@@ -123,6 +123,7 @@ import {
makeChatCompressionEvent,
} from '../telemetry/types.js';
import type { UiTelemetryService } from '../telemetry/uiTelemetry.js';
import { createUserPromptSubmitContextPart } from '../hooks/user-prompt-submit-context.js';
import {
type ChatCompressionInfo,
CompressionStatus,
@@ -679,6 +680,12 @@ export interface LlmChatSendOptions {
* public transcript (#13319).
*/
retractDeliveredOutputOnRetry?: boolean;
/**
* A `UserPromptSubmit` block suppressed as already delivered. Compression
* can drop the history entry holding its only copy inside this same send,
* so it is re-appended when the send compacts.
*/
suppressedHookContext?: string;
}
/** @deprecated Use `LlmChatSendOptions`; retained until a future major release. */
@@ -3396,6 +3403,17 @@ export class LlmChat {
},
);
}
if (
options?.suppressedHookContext &&
compressionInfo.compressionStatus === CompressionStatus.COMPRESSED
) {
// Compaction replaced the history holding the block's only copy, so
// the model would otherwise not see it on this turn at all.
userContent.parts = [
...(userContent.parts ?? []),
createUserPromptSubmitContextPart(options.suppressedHookContext),
];
}
const localPromptTokensAfterCompression = shouldForceFromHard
? estimatePromptTokens(
this.lastPromptTokenCount > 0 ? [] : this.getHistoryShallow(true),
+6 -1
View File
@@ -721,6 +721,7 @@ export class Turn {
private readonly promptIdentity?: string,
private readonly retractDeliveredOutputOnRetry?: boolean,
private readonly onPreparedRequest?: LlmPreparedRequestCallback,
private readonly suppressedHookContext?: string,
) {
this.goalContext = goalContext ? { ...goalContext } : undefined;
}
@@ -739,11 +740,15 @@ export class Turn {
const sendOptions =
this.promptIdentity !== undefined ||
this.retractDeliveredOutputOnRetry === true ||
this.onPreparedRequest !== undefined
this.onPreparedRequest !== undefined ||
this.suppressedHookContext !== undefined
? {
...(this.onPreparedRequest
? { onPreparedRequest: this.onPreparedRequest }
: {}),
...(this.suppressedHookContext
? { suppressedHookContext: this.suppressedHookContext }
: {}),
...(this.promptIdentity ? { promptId: this.promptIdentity } : {}),
...(this.retractDeliveredOutputOnRetry
? { retractDeliveredOutputOnRetry: true }