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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user