From dfd55ea7d1847088abd0b2b5f87f68b716dbaf22 Mon Sep 17 00:00:00 2001 From: jiwangyihao Date: Tue, 23 Jun 2026 17:36:18 +0800 Subject: [PATCH] =?UTF-8?q?fix(gitlab-duo):=20checkpoint=20=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E6=8C=89=E5=9B=9E=E5=90=88=E4=BD=8D=E7=BD=AE=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F=EF=BC=8C=E9=81=BF=E5=85=8D=E5=90=9E=E6=8E=89?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E6=96=87=E6=9C=AC=E7=9A=84=E6=96=B0=E6=B6=88?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 机器人指出两处问题,本提交处理: 1) checkpoint 内容签名去重用的是全局文本相等,导致后续一个合法的新 agent 消息若文本与早先回合相同(例如两个回合都说 "Done"、或重复 reasoning), 因 previousContent 对新 messageKey 为 undefined 而被判为 duplicateContent, provider 不发 delta,用户丢失第二条消息。现在把内容签名作用域绑定到回合 位置(快照内 request/tool 边界计数):同一回合位置上重放的同文本(GitLab 跨收缩快照重命名 message_id 的情形)仍被去重,而位于更晚回合的新消息照常 发出。新增回合内重复文本回归测试;既有 rename 抑制与 pause/resume 测试不变。 2) coding-agent CHANGELOG 把 broker 登录修复条目误放在已发布的 [16.1.16] 段;按 AGENTS.md 已发布段不可变,移动到 [Unreleased] 的 ### Fixed。 --- packages/ai/CHANGELOG.md | 1 + .../ai/src/providers/gitlab-duo-workflow.ts | 14 +++- .../test/gitlab-duo-workflow-provider.test.ts | 83 +++++++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 +- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index e1fea979e..fb1194b96 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -69,6 +69,7 @@ - Fixed GitLab Duo Workflow `direct_access` errors dropping the HTTP status when GitLab returned a JSON error body (e.g. a 401 `{"message":"Unauthorized"}` from an expired OAuth token, or a 429 quota body). The thrown error now embeds `HTTP ` alongside the body message so the streaming auth-retry path (`extractStatusFromAssistantError` → `extractHttpStatusFromError`) can recover the status and refresh/rotate the parked broker credential instead of surfacing a hard failure. - Fixed `AuthStorage.login` always synthesizing a default manual-code paste prompt, which made the loopback `OAuthCallbackFlow` race a readline prompt against the HTTP callback for normal (non-paste-code) OAuth providers and could leave that prompt dangling — a dirty/blocked terminal — when the browser callback won. The default prompt is now synthesized only for `pasteCodeFlow` providers (`PASTE_CODE_LOGIN_PROVIDERS`); loopback providers get no manual-code race unless a caller explicitly supplies `onManualCodeInput`. This is the authoritative gate covering every caller (not just the auth-broker CLI). +- Fixed GitLab Duo Agent checkpoint deduplication swallowing a legitimate later agent message whose text equalled an earlier turn (e.g. two turns that both say "Done", or repeated reasoning). The content-signature fallback that suppresses replayed text across renamed `message_id`s is now scoped to the message's turn position (boundary count within the snapshot) instead of global text equality, so a replayed message reappearing at the same turn is still deduped while a genuinely new message at a later turn emits. ## [16.1.16] - 2026-06-23 diff --git a/packages/ai/src/providers/gitlab-duo-workflow.ts b/packages/ai/src/providers/gitlab-duo-workflow.ts index 422da36b3..b7cd4340b 100644 --- a/packages/ai/src/providers/gitlab-duo-workflow.ts +++ b/packages/ai/src/providers/gitlab-duo-workflow.ts @@ -1977,6 +1977,15 @@ function emitGitLabDuoWorkflowCheckpoint( // not any delta emitted earlier in the socket call — otherwise a stale replayed // boundary would fire one pause_turn per snapshot and hit the loop's continuation cap. let deltaThisCheckpoint = false; + // Turn position within this full-snapshot replay: a request/tool boundary + // starts a new turn. The content-signature fallback below is scoped to this + // index so it suppresses only a replayed message reappearing at the SAME turn + // position (e.g. GitLab renames a message_id across a shrunk snapshot, so the + // per-key lookup misses but the text was already emitted for that turn). A + // genuinely new later message with text equal to an earlier one lands at a + // LATER turn (after an extra boundary), so its signature differs and it still + // emits — repeated assistant output across turns is no longer swallowed. + let turnIndex = 0; for (const entry of checkpoint.entries) { if (entry.kind === "boundary") { if (deltaThisCheckpoint && state.providerSessionState?.active) { @@ -1985,14 +1994,15 @@ function emitGitLabDuoWorkflowCheckpoint( } endGitLabDuoWorkflowText(state); endGitLabDuoWorkflowThinking(state); + turnIndex += 1; continue; } const contentByKey = state.checkpointAgentContentByKey ?? {}; const contentSignatures = state.checkpointAgentContentSignatures ?? {}; const previousContent = contentByKey[entry.messageKey]; - const contentSignature = `${entry.kind}\u0000${entry.content}`; - const contentOnlySignature = `content\u0000${entry.content}`; + const contentSignature = `${turnIndex}\u0000${entry.kind}\u0000${entry.content}`; + const contentOnlySignature = `${turnIndex}\u0000content\u0000${entry.content}`; const duplicateContent = previousContent === undefined && (contentSignatures[contentSignature] === true || contentSignatures[contentOnlySignature] === true); diff --git a/packages/ai/test/gitlab-duo-workflow-provider.test.ts b/packages/ai/test/gitlab-duo-workflow-provider.test.ts index 85b17746e..dbd757fbc 100644 --- a/packages/ai/test/gitlab-duo-workflow-provider.test.ts +++ b/packages/ai/test/gitlab-duo-workflow-provider.test.ts @@ -2510,6 +2510,89 @@ describe("GitLab Duo Workflow WebSocket state machine", () => { expect(text).toBe("Working"); }); + it("emits a later agent message whose text equals an earlier turn (no global content dedupe)", async () => { + // Two genuine agent turns separated by a tool boundary both say "Done". + // The content-signature fallback must be scoped to turn position, not global + // text equality, or the second legitimate message is swallowed. + const socket: GitLabDuoWorkflowWebSocketLike = { + onopen: null, + onmessage: null, + onerror: null, + onclose: null, + send() {}, + close() {}, + }; + const output: AssistantMessage = { + role: "assistant", + content: [], + api: "gitlab-duo-agent", + provider: "gitlab-duo-agent", + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + const startPayload = buildGitLabDuoWorkflowStartRequest("workflow-1", model, context); + const providerSessionState = { + active: { workflowId: "workflow-1", startPayload, ws: socket }, + } as unknown as GitLabDuoWorkflowStreamState["providerSessionState"]; + const streamPromise = runGitLabDuoWorkflowSocket( + socket, + startPayload, + { stream: new AssistantMessageEventStream(), output, started: true, providerSessionState }, + { apiKey: "[REDACTED]" }, + ); + socket.onopen?.(new Event("open")); + // First turn: agent says "Done", then a tool boundary. The boundary after a + // same-checkpoint delta pauses, so the first snapshot only carries turn 0. + socket.onmessage?.( + new MessageEvent("message", { + data: JSON.stringify({ + newCheckpoint: { + status: "CREATED", + checkpoint: JSON.stringify({ + channel_values: { + ui_chat_log: [{ message_type: "agent", message_id: "agent-a", content: "Done" }], + }, + }), + }, + }), + }), + ); + // Second turn after a tool boundary: a NEW agent message also says "Done". + socket.onmessage?.( + new MessageEvent("message", { + data: JSON.stringify({ + newCheckpoint: { + status: "INPUT_REQUIRED", + checkpoint: JSON.stringify({ + channel_values: { + ui_chat_log: [ + { message_type: "agent", message_id: "agent-a", content: "Done" }, + { message_type: "tool", content: "tool ran" }, + { message_type: "agent", message_id: "agent-b", content: "Done" }, + ], + }, + }), + }, + }), + }), + ); + + await streamPromise; + const text = output.content.map(block => (block.type === "text" ? block.text : "")).join(""); + // Both legitimate turns are present (turn 0 "Done" replayed/suppressed once, + // turn 1 "Done" emitted), so the second is not lost to global text dedupe. + expect(text).toBe("DoneDone"); + }); + it("emits pause_turn at a server-side tool boundary and resumes into a separate assistant message", async () => { const socket: GitLabDuoWorkflowWebSocketLike = { onopen: null, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7447d81e7..fdacb6053 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -92,6 +92,7 @@ - Fixed scripted `eval` `agent()` subagents continuing after a successful `yield` when a trailing empty assistant `stop` arrived after the executor's yield-triggered abort. The session's `agent_end` maintenance compared `#assistantEndedWithSuccessfulYield(msg)` against the trailing empty-stop message — not the prior yield-bearing one — so the empty-stop recovery path appended a retry reminder and scheduled `agent.continue()`, reviving the already-yielded child. The yield handler now sets a sticky `#yieldTerminationPending` flag (cleared on the next `prompt()`) that short-circuits empty-stop / unexpected-stop / compaction continuations for the rest of the run, so a successful yield is terminal regardless of trailing stops ([#3389](https://github.com/can1357/oh-my-pi/issues/3389)). - Fixed snapcompact rasterizing transcript frames into requests bound for GitHub Copilot business and enterprise endpoints, which then rejected the session permanently with `400 vision is not supported`. The snapcompact vision gate now also short-circuits whenever `model.provider === "github-copilot"` and the resolved `baseUrl` is not the canonical personal-Copilot host, protecting cached/stale Model specs that still advertise `["text","image"]` on a non-personal endpoint. ([#3387](https://github.com/can1357/oh-my-pi/issues/3387)) - Fixed GitLab Duo Agent namespace/project discovery reading the original repo's git remote after a `/move`. The session's working directory is now resolved live (per LLM call) from the `SessionManager` instead of being captured when the agent was constructed, so moving the session re-scopes Duo workspace discovery to the new repository. +- Fixed `omp auth-broker login gitlab-duo-agent` (and `--via`) hanging until timeout: the provider uses GitLab's fixed `vscode://` OAuth redirect, which never reaches the broker's local callback server, and `runLocalLogin` supplied no `onManualCodeInput` fallback. The broker login now offers the same paste-the-redirect-URL prompt the interactive sign-in uses, so credentials can be saved. ## [16.1.16] - 2026-06-23 @@ -133,7 +134,6 @@ - Fixed `ask` returning `(cancelled)` or aborting the tool when Escape dismissed `Other (type your own)` custom input; it now returns to the option selector so the user can pick a listed answer instead. ([#3269](https://github.com/can1357/oh-my-pi/issues/3269)) - Fixed `/goal` threshold auto-compaction skipping real sessions through three paths: per-turn supersede/drop-useless pruning no longer deflates the threshold trigger below the last provider-billed context; active-goal text stops now attempt threshold maintenance before unexpected-stop retry continuations can return from post-turn handling; and empty `toolUse` stops keep the existing cleanup pass that strips the orphan assistant from active context + session history before any compaction continuation. Active-goal compaction continuations now also resolve completed retry gates before returning, preventing `isRetrying` from staying stuck after a retry succeeds over the threshold. Added `agent_end maintenance routing` and `Auto-compaction threshold decision` debug logs so future no-start reports identify the exact early-return branch and the billed/stored/resolved/post-maintenance token counts that fed `shouldCompact`. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174)) - Fixed active `/goal` runs that never reached `agent_end` because the model kept emitting tool calls inside one agent run. Threshold maintenance now runs between tool-call turns, compacts the live loop context in place, and suppresses queued continuations that would race the still-running goal loop. ([#3174](https://github.com/can1357/oh-my-pi/issues/3174)) -- Fixed `omp auth-broker login gitlab-duo-agent` (and `--via`) hanging until timeout: the provider uses GitLab's fixed `vscode://` OAuth redirect, which never reaches the broker's local callback server, and `runLocalLogin` supplied no `onManualCodeInput` fallback. The broker login now offers the same paste-the-redirect-URL prompt the interactive sign-in uses, so credentials can be saved. ### Removed