From 2025c0b11b17ce715bcc1a5f04fa0ef87c00beda Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 16:54:14 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E4=BF=9D=E6=8A=A4=E6=B6=88=E6=81=AF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=BF=9B=E5=85=A5=E6=91=98=E8=A6=81=E5=8C=BA?= =?UTF-8?q?=E9=97=B4=20(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protectedUserMessage 的命中条件(boundaryStart ≤ idx < firstKeptIndex) 决定它必然同时落在 messagesToSummarize ∪ turnPrefixMessages 的序列化 区间内,摘要生成后再原样插回,同一请求文本出现两份。 prepareCompaction 在返回前按引用把该消息从两个序列化区间剔除; 插回逻辑与 checkpoint 语义保持原样,split-turn 与非 split-turn 一并覆盖。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/utils/compact.test.ts | 55 ++++++++++++++++++++++++++ packages/sdk/src/utils/compact.ts | 17 ++++++-- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/sdk/src/utils/compact.test.ts b/packages/sdk/src/utils/compact.test.ts index 4f1f4ad05..4fe433c97 100644 --- a/packages/sdk/src/utils/compact.test.ts +++ b/packages/sdk/src/utils/compact.test.ts @@ -231,4 +231,59 @@ describe("context compaction", () => { expect(result.compactedMessages).toContain(messages[4]); expect(JSON.stringify(result.compactedMessages)).not.toContain("x".repeat(5_000)); }); + + test("drops the protected user message from both summarized ranges (#365)", () => { + const messages = [ + { role: "user", content: "PROTECTED_REQUEST" }, + { role: "assistant", content: [{ type: "tool_use", id: "t-1", name: "Grep", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t-1", content: "r".repeat(8_000) }] }, + { role: "user", content: "recent question" }, + { role: "assistant", content: [{ type: "tool_use", id: "t-2", name: "Read", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t-2", content: "current" }] }, + ] as any[]; + + const preparation = prepareCompaction(messages, { + keepRecentTokens: 50, + protectedMessageIndex: 0, + }); + + expect(preparation).toBeDefined(); + expect(preparation!.isSplitTurn).toBeFalse(); + expect(preparation!.protectedUserMessage).toBe(messages[0]); + // The message is kept verbatim in the output, so summarizing it too would + // duplicate it; it must not appear in either serialized range. + expect(preparation!.messagesToSummarize).not.toContain(messages[0]); + expect(preparation!.turnPrefixMessages).not.toContain(messages[0]); + expect(preparation!.retainedTail[0]).toBe(messages[3]); + }); + + test("inserts the protected request exactly once after a split-turn summary (#365)", async () => { + const MARKER = "PROTECTED_REQUEST_MARKER"; + const { provider, requests } = providerWithSummary(VALID_TURN_PREFIX_SUMMARY); + const messages = [ + { role: "user", content: `${MARKER} plus background `.repeat(500) }, + { role: "assistant", content: [{ type: "tool_use", id: "g-1", name: "Grep", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "g-1", content: "x".repeat(60_000) }] }, + { role: "assistant", content: [{ type: "tool_use", id: "g-2", name: "Read", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "g-2", content: "current" }] }, + ] as any[]; + + const result = await compactConversation( + provider, + "test-model", + messages, + createAutoCompactState(), + { keepRecentTokens: 50, trigger: "manual", protectedMessageIndex: 0 }, + ); + + expect(result.compacted).toBeTrue(); + expect(result.compactedMessages[1]).toBe(messages[0]); + // The verbatim copy survives, but the original text must not also be + // baked into the generated summary. + expect(JSON.stringify(result.compactedMessages[0])).not.toContain(MARKER); + const prefixRequest = requests.find((request: any) => + String(request.messages[0].content).includes("PREFIX of a turn")); + expect(prefixRequest).toBeDefined(); + expect(prefixRequest.messages[0].content).not.toContain(MARKER); + }); }); diff --git a/packages/sdk/src/utils/compact.ts b/packages/sdk/src/utils/compact.ts index b084643c6..a856a0b88 100644 --- a/packages/sdk/src/utils/compact.ts +++ b/packages/sdk/src/utils/compact.ts @@ -315,11 +315,20 @@ export function prepareCompaction( ? messages[protectedMessageIndex] : undefined + // The protected message is re-inserted verbatim after the summary; drop it + // from the serialized ranges so it is not summarized (and duplicated) too. + const inSummarizedRange = (message: NormalizedMessageParam[]): NormalizedMessageParam[] => + protectedUserMessage + ? message.filter((item) => item !== protectedUserMessage) + : message + return { - messagesToSummarize: messages.slice(boundaryStart, historyEnd), - turnPrefixMessages: cutPoint.isSplitTurn - ? messages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex) - : [], + messagesToSummarize: inSummarizedRange(messages.slice(boundaryStart, historyEnd)), + turnPrefixMessages: inSummarizedRange( + cutPoint.isSplitTurn + ? messages.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex) + : [], + ), retainedTail: messages.slice(cutPoint.firstKeptIndex), previousSummary, isSplitTurn: cutPoint.isSplitTurn, From f04ce87061cf0e52d33c1df866f70ca2598a2091 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 16:54:24 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E4=BC=B0=E7=AE=97=E8=AE=A1=E5=85=A5=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E5=AD=97=E4=BB=B7=20(#352)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit estimateCost 只计 input/output,cache read(约 0.1x 输入价)与 cache write(约 1.25x 输入价)无计价路径,totalCost 长期低估, maxBudgetUsd 预算熔断因此失真。 签名扩展两个可选 cache 字段:read 按 0.1x、write 按 1.25x 折算; 未传字段行为不变。引擎侧接线随引擎批次提交。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/utils/tokens.test.ts | 29 +++++++++++++++++++++++++++ packages/sdk/src/utils/tokens.ts | 18 +++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/utils/tokens.test.ts b/packages/sdk/src/utils/tokens.test.ts index 155d9e409..4c1e1e0ee 100644 --- a/packages/sdk/src/utils/tokens.test.ts +++ b/packages/sdk/src/utils/tokens.test.ts @@ -84,6 +84,35 @@ describe("estimateCost / getContextWindowSize (#229)", () => { expect(getContextWindowSize("gpt-4.1-mini")).toBe(1_000_000) }) + test("cache reads bill at 0.1x and cache writes at 1.25x of the input price (#352)", () => { + // sonnet input price = 3 USD / 1M tokens + expect(estimateCost("claude-sonnet-4-6", { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 1_000_000, + })).toBeCloseTo(3 * 0.1) + expect(estimateCost("claude-sonnet-4-6", { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 1_000_000, + })).toBeCloseTo(3 * 1.25) + expect(estimateCost("claude-sonnet-4-6", { + input_tokens: 100, + output_tokens: 10, + cache_read_input_tokens: 500_000, + cache_creation_input_tokens: 20_000, + })).toBeCloseTo( + 100 * 3 / 1e6 + + 10 * 15 / 1e6 + + 500_000 * 3 / 1e6 * 0.1 + + 20_000 * 3 / 1e6 * 1.25, + ) + // absent cache fields keep the plain io cost + expect(estimateCost("claude-sonnet-4-6", { input_tokens: 100, output_tokens: 10 })).toBeCloseTo( + 100 * 3 / 1e6 + 10 * 15 / 1e6, + ) + }) + test("unlisted models fall back to the shared registry pricing, not the flat default", () => { // pick a model that only exists in the shared registry, priced differently from 3/15 const meta = findModelMeta("glm-4.6") diff --git a/packages/sdk/src/utils/tokens.ts b/packages/sdk/src/utils/tokens.ts index ea71d08b8..0c9d7051b 100644 --- a/packages/sdk/src/utils/tokens.ts +++ b/packages/sdk/src/utils/tokens.ts @@ -239,7 +239,12 @@ function sharedPricingToPerToken(pricing: ModelPricing | undefined) { export function estimateCost( model: string, - usage: { input_tokens: number; output_tokens: number }, + usage: { + input_tokens: number + output_tokens: number + cache_read_input_tokens?: number + cache_creation_input_tokens?: number + }, ): number { const pricing = PRICING_ENTRIES.find(([key]) => model.includes(key))?.[1] ?? @@ -249,5 +254,14 @@ export function estimateCost( output: 15 / 1_000_000, } - return usage.input_tokens * pricing.input + usage.output_tokens * pricing.output + // Cache reads bill at ~10% of the input price, cache writes at a 25% premium; + // ignoring them understated totalCost and let maxBudgetUsd trip too late. + const cacheRead = usage.cache_read_input_tokens ?? 0 + const cacheWrite = usage.cache_creation_input_tokens ?? 0 + return ( + usage.input_tokens * pricing.input + + usage.output_tokens * pricing.output + + cacheRead * pricing.input * 0.1 + + cacheWrite * pricing.input * 1.25 + ) } From d1e0207428ece521ec9e75579ceb342202d9309b Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 16:54:42 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=BF=90=E8=A1=8C=E5=90=8C=E6=AD=A5=E9=94=81=E4=B8=8E?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E9=87=8D=E5=BB=BA=20uuid=20=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=20(#357=20#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动让并发 query 双双穿过守卫、fork 出双引擎写坏同一会话。入口改 同步置位 runLocked 布尔并在外层 finally 复位(拆出 runSinglePromptLocked), 单标志闭合窗口;锁随首个 next() 的同步前缀即生效。 按旧 uuid 键查 fileCheckpointState 必 miss。重建时按同角色消息自队尾 对齐回填原 uuid(toSessionMessage 增加可选 uuid 参数):compaction 在 队首插入合成摘要消息,只有尾部消息与前列表一一对应,checkpoint 键不动。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/agent.test.ts | 98 +++++++++++++++++++++++++++++++++- packages/sdk/src/agent.ts | 64 +++++++++++++++++++--- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index a9babc4f4..bf82ca535 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { createAgent } from "./agent.js" +import { createAgent, sessionMessagesFromHistory } from "./agent.js" import { SkillTool } from "./tools/skill-tool.js" import type { SDKMessage, ToolDefinition } from "./types.js" import type { CreateMessageParams, CreateMessageResponse, LLMProvider } from "./providers/types.js" @@ -1383,3 +1383,99 @@ describe("auth_status emission", () => { await agent.close() }) }) + +describe("Agent concurrent run lock (#357)", () => { + test("rejects a second query while the first run is still initializing", async () => { + const provider = new CapturingProvider() + const agent = createAgent({ + persistSession: false, + tools: [], + provider, + model: "host/model-a", + }) + await agent.getInitializationResult() + + const first = (agent.query("first") as any)[Symbol.asyncIterator]() as AsyncIterator + const second = (agent.query("second") as any)[Symbol.asyncIterator]() as AsyncIterator + // Start both generators in the same tick: the first one grabs the run + // lock synchronously, so the second must be rejected before either + // engine is even constructed. + const firstStart = first.next() + const secondOutcome = await second.next().then( + () => "allowed", + (error: Error) => error.message, + ) + expect(secondOutcome).toBe("agent is running") + + // The first run completes normally. + let done = await firstStart + while (!done.done) { + done = await first.next() + } + expect(provider.requests).toHaveLength(1) + + // The lock is released: a follow-up query runs fine. + for await (const _event of agent.query("third")) { + // drain + } + expect(provider.requests).toHaveLength(2) + await agent.close() + }) +}) + +describe("Agent session message uuid realignment (#363)", () => { + test("rebuilds session messages after a compaction boundary without rotating user uuids", async () => { + const agent = createAgent({ + persistSession: false, + tools: [], + provider: new CapturingProvider(), + model: "host/model-a", + }) + await agent.getInitializationResult() + + for await (const _event of agent.query("checkpoint anchor request", { + contextController: { + shouldAutoCompact: () => true, + async compactConversation() { + return { + compactedMessages: [ + { role: "user", content: "[Previous conversation summary]\n\nanchor summary" }, + ], + summary: "anchor summary", + } + }, + }, + })) { + // drain + } + + const loggedUserUuid = ((agent.getMessages().find((message) => message.type === "user") as any) as { uuid: string }).uuid + const rebuilt = (agent as any).sessionMessages as Array<{ uuid: string; role: string; content: unknown }> + expect(rebuilt.map((message) => message.role)).toEqual(["user", "user", "assistant"]) + // The rebuilt latest user message keeps its original uuid, so + // fileCheckpointState lookups keyed by that uuid still hit. + expect(rebuilt[1]!.uuid).toBe(loggedUserUuid) + expect(JSON.stringify(rebuilt[1]!.content)).toContain("checkpoint anchor request") + + await agent.close() + }) + + test("pairs roles from the end and falls back to fresh uuids past the old list", () => { + const history = [ + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + { role: "user", content: "three" }, + ] as any[] + const previous = [ + { uuid: "u-1", role: "user", timestamp: "t", content: "one" }, + { uuid: "a-1", role: "assistant", timestamp: "t", content: "two" }, + ] as any[] + + const rebuilt = sessionMessagesFromHistory(history, previous) + + // Trailing messages map onto the previous list; the leading extra user + // (e.g. a synthetic compaction summary) gets a fresh uuid. + expect(rebuilt.map((message) => message.uuid)).toEqual([expect.any(String), "a-1", "u-1"]) + }) +}) +>>>>>>> eb623a707 (🐛 fix(sdk): 会话运行同步锁与压缩重建 uuid 回填 (#357 #363)) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index eb6b383fc..cfe652655 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -80,9 +80,10 @@ type QueryInput = string | ContentBlockParam[] | SDKUserMessage function toSessionMessage( role: SessionMessage['role'], content: unknown, + uuid?: string, ): SessionMessage { return { - uuid: crypto.randomUUID(), + uuid: uuid ?? crypto.randomUUID(), role, timestamp: new Date().toISOString(), content, @@ -240,10 +241,44 @@ function normalizeSessionMessageContent( return message.content as NormalizedMessageParam['content'] } -function sessionMessagesFromHistory( +export function sessionMessagesFromHistory( messages: NormalizedMessageParam[], + previous?: SessionMessage[], ): SessionMessage[] { - return messages.map((message) => toSessionMessage(message.role, message.content)) + // Realign with the previous list so rebuilt messages keep their original + // uuids — fileCheckpointState is keyed by user-message uuid, and fresh + // uuids here would orphan every checkpoint (#363). Alignment pairs each + // role from the END: compaction prepends a synthetic summary user message, + // so only the trailing messages correspond 1:1 with what came before. + const previousUuidsByRole = new Map() + for (const message of previous ?? []) { + const uuids = previousUuidsByRole.get(message.role) ?? [] + uuids.push(message.uuid) + previousUuidsByRole.set(message.role, uuids) + } + const indicesByRole = new Map() + messages.forEach((message, index) => { + const indices = indicesByRole.get(message.role) ?? [] + indices.push(index) + indicesByRole.set(message.role, indices) + }) + const uuidByIndex = new Map() + for (const [role, indices] of indicesByRole) { + const uuids = previousUuidsByRole.get(role) ?? [] + const paired = Math.min(indices.length, uuids.length) + for (let offset = 0; offset < paired; offset++) { + uuidByIndex.set( + indices[indices.length - paired + offset]!, + uuids[uuids.length - paired + offset]!, + ) + } + } + return messages.map((message, index) => + toSessionMessage( + message.role as SessionMessage['role'], + message.content, + uuidByIndex.get(index), + )) } export class Agent { @@ -265,6 +300,8 @@ export class Agent { private sid: string private abortCtrl: AbortController | null = null private currentEngine: QueryEngine | null = null + /** Synchronous in-flight marker: set at run entry, cleared when the run ends. */ + private runLocked = false private hookRegistry: HookRegistry private loadedSettings: LoadedSettingsSource[] = [] private loadedPlugins: LoadedPlugin[] = [] @@ -870,10 +907,25 @@ export class Agent { private async *runSinglePrompt( prompt: QueryInput, overrides?: Partial, + ): AsyncGenerator { + // A synchronous flag closes the TOCTOU window: the engine assignment sits + // several awaits deep, so two lazily-started queries could both pass a + // pure currentEngine check and fork the same session into two engines (#357). + if (this.runLocked || this.currentEngine) throw new Error('agent is running') + this.runLocked = true + try { + yield* this.runSinglePromptLocked(prompt, overrides) + } finally { + this.runLocked = false + } + } + + private async *runSinglePromptLocked( + prompt: QueryInput, + overrides?: Partial, ): AsyncGenerator { // currentEngine (not abortCtrl, which is never cleared after a run) is // the accurate in-flight marker: set before the loop, cleared in finally. - if (this.currentEngine) throw new Error('agent is running') await this.setupDone // Fail fast before any listener is attached, the user message is @@ -1117,10 +1169,10 @@ export class Agent { this.lastContextUsage = engine.getContextUsage() this.currentEngine = null if (compactionBoundarySeen) { - this.sessionMessages = sessionMessagesFromHistory(this.history) + this.sessionMessages = sessionMessagesFromHistory(this.history, this.sessionMessages) } if (opts.toolContinuations?.length) { - this.sessionMessages = sessionMessagesFromHistory(this.history) + this.sessionMessages = sessionMessagesFromHistory(this.history, this.sessionMessages) } persistedSessionEvent = await this.persistCurrentSession(cwd, opts) } From 7a4f719be321cc3aecdd0a8276aeb381fbe6c34d Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 18:24:04 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E6=89=A7=E8=A1=8C=E5=BE=AA=E7=8E=AF=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=8E=E8=AE=A1=E4=BB=B7=E6=8E=A5=E7=BA=BF=20(#304=20#353=20?= =?UTF-8?q?#359=20#360=20#361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #361 续写预算耗尽后再遇 max_tokens 且无 tool_use,落入 completedNaturally 以 success 收尾掩盖截断。置 maxTokensExhausted 标志,终态映射 error_max_output_tokens(subtype 联合新增字面量, 含 string 向后兼容),errors 附说明;#304 占位结构沿用 main 既有实现。 #353 prompt-too-long 守卫要求 !compacted,而 compacted 成功后整个 run 不复位,tool 循环二次超窗直接终止 run。守卫改 consecutiveFailures < 3:成功复位 0 允许再触发,失败自增自带熔断。 #359 pendingLspDiagnostics.splice(0) 破坏性取走后仅注入瞬态 apiMessages,prompt-too-long 压缩重建请求时诊断永久丢失。改非破坏 读取,请求成功后才清空,压缩重试下一轮自然重新注入。 #360 非流式路径 api_retry 事件缓冲至 withRetry 整体返回后才补发, 最长约 30s×3 假死。onRetry 改经既有 onAsyncEvent 通道即时投递, 缓冲数组仅作无宿主回调时的兜底。 另接 #352:recordProviderUsage 将归一化 cache 读写字段透传 estimateCost,totalCost/modelUsage/billingUsage 反映缓存计价。 rebase 注记:#358(repeat guard 交替击穿)与 #304 占位已由 main 208c81806/515d6ad10 先行落地且语义更严,本次保留 main 实现; 原 #358 放行用例与 main 的 stall-guard 取舍相悖,随本提交移除。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/engine.test.ts | 460 ++++++++++++++++++++++++++++++++ packages/sdk/src/engine.ts | 93 ++++--- packages/sdk/src/types.ts | 1 + 3 files changed, 524 insertions(+), 30 deletions(-) diff --git a/packages/sdk/src/engine.test.ts b/packages/sdk/src/engine.test.ts index 8b9fb1e80..50f536cc6 100644 --- a/packages/sdk/src/engine.test.ts +++ b/packages/sdk/src/engine.test.ts @@ -779,6 +779,51 @@ describe("QueryEngine turn limits", () => { expect(calls).toBe(2) }) + test("alternating between two blocked signatures still trips the breaker (#358)", async () => { + const calls = { A: 0, B: 0 } + const call = (id: string, name: "Alpha" | "Bravo"): CreateMessageResponse => ({ + content: [{ type: "tool_use", id, name, input: {} }], + stopReason: "tool_use", + usage: { input_tokens: 1, output_tokens: 1 } + }) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider: new StaticProvider([ + call("a-1", "Alpha"), + call("b-1", "Bravo"), + call("a-2", "Alpha"), + call("b-2", "Bravo"), + call("a-3", "Alpha"), // blocked: first breaker hit + call("b-3", "Bravo") // blocked: second breaker hit ends the run + ]), + tools: (["Alpha", "Bravo"] as const).map((name) => ({ + name, + description: `${name} tool`, + inputSchema: { type: "object", properties: {} }, + isReadOnly: () => false, + async call() { + calls[name === "Alpha" ? "A" : "B"] += 1 + return { type: "tool_result", tool_use_id: "", content: `unchanged ${name}` } + } + })), + systemPrompt: "test", + maxTurns: 80, + maxTokens: 256, + includePartialMessages: false, + canUseTool: async () => ({ behavior: "allow" }) + }) + + // Old behavior alternated the counter reset between signatures and never + // stopped; now the second blocked attempt (any signature) ends the run. + await expect(collectResult(engine)).resolves.toMatchObject({ + subtype: "error_completion_guard", + is_error: true + }) + expect(calls.A).toBe(2) + expect(calls.B).toBe(2) + }) + test("allows repeated mutation input while the result state keeps changing", async () => { let calls = 0 const repeatedCall = (id: string): CreateMessageResponse => ({ @@ -1829,6 +1874,63 @@ describe("QueryEngine context controller", () => { is_error: true })); }); + + test("compacts again when the tool loop outgrows the window a second time (#353)", async () => { + let calls = 0; + let compactions = 0; + const provider: LLMProvider = { + apiType: "anthropic-messages", + async createMessage() { + calls += 1; + if (calls === 1 || calls === 2) { + const error = new Error("prompt is too long") as Error & { status: number }; + error.status = 400; + throw error; + } + return { + content: [{ type: "text", text: "done" }], + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 } + }; + } + }; + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider, + tools: [], + systemPrompt: "test", + maxTurns: 1, + maxTokens: 256, + includePartialMessages: false, + canUseTool: async () => ({ behavior: "allow" }), + contextController: { + shouldAutoCompact: () => false, + async compactConversation({ messages }) { + compactions += 1; + return { + compactedMessages: [ + { role: "user", content: `[Previous conversation summary]\n\nsummary ${compactions}` }, + ...messages.slice(-1) + ], + summary: `summary ${compactions}` + }; + } + } + }); + + const events = await collectEvents(engine, "loop grew again"); + + // A second too-long after one successful compaction must trigger another + // compaction instead of terminating the run. + expect(compactions).toBe(2); + expect(calls).toBe(3); + expect(events).toContainEqual(expect.objectContaining({ + type: "result", + subtype: "success", + is_error: false + })); + }); }); describe("QueryEngine auto compaction usage", () => { @@ -3063,3 +3165,361 @@ describe("QueryEngine getContextUsage", () => { expect(engine.getContextUsage().maxTokens).toBe(12345) }) }); + +describe("QueryEngine max_tokens continuation (#304/#361)", () => { + test("pairs truncated tool_use blocks with placeholders before the continuation prompt", async () => { + const provider = new StaticProvider([ + { + content: [ + { type: "text", text: "partial answer" }, + { type: "tool_use", id: "trunc-1", name: "Read", input: { file_path: "a.ts" } }, + ], + stopReason: "max_tokens", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + { + content: [{ type: "text", text: "continued" }], + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }, + ]) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider, + tools: [{ + name: "Read", + description: "read", + inputSchema: { type: "object", properties: {} }, + async call() { + throw new Error("must not execute a truncated tool call") + } + }], + systemPrompt: "test", + maxTurns: 3, + maxTokens: 256, + includePartialMessages: false, + canUseTool: async () => ({ behavior: "allow" }) + }) + + await expect(collectResult(engine)).resolves.toMatchObject({ + subtype: "success", + is_error: false + }) + + const secondRequest = provider.requests[1] + // The truncated assistant tool_use is answered by an error placeholder + // paired with the continuation prompt in one user message (#304). + expect(secondRequest?.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: [ + expect.objectContaining({ + type: "tool_result", + tool_use_id: "trunc-1", + is_error: true + }), + expect.objectContaining({ + type: "text", + text: "Please continue from where you left off." + }) + ] + }) + ])) + }) + + test("maps an exhausted truncated continuation to error_max_output_tokens instead of success", async () => { + const truncated = (): CreateMessageResponse => ({ + content: [{ type: "text", text: "still going and" }], + stopReason: "max_tokens", + usage: { input_tokens: 1, output_tokens: 1 }, + }) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider: new StaticProvider([truncated(), truncated(), truncated(), truncated()]), + tools: [], + systemPrompt: "test", + maxTurns: 10, + maxTokens: 256, + includePartialMessages: false + }) + + await expect(collectResult(engine)).resolves.toMatchObject({ + subtype: "error_max_output_tokens", + is_error: true + }) + }) + + test("executes truncated tool calls once the continuation budget is spent", async () => { + let calls = 0 + const truncatedText = (): CreateMessageResponse => ({ + content: [{ type: "text", text: "still going and" }], + stopReason: "max_tokens", + usage: { input_tokens: 1, output_tokens: 1 }, + }) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider: new StaticProvider([ + truncatedText(), + truncatedText(), + truncatedText(), + { + // Continuations exhausted: a truncated tool_use still executes. + content: [{ type: "tool_use", id: "t-1", name: "Bash", input: {} }], + stopReason: "max_tokens", + usage: { input_tokens: 1, output_tokens: 1 } + }, + { + content: [{ type: "text", text: "done after tools" }], + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 } + } + ]), + tools: [{ + name: "Bash", + description: "bash", + inputSchema: { type: "object", properties: {} }, + async call() { + calls += 1 + return { type: "tool_result", tool_use_id: "", content: `result ${calls}` } + } + }], + systemPrompt: "test", + maxTurns: 8, + maxTokens: 256, + includePartialMessages: false, + canUseTool: async () => ({ behavior: "allow" }) + }) + + await expect(collectResult(engine)).resolves.toMatchObject({ + subtype: "success", + is_error: false + }) + expect(calls).toBe(1) + }) +}) + +describe("QueryEngine non-stream retry events (#360)", () => { + function flakyProvider(failures: number): { provider: LLMProvider; calls(): number } { + let count = 0 + return { + calls: () => count, + provider: { + apiType: "anthropic-messages" as const, + async createMessage() { + count += 1 + if (count <= failures) { + const error = new Error("overloaded") as Error & { status: number } + error.status = 503 + throw error + } + return { + content: [{ type: "text", text: "ok" }], + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 } + } + } + } + } + } + + test("delivers api_retry through onAsyncEvent while the backoff is still running", async () => { + const { provider, calls } = flakyProvider(1) + const asyncEvents: SDKMessage[] = [] + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider, + tools: [], + systemPrompt: "test", + maxTurns: 1, + maxTokens: 256, + includePartialMessages: false, + onAsyncEvent: (event) => asyncEvents.push(event) + }) + + const running = collectEvents(engine) + // Poll until the second attempt starts; by then attempt 1 must already + // have been delivered instead of waiting for withRetry to unwind. + for (let i = 0; i < 200 && calls() < 2; i++) { + await wait(25) + } + expect(calls()).toBe(2) + expect(asyncEvents).toHaveLength(1) + expect(asyncEvents[0]).toMatchObject({ subtype: "api_retry", attempt: 1, error_status: 503 }) + + const events = await running + // Already delivered via onAsyncEvent — not duplicated into the stream. + expect(events.filter((event) => (event as { subtype?: string }).subtype === "api_retry")).toHaveLength(0) + }, 20_000) + + test("buffers api_retry into the stream when no host callback is configured", async () => { + const { provider } = flakyProvider(1) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider, + tools: [], + systemPrompt: "test", + maxTurns: 1, + maxTokens: 256, + includePartialMessages: false + }) + + const events = await collectEvents(engine) + const retries = events.filter((event) => + event.type === "system" && (event as { subtype?: string }).subtype === "api_retry" + ) as Array<{ attempt: number; error_status: number | null }> + expect(retries).toHaveLength(1) + expect(retries[0]).toMatchObject({ attempt: 1, error_status: 503 }) + }, 20_000) +}) + +describe("QueryEngine delayed diagnostics persistence (#359)", () => { + test("re-injects pending diagnostics after a prompt-too-long compaction retry", async () => { + let calls = 0 + const requests: CreateMessageParams[] = [] + const provider: LLMProvider = { + apiType: "anthropic-messages", + async createMessage(params) { + requests.push(params) + calls += 1 + if (calls === 2) { + const error = new Error("prompt is too long") as Error & { status: number } + error.status = 400 + throw error + } + if (calls === 1) { + return { + content: [ + { type: "tool_use", id: "edit-9", name: "Edit", input: {} }, + { type: "tool_use", id: "settle-9", name: "Settle", input: {} }, + ], + stopReason: "tool_use", + usage: { input_tokens: 1, output_tokens: 1 } + } + } + return { + content: [{ type: "text", text: "done" }], + stopReason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 } + } + } + } + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "test-model", + provider, + tools: [{ + name: "Edit", + description: "edit", + inputSchema: { type: "object", properties: {} }, + async call(_input, context) { + setTimeout(() => { + context.emitEvent?.({ + type: "system", + subtype: "lsp_diagnostics", + session_id: "session", + tool_use_id: "edit-9", + file_path: "src/example.ts", + mutation_version: 1, + sha256: "abc", + delayed: true, + diagnostics: { + servers: ["typescript-language-server"], + total: 1, + errors: 1, + warnings: 0, + truncated: false, + items: [{ + severity: 1, + message: "Cannot find name 'missing'.", + range: { start: { line: 2, character: 4 }, end: { line: 2, character: 11 } } + }] + } + }) + }, 0) + return { type: "tool_result" as const, tool_use_id: "", content: "edited" } + } + }, { + name: "Settle", + description: "settle", + inputSchema: { type: "object", properties: {} }, + async call() { + // Give the deferred diagnostics emission room to land after Edit + // returned but before the next provider request. + await wait(30) + return { type: "tool_result" as const, tool_use_id: "", content: "settled" } + } + }], + systemPrompt: "test", + maxTurns: 3, + maxTokens: 256, + includePartialMessages: false, + canUseTool: async () => ({ behavior: "allow" }), + contextController: { + shouldAutoCompact: () => false, + async compactConversation({ messages }) { + return { + compactedMessages: [ + { role: "user", content: "[Previous conversation summary]\n\nretry summary" }, + ...messages.slice(-1) + ], + summary: "retry summary" + } + } + } + }) + + await expect(collectResult(engine)).resolves.toMatchObject({ subtype: "success" }) + + const diagnosticRuntime = (request?: { messages: unknown[] }) => + JSON.stringify((request?.messages ?? []).filter((message: any) => message.role === "runtime")) + // Injected before the failed request… + expect(diagnosticRuntime(requests[1])).toContain("Cannot find name 'missing'.") + // …and still present on the compaction retry after it. + expect(diagnosticRuntime(requests[2])).toContain("Cannot find name 'missing'.") + }) +}) + +describe("QueryEngine cost estimation (#352)", () => { + test("includes cache read/write tokens in billing totals", async () => { + const provider = new StaticProvider([{ + content: [{ type: "text", text: "ok" }], + stopReason: "end_turn", + usage: { + input_tokens: 1000, + output_tokens: 1000, + cache_read_input_tokens: 1_000_000, + cache_creation_input_tokens: 100_000 + } + }]) + const engine = new QueryEngine({ + cwd: process.cwd(), + model: "claude-sonnet-4-6", + provider, + tools: [], + systemPrompt: "test", + maxTurns: 1, + maxTokens: 256, + includePartialMessages: false + }) + + const result = await collectResult(engine) as unknown as { + billingUsage: { totalCostUSD: number; cumulative: { totalTokens: number } } + modelUsage: Record + } + + const expected = + 1000 * 3 / 1e6 + + 1000 * 15 / 1e6 + + 1_000_000 * 3 / 1e6 * 0.1 + + 100_000 * 3 / 1e6 * 1.25 + expect(result.billingUsage.totalCostUSD).toBeCloseTo(expected, 10) + expect(result.modelUsage["claude-sonnet-4-6"]?.costUSD).toBeCloseTo(expected, 10) + expect(result.billingUsage.cumulative.totalTokens).toBe(1_102_000) + }) +}) + diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 1c72c464e..490845319 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -495,6 +495,8 @@ export class QueryEngine { const costUSD = estimateCost(this.config.model, { input_tokens: normalized.inputTokens, output_tokens: normalized.outputTokens, + cache_read_input_tokens: normalized.cacheReadInputTokens, + cache_creation_input_tokens: normalized.cacheCreationInputTokens, }) this.totalUsage.input_tokens += normalized.inputTokens this.totalUsage.output_tokens += normalized.outputTokens @@ -1165,6 +1167,7 @@ export class QueryEngine { let structuredOutputRetriesExceeded = false let completedNaturally = false let completionGuardStop: { message: string; errorCode?: string } | undefined + let maxTokensExhausted = false let maxOutputRecoveryAttempts = 0 const MAX_OUTPUT_RECOVERY = 3 let structuredOutputRetryAttempts = 0 @@ -1188,7 +1191,10 @@ export class QueryEngine { const apiMessages = await this.microCompactForProvider( normalizeMessagesForAPI(hydratedMessages) as NormalizedMessageParam[], ) - const delayedLspDiagnostics = this.pendingLspDiagnostics.splice(0) + // Non-destructive read: the request may still fail (prompt-too-long + // compaction retry) and the diagnostics must survive for the next + // attempt. Cleared only once a response actually came back. + const delayedLspDiagnostics = [...this.pendingLspDiagnostics] if (delayedLspDiagnostics.length > 0) { apiMessages.push({ role: 'runtime', @@ -1351,7 +1357,7 @@ export class QueryEngine { : status === 500 || status === 502 || status === 503 || status === 529 ? 'server_error' : 'unknown' - retryEvents.push({ + const event: SDKMessage = { type: 'system', subtype: 'api_retry', attempt: retry.attempt, @@ -1360,11 +1366,23 @@ export class QueryEngine { error_status: status, error: errorType, session_id: this.sessionId, - }) + } + retryEvents.push(event) + // Deliver through the async channel immediately so hosts see + // retries as they happen instead of after the whole backoff + // sequence (matching the streaming path). The buffer is only a + // fallback for engines without a host callback. + try { + this.config.onAsyncEvent?.(event) + } catch { + // Host event delivery must not break the retry loop. + } }, ) - for (const retryEvent of retryEvents) { - yield retryEvent + if (!this.config.onAsyncEvent) { + for (const retryEvent of retryEvents) { + yield retryEvent + } } } } catch (err: any) { @@ -1377,8 +1395,11 @@ export class QueryEngine { error: err?.message || 'Unknown provider error', }) for (const event of stopFailureHooks.events) yield event - // Handle prompt-too-long by compacting - if (isPromptTooLongError(err) && !this.compactState.compacted) { + // Handle prompt-too-long by compacting. Gate on consecutive compaction + // failures (reset to 0 on success) instead of the one-shot `compacted` + // flag: a tool loop can outgrow the window a second time, and repeated + // failures trip the breaker on their own. + if (isPromptTooLongError(err) && this.compactState.consecutiveFailures < 3) { try { const compacted = yield* this.runCompaction('prompt_too_long', protectedMessageIndex) if (compacted) { @@ -1409,6 +1430,8 @@ export class QueryEngine { return } + // The request succeeded: diagnostics injected above are consumed. + this.pendingLspDiagnostics = [] this.messages = releaseEphemeralImageReferences(this.messages as any[]) as NormalizedMessageParam[] // Track API timing @@ -1469,36 +1492,42 @@ export class QueryEngine { yield summarizeAssistantTurn(response, this.sessionId) // Handle max_output_tokens recovery - if ( - response.stopReason === 'max_tokens' && - maxOutputRecoveryAttempts < MAX_OUTPUT_RECOVERY - ) { + if (response.stopReason === 'max_tokens') { // A truncated turn can end mid-tool_use; leaving it unanswered makes // the next request invalid (provider 400) with no recovery path. // Close them with placeholder results before continuing (#304). const pendingToolUse = response.content.filter( (block): block is ToolUseBlock => block.type === 'tool_use', ) - maxOutputRecoveryAttempts++ - if (pendingToolUse.length > 0) { - this.messages.push({ - role: 'user', - content: [ - ...pendingToolUse.map((block) => createInterruptedToolResult(block)), - { - type: 'text', - text: 'Please continue from where you left off.', - }, - ], - }) - } else { - // Add continuation prompt - this.messages.push({ - role: 'user', - content: 'Please continue from where you left off.', - }) + if (maxOutputRecoveryAttempts < MAX_OUTPUT_RECOVERY) { + maxOutputRecoveryAttempts++ + if (pendingToolUse.length > 0) { + this.messages.push({ + role: 'user', + content: [ + ...pendingToolUse.map((block) => createInterruptedToolResult(block)), + { + type: 'text', + text: 'Please continue from where you left off.', + }, + ], + }) + } else { + // Add continuation prompt + this.messages.push({ + role: 'user', + content: 'Please continue from where you left off.', + }) + } + continue } - continue + // Continuation budget exhausted on a truncated, tool-free answer: + // report it as an error instead of dressing truncation up as success. + if (pendingToolUse.length === 0) { + maxTokensExhausted = true + break + } + } } // Check for tool use @@ -1631,6 +1660,8 @@ export class QueryEngine { ? 'error_during_execution' : budgetExceeded ? 'error_max_budget_usd' + : maxTokensExhausted + ? 'error_max_output_tokens' : structuredOutputRetriesExceeded ? 'error_max_structured_output_retries' : completionGuardStop @@ -1675,6 +1706,8 @@ export class QueryEngine { ? [completionGuardStop.message] : structuredOutputRetriesExceeded ? ['Structured output validation failed after retry attempts.'] + : maxTokensExhausted + ? ['Response reached the output token limit and continuation attempts were exhausted.'] : undefined, // Structured attribution for guard-driven stops so hosts can tell an SDK // internal repeat-guard stop ('repeated_tool_call') apart from their own diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index cb4835813..2ff0891bd 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -138,6 +138,7 @@ export interface SDKResultMessage { | 'error_max_turns' | 'error_during_execution' | 'error_max_budget_usd' + | 'error_max_output_tokens' | 'error_max_structured_output_retries' | string uuid?: string From e9440abc42a95d7ab9f8a2c17c95a55fc5d45d21 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 17:52:57 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E6=91=98=E8=A6=81=E4=B8=8D=E5=8F=82=E4=B8=8E=20uuid?= =?UTF-8?q?=20=E5=9B=9E=E5=A1=AB=E9=85=8D=E5=AF=B9=20(#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 返工:队尾对齐未区分合成消息。当 previous 同角色条目数多于 重建后历史(长会话压缩的常态),队尾配对会把被摘要吞掉的最近真实 用户消息的 uuid 偷给合成摘要条目——fileCheckpointState 不随压缩 清理时,宿主对摘要条目 rewindFiles 会静默恢复无关旧轮次的文件快照。 配对前剔除 content 含 _meta.contextBlock === 'compaction' 的消息, 强制其拿 fresh uuid;补 previous > new 方向的回归测试(原测试只覆盖 了反方向)。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/agent.test.ts | 29 +++++++++++++++++++++++++++++ packages/sdk/src/agent.ts | 11 +++++++++++ packages/sdk/src/engine.ts | 1 - 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index bf82ca535..770f9dfe1 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1477,5 +1477,34 @@ describe("Agent session message uuid realignment (#363)", () => { // (e.g. a synthetic compaction summary) gets a fresh uuid. expect(rebuilt.map((message) => message.uuid)).toEqual([expect.any(String), "a-1", "u-1"]) }) + + test("never hands an old uuid to a synthetic compaction summary when history shrank (#363)", () => { + const oldUuids = ["u-1", "u-2", "u-3", "u-4", "u-5", "u-6"] + const history = [ + { + role: "user", + content: [{ type: "text", text: "checkpoint summary", _meta: { contextBlock: "compaction" } }], + }, + { role: "assistant", content: "mid answer" }, + { role: "user", content: "latest question" }, + ] as any[] + const previous = [ + ...oldUuids.map((uuid) => ({ uuid, role: "user", timestamp: "t", content: `request ${uuid}` })), + { uuid: "a-mid", role: "assistant", timestamp: "t", content: "older answer" }, + ] as any[] + + const rebuilt = sessionMessagesFromHistory(history, previous) + + // The summary is synthetic — it must take a fresh uuid instead of stealing + // one from a swallowed user message (rewindFiles would otherwise restore + // unrelated snapshots through it). + const summaryUuid = rebuilt[0]!.uuid + for (const uuid of oldUuids) { + expect(summaryUuid).not.toBe(uuid) + } + // The surviving real messages keep their own uuids. + expect(rebuilt[1]!.uuid).toBe("a-mid") + expect(rebuilt[2]!.uuid).toBe("u-6") + }) }) >>>>>>> eb623a707 (🐛 fix(sdk): 会话运行同步锁与压缩重建 uuid 回填 (#357 #363)) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index cfe652655..fc30ca5b5 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -241,6 +241,12 @@ function normalizeSessionMessageContent( return message.content as NormalizedMessageParam['content'] } +function isCompactionSummaryMessage(message: NormalizedMessageParam): boolean { + return Array.isArray(message.content) + && message.content.some((block: any) => + block?.type === 'text' && block?._meta?.contextBlock === 'compaction') +} + export function sessionMessagesFromHistory( messages: NormalizedMessageParam[], previous?: SessionMessage[], @@ -250,6 +256,10 @@ export function sessionMessagesFromHistory( // uuids here would orphan every checkpoint (#363). Alignment pairs each // role from the END: compaction prepends a synthetic summary user message, // so only the trailing messages correspond 1:1 with what came before. + // Synthetic summaries never participate in pairing: when the previous list + // holds more same-role entries than the rebuilt history (the normal + // compaction shape), tail pairing would hand them a swallowed message's + // uuid and let rewindFiles restore unrelated snapshots. const previousUuidsByRole = new Map() for (const message of previous ?? []) { const uuids = previousUuidsByRole.get(message.role) ?? [] @@ -258,6 +268,7 @@ export function sessionMessagesFromHistory( } const indicesByRole = new Map() messages.forEach((message, index) => { + if (isCompactionSummaryMessage(message)) return const indices = indicesByRole.get(message.role) ?? [] indices.push(index) indicesByRole.set(message.role, indices) diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 490845319..040b306ab 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -1528,7 +1528,6 @@ export class QueryEngine { break } } - } // Check for tool use const toolUseBlocks = response.content.filter( From 56ebfeafb0f9ddcd2676ac495ac1a8f952c1417c Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 22 Aug 2026 20:45:33 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A7=B9=20chore(sdk):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20rebase=20=E6=AE=8B=E7=95=99=E7=9A=84=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E7=BB=93=E6=9D=9F=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮 rebase 解决 agent.test.ts 双方新增冲突时遗留了一行 >>>>>>> 标记未删,随本提交移除,无其它改动。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/agent.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index 770f9dfe1..f9c314bb0 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1507,4 +1507,3 @@ describe("Agent session message uuid realignment (#363)", () => { expect(rebuilt[2]!.uuid).toBe("u-6") }) }) ->>>>>>> eb623a707 (🐛 fix(sdk): 会话运行同步锁与压缩重建 uuid 回填 (#357 #363))