fix(gitlab-duo): checkpoint 去重按回合位置作用域,避免吞掉重复文本的新消息

机器人指出两处问题,本提交处理:

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。
This commit is contained in:
jiwangyihao
2026-06-26 16:13:25 +08:00
parent ec00294462
commit dfd55ea7d1
4 changed files with 97 additions and 3 deletions
+1
View File
@@ -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 <status>` 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
@@ -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);
@@ -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,
+1 -1
View File
@@ -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