diff --git a/.github/workflows/qwen-fleet-shepherd.yml b/.github/workflows/qwen-fleet-shepherd.yml index 2672b39f77..4e0f7a19de 100644 --- a/.github/workflows/qwen-fleet-shepherd.yml +++ b/.github/workflows/qwen-fleet-shepherd.yml @@ -11,7 +11,11 @@ name: 'Fleet Shepherd' # propagates workflow/skill fixes; self-limiting since # behind_by resets to 0 after the sync) # • scan liveness → if no autofix full scan (schedule/dispatch) ran -# recently, dispatch one (GitHub cron is unreliable) +# recently, dispatch one (GitHub cron is unreliable). +# A run wedged in `queued` past ZOMBIE_QUEUED_MINUTES +# never counts as in-flight: GitHub never started it, +# so deferring to it starves the watchdog forever +# (2026-08-19, an oversized workflow file) # # It also maintains a single "Fleet Shepherd Dashboard" issue (edited in # place, never comment spam) so fleet state is observable at a glance. The @@ -70,6 +74,15 @@ env: AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}" BEHIND_SYNC_THRESHOLD: '25' SCAN_LIVENESS_MINUTES: '60' + # A dispatched or scheduled run claims a runner within seconds; one still + # 'queued' this long was never STARTED by GitHub at all. That run is wedged, + # not in flight, and counting it as in-flight starves every lever that + # defers to a live run — permanently, since nothing will ever complete it. + # 2026-08-19: a workflow file over GitHub's 500 KB limit produced exactly + # this (runs created, zero jobs, uncancellable through the API) and the + # liveness watchdog sat at in-flight=1 for 18 hours while the loop was dark. + # Generous by design: it must never fire on an ordinary runner queue. + ZOMBIE_QUEUED_MINUTES: "${{ vars.QWEN_SHEPHERD_ZOMBIE_QUEUED_MINUTES || '30' }}" MAX_SYNCS_PER_TICK: '3' MAX_CONFLICT_DISPATCHES_PER_TICK: '2' DASHBOARD_TITLE: 'Fleet Shepherd Dashboard' @@ -218,6 +231,36 @@ jobs: SCAN_RUNS_OK=false echo "::warning::autofix run-list read failed; liveness lever and conflict dispatches skipped this tick" fi + # The variable is operator-tunable, so it is also operator- + # breakable: a non-numeric value makes every jq consumer carrying + # $zmin exit 5 into its benign fallback — in-flight reads 0 on top + # of live runs and the census reads 0, re-hiding exactly the wedge + # this lever exists to surface. Fall back to the default, mirroring + # AUTO_RELEASE_DAYS. The digit-only regex still admits values large + # enough to wedge every queued run at once — re-creating the + # starvation — so bound by string LENGTH too, and reject zero: it + # wedges every queued run at birth, the exact opposite of the + # generous-by-design invariant the env block declares. + if [[ ! "${ZOMBIE_QUEUED_MINUTES}" =~ ^[0-9]+$ ]] || [[ ${#ZOMBIE_QUEUED_MINUTES} -gt 3 ]] || [[ "${ZOMBIE_QUEUED_MINUTES}" =~ ^0+$ ]]; then + echo "::warning::ZOMBIE_QUEUED_MINUTES '${ZOMBIE_QUEUED_MINUTES}' is not a positive integer or is too large; using 30" + ZOMBIE_QUEUED_MINUTES=30 + fi + # One definition of "wedged", shared by every reader of the run + # snapshot below, so the in-flight count, the census, and the + # liveness re-dispatch guard can never disagree about what counts + # as a live run. A missing createdAt reads as brand new (never + # wedged): unknown age must not license a duplicate dispatch. + ZOMBIE_JQ='def wedged($now; $mins): .status == "queued" and (((.createdAt // "") | if . == "" then 9999999999 else fromdateiso8601 end) <= (($now | tonumber) - ($mins | tonumber) * 60));' + SCAN_ZOMBIES="$(jq -r --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"' + [ .[] | select(wedged($now; $zmin)) ] | length' /tmp/scan-runs.json 2> /dev/null || echo 0)" + SCAN_ZOMBIE_OLDEST="$(jq -r --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"' + [ .[] | select(wedged($now; $zmin)) | .createdAt ] | sort | first // ""' /tmp/scan-runs.json 2> /dev/null || echo '')" + # Loud, because invisibility is what made this expensive: the loop + # looked half-alive for a day (PR-event runs kept succeeding) while + # every dispatch queued forever. + if [[ "${SCAN_ZOMBIES}" -gt 0 ]]; then + echo "::warning::${SCAN_ZOMBIES} autofix run(s) stuck 'queued' for over ${ZOMBIE_QUEUED_MINUTES}m (oldest ${SCAN_ZOMBIE_OLDEST:-unknown}) — GitHub is not starting them; a workflow file over the 500 KB limit does exactly this. They are excluded from the in-flight count so the liveness lever keeps working." + fi LAST_SCHEDULE="$(jq -r '[.[] | select(.event == "schedule")] | first | .createdAt // ""' /tmp/scan-runs.json 2> /dev/null || echo '')" # In-flight counts SCHEDULE runs plus OUR OWN liveness dispatch, # attributed by recorded run id — never by timestamp proximity: a @@ -228,13 +271,29 @@ jobs: # marker): the dispatch is simply not counted, so the failure mode # is one absorbed duplicate scan — never starvation. Same for the # first tick: no watermark, nothing attributed. - SCAN_INFLIGHT="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" ' + SCAN_INFLIGHT="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"' [ .[] | select(.status != "completed") + | select(wedged($now; $zmin) | not) | select( (.event == "schedule") or (.event == "workflow_dispatch" and $lvrun != "" and ((.databaseId | tostring) == $lvrun)) ) ] | length' /tmp/scan-runs.json 2> /dev/null || echo 0)" + # During a PERSISTENT wedge the watermark cycle would reopen this + # gate every 60 minutes and plant a fresh uncancellable queued + # dispatch per hour, each refreshing the very liveness watermark + # whose growing age exposed the incident. If the run recorded from + # the last dispatch is ITSELF still wedged in the snapshot, another + # dispatch would wedge too — keep the gate closed. This lengthens + # the interval, it does not block hard: once that run starts, + # completes, or leaves the snapshot window, the gate reopens on its + # own, so the residual stays the documented single absorbed + # duplicate scan instead of one corpse per hour. When attribution + # falls back to run=none the guard cannot see the dispatch — no id + # was recorded — and the watermark-cycle corpse planting resumes; + # the wedge banner remains the exposure signal for that path. + PREV_LIVENESS_WEDGED="$(jq -r --arg lvrun "${PREV_LIVENESS_RUN}" --arg now "${NOW_EPOCH}" --arg zmin "${ZOMBIE_QUEUED_MINUTES}" "${ZOMBIE_JQ}"' + [ .[] | select($lvrun != "" and ((.databaseId | tostring) == $lvrun) and wedged($now; $zmin)) ] | length' /tmp/scan-runs.json 2> /dev/null || echo 0)" LAST_SIGNAL="${LAST_SCHEDULE}" if [[ -n "${PREV_LIVENESS}" && "${PREV_LIVENESS}" > "${LAST_SIGNAL}" ]]; then LAST_SIGNAL="${PREV_LIVENESS}" @@ -245,8 +304,8 @@ jobs: fi LIVENESS_OUT="${PREV_LIVENESS}" LIVENESS_RUN_OUT="${PREV_LIVENESS_RUN}" - echo "🫀 last scan signal: ${LAST_SIGNAL:-never} (${SCAN_AGE_MIN}m ago), liveness-relevant in-flight: ${SCAN_INFLIGHT}, snapshot ok: ${SCAN_RUNS_OK}, watermark state known: ${DASH_LOOKUP_OK}" - if [[ "${DASH_LOOKUP_OK}" == "true" && "${SCAN_RUNS_OK}" == "true" && "${SCAN_AGE_MIN}" -ge "${SCAN_LIVENESS_MINUTES}" && "${SCAN_INFLIGHT}" == "0" ]]; then + echo "🫀 last scan signal: ${LAST_SIGNAL:-never} (${SCAN_AGE_MIN}m ago), liveness-relevant in-flight: ${SCAN_INFLIGHT}, wedged-queued: ${SCAN_ZOMBIES}, prev-liveness wedged: ${PREV_LIVENESS_WEDGED}, snapshot ok: ${SCAN_RUNS_OK}, watermark state known: ${DASH_LOOKUP_OK}" + if [[ "${DASH_LOOKUP_OK}" == "true" && "${SCAN_RUNS_OK}" == "true" && "${SCAN_AGE_MIN}" -ge "${SCAN_LIVENESS_MINUTES}" && "${SCAN_INFLIGHT}" == "0" && "${PREV_LIVENESS_WEDGED}" == "0" ]]; then DISPATCH_T0="$(date -u -d '5 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" if act "scan liveness: dispatch unforced review scan" \ env GITHUB_TOKEN="${ACTIONS_TOKEN}" gh workflow run qwen-autofix.yml --repo "${REPO}" -f phase=review; then @@ -305,6 +364,11 @@ jobs: # enumeration is not a busy-set, it is unknown busy-state, and # BUSY_OK=false defers every conflict dispatch below (it inherits # SCAN_RUNS_OK so a failed run-list read defers the same way). + # Wedged runs are NOT skipped here (unlike the in-flight count): + # age alone proves jobless only for the wedge class that defined + # the threshold — when the runner pool is offline, queued runs hold + # live review-address jobs indefinitely, and dropping them by age + # would silently re-dispatch their PRs. The jobs read settles it. SHEP_BUSY=' ' BUSY_OK="${SCAN_RUNS_OK}" while IFS= read -r LIVE_RUN; do @@ -1130,6 +1194,16 @@ jobs: echo echo "Last tick: $(date -u +%Y-%m-%dT%H:%M:%SZ) · scan-signal age: ${SCAN_AGE_MIN}m · syncs: ${SYNCS} · dispatches: ${DISPATCHES} · releases: ${RELEASES} · cleanups: ${CLEANUPS}" echo + # Surfaced on the dashboard, not just in a log nobody opens: a + # wedged queue is the shape of a dead loop that still reports + # green from PR-event runs. + if [[ "${SCAN_ZOMBIES}" -gt 0 ]]; then + echo "> ⚠️ **${SCAN_ZOMBIES} autofix run(s) wedged in \`queued\`** (oldest ${SCAN_ZOMBIE_OLDEST:-unknown}) — excluded from the in-flight count. The list is status+age only and cannot see jobs: a workflow file over GitHub's 500 KB limit wedges zero-job runs, and an offline runner pool keeps live jobs queued just as long. Check \`gh run view --json jobs\` before deleting any of them." + if [[ "${PREV_LIVENESS_WEDGED}" -gt 0 ]]; then + echo "> 🚧 The shepherd's liveness re-dispatch stays paused while the recorded liveness run (id ${PREV_LIVENESS_RUN}) is among them — a fresh dispatch would only wedge again. Deleting THAT run (\`gh run delete ${PREV_LIVENESS_RUN}\`) reopens it immediately; it also reopens once it leaves the 50-run snapshot window. When several runs are listed, check \`gh run view --json jobs\` before deleting — an offline runner pool keeps live jobs queued." + fi + echo + fi echo '## Bot fleet' echo echo '| PR | Head | State | Action this tick |' diff --git a/package-lock.json b/package-lock.json index dc7d929ba9..d3e4d41661 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18549,7 +18549,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18571,7 +18570,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18593,7 +18591,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18615,7 +18612,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18637,7 +18633,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18659,7 +18654,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18681,7 +18675,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18703,7 +18696,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18725,7 +18717,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18747,7 +18738,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -18769,7 +18759,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index eb333f6a58..458d7560ac 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -1393,6 +1393,40 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('wraps a Goal control request in the envelope the agent reads', async () => { + // The agent's `sessionGoalControl` handler reads `params['request']`; this + // method is its only producer, and a flattened envelope makes every + // POST /session/:id/goal fail with "Invalid or missing Goal control + // request" while the route and agent tests stay green. + const snapshot = { v: 2, activity: 'idle', goal: null }; + const handle = makeChannel({ + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionGoalControl + ? { snapshot } + : {}, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const request = { action: 'create' as const, objective: 'ship it' }; + + await expect( + bridge.controlSessionGoal(session.sessionId, request), + ).resolves.toEqual({ snapshot }); + expect(handle.agent.extMethodCalls).toContainEqual({ + method: SERVE_CONTROL_EXT_METHODS.sessionGoalControl, + params: { sessionId: session.sessionId, request }, + }); + + await expect( + bridge.controlSessionGoal( + '11111111-2222-3333-4444-555555555555', + request, + ), + ).rejects.toBeInstanceOf(SessionNotFoundError); + + await bridge.shutdown(); + }); + it('serves completed MCP status without restarting an idle channel', async () => { const makeMcpChannel = () => makeChannel({ @@ -29635,6 +29669,103 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await bridge.shutdown(); }); + /** + * A Goal turn runs inside the child via `prompt()` directly, so the bridge + * never sees a `session/prompt` RPC for it and `pendingPromptCount` stays 0 + * for its whole duration. The child still drains this queue between tool + * batches, so the session is busy: without the `goalTurnActive` check every + * mid-turn insert during a Goal turn would be refused as idle — while the + * client enables the affordance precisely because a Goal turn is non-idle. + */ + it('accepts a rejectIfIdle insert while a child-driven Goal turn runs', async () => { + const handle = makeChannel({}); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await handle.agentConnection.extNotification('_qwencode/start_turn', { + sessionId: session.sessionId, + source: 'goal', + }); + + expect( + bridge.enqueueMidTurnMessage( + session.sessionId, + 'insert me', + { clientId: session.clientId }, + 'goal-insert', + { rejectIfIdle: true }, + ), + ).toEqual({ accepted: true, messageId: 'goal-insert' }); + // Queued for the child's drain, NOT promoted into a prompt of its own. + expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]); + expect( + bridge.getMidTurnMessages(session.sessionId, { + clientId: session.clientId, + }).messages, + ).toEqual([ + expect.objectContaining({ messageId: 'goal-insert', text: 'insert me' }), + ]); + + await bridge.shutdown(); + }); + + it('promotes what the ending Goal turn never drained', async () => { + let release: (() => void) | undefined; + const handle = makeChannel({ + promptImpl: async () => { + await new Promise((res) => { + release = res; + }); + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await handle.agentConnection.extNotification('_qwencode/start_turn', { + sessionId: session.sessionId, + source: 'goal', + }); + expect( + bridge.enqueueMidTurnMessage( + session.sessionId, + 'never drained', + { clientId: session.clientId }, + 'goal-undrained', + { rejectIfIdle: true }, + ), + ).toEqual({ accepted: true, messageId: 'goal-undrained' }); + + // A Goal turn owns no prompt slot, so its end is the only signal that can + // settle what its last drain missed. + await handle.agentConnection.extNotification('_qwencode/end_turn', { + sessionId: session.sessionId, + reason: 'end_turn', + source: 'goal', + promptId: `${session.sessionId}########1`, + }); + + await vi.waitFor(() => + expect(bridge.getPendingPrompts(session.sessionId)).toEqual([ + expect.objectContaining({ + promptId: 'goal-undrained', + text: 'never drained', + }), + ]), + ); + expect( + bridge.getMidTurnMessages(session.sessionId, { + clientId: session.clientId, + }).messages, + ).toEqual([]); + + release?.(); + await vi.waitFor(() => + expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]), + ); + await bridge.shutdown(); + }); + it('rejects a whitespace-only message even while busy', async () => { const { factory, release } = hangingPromptFactory(); const bridge = makeBridge({ channelFactory: factory }); @@ -30699,10 +30830,13 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa const admission = bridge.enqueueMidTurnMessage( session.sessionId, 'leftover', + { clientId: session.clientId }, + 'leftover-public', + { rejectIfIdle: true }, ); expect(admission).toEqual({ accepted: true, - messageId: expect.any(String), + messageId: 'leftover-public', }); releases[0]!(); await t1; @@ -30712,6 +30846,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa expect.objectContaining({ promptId: admission.messageId, text: 'leftover', + originatorClientId: session.clientId, }), ]); releases[1]!(); @@ -31100,12 +31235,16 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa .catch(() => {}); await new Promise((r) => setTimeout(r, 10)); - const admission = bridge.enqueueMidTurnMessage(session.sessionId, 'hi', { - clientId: session.clientId, - }); + const admission = bridge.enqueueMidTurnMessage( + session.sessionId, + 'hi', + { clientId: session.clientId }, + 'public-mid-turn', + { rejectIfIdle: true }, + ); expect(admission).toEqual({ accepted: true, - messageId: expect.any(String), + messageId: 'public-mid-turn', }); // Subscribe before the drain so the live injection frame is captured. The @@ -32034,6 +32173,31 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa await bridge.shutdown(); }); + it('rejects a public enqueue on idle only when rejectIfIdle is set', async () => { + let promptCalls = 0; + const handle = makeChannel({ + promptImpl: async () => { + promptCalls++; + return { stopReason: 'end_turn' }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + expect( + bridge.enqueueMidTurnMessage( + session.sessionId, + 'public message', + { clientId: session.clientId }, + 'public-idle', + { rejectIfIdle: true }, + ), + ).toEqual({ accepted: false }); + expect(promptCalls).toBe(0); + expect(bridge.getPendingPrompts(session.sessionId)).toEqual([]); + await bridge.shutdown(); + }); + it('still queues a queueOnly enqueue while the session is busy', async () => { const release = deferred(); const prompts: string[] = []; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 5155d452ec..70a75ce7ee 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1136,6 +1136,13 @@ interface SessionEntry { * an originator clientId is known. Used by the session reaper to avoid * killing sessions mid-prompt. */ promptActive: boolean; + /** + * True while a child-driven Goal turn is running. Maintained by the + * `_qwencode/start_turn` / `_qwencode/end_turn` (source `goal`) + * notifications in `BridgeClient`; OR-ed into `hasActivePrompt` + * summaries because Goal turns never flip `promptActive`. + */ + goalTurnActive?: boolean; /** Terminal error from the prior turn, cleared when the next turn starts. */ turnError?: { message: string; @@ -3619,7 +3626,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(entry.sourceType ? { sourceType: entry.sourceType } : {}), ...(entry.sourceId !== undefined ? { sourceId: entry.sourceId } : {}), clientCount: entry.clientIds.size, - hasActivePrompt: entry.promptActive, + hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, isWaitingForPermission, isWaitingForUserQuestion, pendingInteractionCount: entry.pendingInteractions.size, @@ -4018,6 +4025,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Child-side automatic title updates change persisted catalog // metadata the bridge never sees; forward the catalog-clock mark. markSessionCatalogChanged, + // A Goal turn drains the mid-turn queue but owns no prompt slot, so + // nothing else would settle what its last drain missed. + settleMidTurnQueueAfterGoalTurn, ); const rawConnection = new ClientSideConnection( () => @@ -6622,7 +6632,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // Late attachers get the same ACP state the original restore // caller saw; spawn-only sessions don't carry a state payload. state: existing.restoreState ?? {}, - hasActivePrompt: existing.promptActive, + hasActivePrompt: + existing.promptActive || existing.goalTurnActive === true, ...replayFields, ...(historyAnchorRecordId !== undefined ? { historyAnchorRecordId } @@ -6768,7 +6779,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attached: true, clientId, createdAt: entry.createdAt, - hasActivePrompt: entry.promptActive, + hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, ...(waiterReplayFields ?? {}), }; } @@ -7213,7 +7224,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ? { sourceId: racedEntry.sourceId } : {}), state: racedEntry.restoreState ?? {}, - hasActivePrompt: racedEntry.promptActive, + hasActivePrompt: + racedEntry.promptActive || racedEntry.goalTurnActive === true, ...replayFieldsFor(racedEntry, action, liveReplayMode), }; } @@ -7313,7 +7325,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(artifactRestoreWarnings.length > 0 ? { artifactWarnings: artifactRestoreWarnings } : {}), - hasActivePrompt: entry.promptActive, + hasActivePrompt: entry.promptActive || entry.goalTurnActive === true, ...replayFieldsFor(entry, action, liveReplayMode), }; })().finally(async () => { @@ -7667,6 +7679,61 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { }); }; + /** + * Hand back every mid-turn message the turn that just ended never drained: + * `queueOnly` callers drive their own follow-through, everything else starts + * through the normal prompt path. + */ + const settleUndrainedMidTurnMessages = ( + entry: SessionEntry, + messages: readonly MidTurnQueueEntry[], + ) => { + for (const message of messages) { + if (message.queueOnly) { + try { + message.onSettledWithoutDrain?.(); + } catch (error) { + writeStderrLine( + `[mid-turn] session=${JSON.stringify(entry.sessionId)} failed to hand undrained queue-only message ${JSON.stringify(message.messageId)} back to its caller: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`, + ); + } + continue; + } + promoteMidTurnMessage( + entry, + message.messageId, + message.text, + message.originatorClientId, + message.content, + ); + } + }; + + /** + * Close the Goal turn's drain window. A Goal turn drains the mid-turn queue + * from inside the child, so a message enqueued after its last drain would + * otherwise sit in the queue with nothing scheduled to consume it — the same + * race the prompt settle already closes. Promoting is the supported path + * while a Goal is still active: the child's `claimGoalTurn` makes the + * promoted prompt wait for the permit and run as the next Goal turn. + */ + const settleMidTurnQueueAfterGoalTurn = (sessionId: string) => { + const entry = byId.get(sessionId); + if (!entry) return; + // A prompt owns the queue and settles it on its own terminal; a Goal turn + // that started again already re-armed the child's drain. + if ( + entry.goalTurnActive === true || + entry.pendingPromptCount > 0 || + entry.closing + ) { + return; + } + const undrained = entry.midTurnMessageQueue.splice(0); + if (undrained.length === 0) return; + settleUndrainedMidTurnMessages(entry, undrained); + }; + const bridgeApi: AcpSessionBridge = { setLiveScreenContextCaptureHandler(handler) { liveScreenContextCaptureHandler = handler; @@ -7715,7 +7782,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { attachCount: entry.attachCount, pendingPromptCount: entry.pendingPromptCount, pendingPermissionCount: entry.pendingPermissionIds.size, - hasActivePrompt: entry.promptActive, + hasActivePrompt: + entry.promptActive || entry.goalTurnActive === true, lastEventId: entry.events.lastEventId, ...(entry.sessionLastSeenAt !== undefined ? { lastSeenAt: entry.sessionLastSeenAt } @@ -7979,7 +8047,8 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...(existing.sourceId !== undefined ? { sourceId: existing.sourceId } : {}), - hasActivePrompt: existing.promptActive, + hasActivePrompt: + existing.promptActive || existing.goalTurnActive === true, }; } // Coalesce: if another caller is already mid-spawn for this same @@ -8055,7 +8124,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ...session, attached: true, clientId, - hasActivePrompt: attachedEntry.promptActive, + hasActivePrompt: + attachedEntry.promptActive || + attachedEntry.goalTurnActive === true, }; } } @@ -8594,6 +8665,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { return copy; })(); entry.promptActive = true; + // The child serializes Goal turns against RPC prompts, so a + // still-set flag here means the goal end_turn signal was + // lost; self-heal rather than pin the session active. + entry.goalTurnActive = false; entry.activePromptId = pendingEntry.promptId; delete entry.cancelBroadcastWithoutPrompt; delete entry.turnError; @@ -8863,25 +8938,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // caller synchronously reserves the next FIFO slot, then ordinary // promotions follow it without exposing the fallback as queued. releasePromptSlot(); - for (const message of undrainedMessages) { - if (message.queueOnly) { - try { - message.onSettledWithoutDrain?.(); - } catch (error) { - writeStderrLine( - `[mid-turn] session=${JSON.stringify(entry.sessionId)} failed to hand undrained queue-only message ${JSON.stringify(message.messageId)} back to its caller: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`, - ); - } - continue; - } - promoteMidTurnMessage( - entry, - message.messageId, - message.text, - message.originatorClientId, - message.content, - ); - } + settleUndrainedMidTurnMessages(entry, undrainedMessages); // DAEMON-005: deferred close-on-prompt-complete. Lives here (not // in `promptPromise.finally`) so the terminal broadcast — the // `result.then` registered above on this same promise — runs @@ -10134,6 +10191,19 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { ); }, + async controlSessionGoal(sessionId, request, context) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + resolveTrustedClientId(entry, context?.clientId); + return requestSessionStatus( + sessionId, + SERVE_CONTROL_EXT_METHODS.sessionGoalControl, + { request }, + ); + }, + async clearSessionGoal(sessionId) { return requestSessionStatus<{ cleared: boolean; condition?: string }>( sessionId, @@ -11058,11 +11128,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const messageId = requestedMessageId ?? randomUUID(); // If the turn settled while the POST was in flight, start it through the // normal prompt path. A client-supplied id keeps retries idempotent. - if (entry.pendingPromptCount === 0) { - // `queueOnly` callers (live steering) drive the next turn themselves: - // a promoted message would run as a bare prompt with no collector - // forwarding its response to them or arming a deadline. - if (options?.queueOnly) { + // A child-driven Goal turn never crosses the `session/prompt` RPC + // boundary, so `pendingPromptCount` stays 0 for its whole duration — + // but the child drains THIS queue between tool batches from inside that + // turn, so the session is genuinely busy and the message belongs in the + // queue. Without `goalTurnActive` here every mid-turn insert during a + // Goal turn is rejected as idle even though the client enables the + // affordance (Goal turns are non-idle in `hasActivePrompt` summaries). + if (entry.pendingPromptCount === 0 && entry.goalTurnActive !== true) { + // Both modes refuse new ownership once idle. `queueOnly` callers (live + // steering) additionally drive the next turn themselves: a promoted + // message would have no collector forwarding its response or deadline. + if (options?.queueOnly || options?.rejectIfIdle) { writeStderrLine( `[mid-turn] session=${JSON.stringify(entry.sessionId)} rejected id ${JSON.stringify(messageId)}: session idle`, ); diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index dc4f252a97..ca0473bd0d 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -213,6 +213,96 @@ describe('BridgeClient — background notification turn boundary', () => { }); }); + it('marks the session active for a goal-turn start signal', async () => { + const sessionId = 'session-goal'; + const publish = vi.fn(); + const entry = { sessionId, events: { publish }, goalTurnActive: false }; + const noFlow = () => { + throw new Error('test: permission flow should not run'); + }; + const client = new BridgeClient( + ((id: string) => (id === sessionId ? entry : undefined)) as never, + noFlow as never, + { request: noFlow } as never, + 0, + Infinity, + ); + + await client.extNotification('_qwencode/start_turn', { + sessionId, + source: 'goal', + }); + + expect(entry.goalTurnActive).toBe(true); + expect(publish).not.toHaveBeenCalled(); + + await client.extNotification('_qwencode/end_turn', { + sessionId, + reason: 'end_turn', + source: 'goal', + promptId: 'session-goal########1', + }); + + expect(entry.goalTurnActive).toBe(false); + }); + + it('publishes a real turn_complete for a goal-turn end signal', async () => { + const sessionId = 'session-goal'; + const publish = vi.fn().mockReturnValue(true); + const entry = { sessionId, events: { publish } }; + const noFlow = () => { + throw new Error('test: permission flow should not run'); + }; + const client = new BridgeClient( + ((id: string) => (id === sessionId ? entry : undefined)) as never, + noFlow as never, + { request: noFlow } as never, + 0, + Infinity, + ); + + await client.extNotification('_qwencode/end_turn', { + sessionId, + reason: 'end_turn', + source: 'goal', + promptId: 'session-goal########3', + }); + + expect(publish).toHaveBeenCalledWith({ + type: 'turn_complete', + promptId: 'session-goal########3', + data: { + sessionId, + stopReason: 'end_turn', + promptId: 'session-goal########3', + }, + }); + }); + + it('drops a goal-turn end signal without a promptId', async () => { + const sessionId = 'session-goal'; + const publish = vi.fn(); + const entry = { sessionId, events: { publish } }; + const noFlow = () => { + throw new Error('test: permission flow should not run'); + }; + const client = new BridgeClient( + ((id: string) => (id === sessionId ? entry : undefined)) as never, + noFlow as never, + { request: noFlow } as never, + 0, + Infinity, + ); + + await client.extNotification('_qwencode/end_turn', { + sessionId, + reason: 'end_turn', + source: 'goal', + }); + + expect(publish).not.toHaveBeenCalled(); + }); + it('drops malformed or foreign end-turn signals', async () => { const publish = vi.fn(); const entry = { sessionId: 'owned', events: { publish } }; diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 8e4a29996a..b0b424f16b 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -636,6 +636,14 @@ export interface BridgeClientSessionEntry { settledMidTurnMessageIds: string[]; /** Complete prompts waiting behind the currently running prompt. */ pendingPromptList: PendingPromptEntry[]; + /** + * True while a child-driven Goal turn is running. Set by the + * `_qwencode/start_turn` notification and cleared by the matching + * `_qwencode/end_turn`; OR-ed into `hasActivePrompt` summaries so + * live-state consumers (sidebar activity, daemon status) see Goal turns + * that never cross the bridge's `session/prompt` RPC boundary. + */ + goalTurnActive?: boolean; /** Bridge prompt that owns the child Guard wait for this FIFO. */ todoStopGuardAwaitingQueuedPromptOwnerPromptId?: string; /** True while a prompt is executing for this session. */ @@ -829,6 +837,14 @@ export class BridgeClient implements Client { * optional so existing direct constructors stay source-compatible. */ private readonly onSessionCatalogChanged?: () => void, + /** + * Invoked after a child-driven Goal turn clears `goalTurnActive`. The + * bridge settles whatever the ending turn's last mid-turn drain missed — + * a Goal turn owns no prompt slot, so its terminal is the only signal. + * Trailing and optional so existing direct constructors stay + * source-compatible. + */ + private readonly onGoalTurnEnded?: (sessionId: string) => void, ) {} async requestPermission( @@ -1929,7 +1945,7 @@ export class BridgeClient implements Client { * `qwen/notify/session/prompt-suggestion` (followup assist), * `qwen/notify/session/artifact-event` (hook artifacts), * `qwen/notify/session/terminal-sequence`, and - * `_qwencode/end_turn` (background-notification turns), and + * `_qwencode/end_turn` (background-notification and goal turns), and * `qwen/notify/session/mcp-budget-event` — each translated into a * session-scoped SSE frame. Unknown methods are dropped silently for * forward-compat. @@ -1961,21 +1977,56 @@ export class BridgeClient implements Client { } return; } + if (method === '_qwencode/start_turn') { + const sessionId = params['sessionId']; + if ( + typeof sessionId !== 'string' || + sessionId.length === 0 || + params['source'] !== 'goal' + ) { + return; + } + const entry = this.resolveEntry(sessionId); + if (!entry || !this.ownsSession(sessionId)) return; + entry.goalTurnActive = true; + return; + } if (method === '_qwencode/end_turn') { const sessionId = params['sessionId']; const reason = params['reason']; + const source = params['source']; if ( typeof sessionId !== 'string' || sessionId.length === 0 || typeof reason !== 'string' || reason.length === 0 || reason.length > 128 || - params['source'] !== 'background_notification' + (source !== 'background_notification' && source !== 'goal') ) { return; } const entry = this.resolveEntry(sessionId); if (!entry || !this.ownsSession(sessionId)) return; + if (source === 'goal') { + entry.goalTurnActive = false; + // Before the promptId validation below: a malformed id costs the + // session its `turn_complete`, but the queue must still be settled. + this.onGoalTurnEnded?.(sessionId); + const promptId = params['promptId']; + if ( + typeof promptId !== 'string' || + promptId.length === 0 || + promptId.length > 256 + ) { + return; + } + entry.events.publish({ + type: 'turn_complete', + promptId, + data: { sessionId, stopReason: reason, promptId }, + }); + return; + } entry.events.publish({ type: 'background_notification_turn_complete', data: { sessionId, reason }, diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 3332371c2d..1e2d087a05 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -6,7 +6,9 @@ import type { ApprovalMode, + GoalControlRequest, GoalSnapshotV2, + GoalStateResponse, SessionGroupPresetColor, TurnResultCode, TurnResultErrorPayload, @@ -1661,6 +1663,13 @@ export interface AcpSessionBridge { sessionId: string, ): Promise<{ cleared: boolean; condition?: string }>; + /** Atomically apply a typed Goal lifecycle control in a live session. */ + controlSessionGoal( + sessionId: string, + request: GoalControlRequest, + context?: BridgeClientRequestContext, + ): Promise; + /** * Read a live session's Goal state. Throws `SessionNotFoundError` when the * session is not resident because this route addresses the selected runtime. @@ -1849,9 +1858,12 @@ export interface AcpSessionBridge { * authorized against the session like `/prompt` and `/btw` — throws * `InvalidClientIdError` when the id is not bound to the session, and * `SessionNotFoundError` for unknown ids. Ownership is session-wide. - * With `options.queueOnly` an idle session rejects instead of promoting. If - * a busy session settles before draining the message, - * `onSettledWithoutDrain` lets the caller drive the next turn itself. + * With `options.rejectIfIdle` an idle session rejects instead of taking + * ownership. A message accepted while busy keeps the ordinary public queue + * semantics: it is echoed when drained and promoted if the turn settles + * first. `options.queueOnly` is reserved for internal live steering; if a + * busy session settles before draining one of those messages, + * `onSettledWithoutDrain` lets that internal caller drive the next turn. * `options.content` carries image blocks with the message; * an empty `message` is admitted when media blocks are present. */ @@ -1861,6 +1873,7 @@ export interface AcpSessionBridge { context?: BridgeClientRequestContext, messageId?: string, options?: { + rejectIfIdle?: boolean; queueOnly?: boolean; onSettledWithoutDrain?: () => void; content?: readonly BridgePromptContentBlock[]; diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 51cb8ac603..7a49486b3c 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -175,6 +175,7 @@ export const SERVE_CONTROL_EXT_METHODS = { workspaceMemoryDream: 'qwen/control/workspace/memory/dream', // Runtime MCP server mutation ext-methods sessionTaskCancel: 'qwen/control/session/task/cancel', + sessionGoalControl: 'qwen/control/session/goal/control', sessionGoalClear: 'qwen/control/session/goal/clear', /** * Read a live session's `/goal` state. The active goal lives only in the diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 522260986a..94d21b07ed 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -89,14 +89,30 @@ describe('createTranscriptReplayMachine', () => { ).toEqual([]); }); + it('replays user-initiated Goal controls as user messages', () => { + const projected = updates( + createTranscriptReplayMachine(), + goalStateRecord('goal-create', 'create', GOAL), + ); + + expect(projected[0]).toMatchObject({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: `/goal ${GOAL.objective}` }, + _meta: { + source: 'goal_control', + 'qwen.session.recordId': 'goal-create', + }, + }); + }); + it('projects goal_state through v2-first metadata', () => { const projected = updates( createTranscriptReplayMachine(), goalStateRecord('goal-create', 'create', GOAL), ); - expect(projected).toHaveLength(1); - expect(projected[0]?._meta).toMatchObject({ + expect(projected).toHaveLength(2); + expect(projected[1]?._meta).toMatchObject({ goalState: { v: 2, goal: GOAL, activity: 'idle' }, goalStatus: { kind: 'set', condition: GOAL.objective }, 'qwen.session.recordId': 'goal-create', @@ -115,7 +131,7 @@ describe('createTranscriptReplayMachine', () => { goalStateRecord('goal-clear', 'clear', null), ); - expect(projected[0]?._meta).toMatchObject({ + expect(projected[1]?._meta).toMatchObject({ goalState: { v: 2, goal: null, activity: 'idle' }, goalStatus: { kind: 'cleared', condition: GOAL.objective }, 'qwen.session.recordId': 'goal-clear', @@ -182,7 +198,7 @@ describe('createTranscriptReplayMachine', () => { expect( updates(machine, goalStateRecord('goal-create', 'create', GOAL)), - ).toHaveLength(1); + ).toHaveLength(2); const turned: GoalRecord = { ...GOAL, @@ -302,7 +318,7 @@ describe('createTranscriptReplayMachine', () => { expect( updates(machine, goalStateRecord('goal-create', 'create', GOAL)), - ).toHaveLength(1); + ).toHaveLength(2); const turnedOnce: GoalRecord = { ...GOAL, diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 3b8553c55a..b8a6c8b645 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -939,9 +939,26 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { payload, this.goalState?.goal ?? null, ); + const goalControlCommand = projectGoalControlCommand( + payload.cause, + payload.snapshot, + ); this.goalState = payload.snapshot; this.goalCause = payload.cause; if (bookkeepingOnly) return; + if (goalControlCommand) { + yield emit( + createTranscriptMessageUpdate({ + role: 'user', + text: goalControlCommand, + ...meta, + extra: { + source: 'goal_control', + 'qwen.session.recordId': record.uuid, + }, + }), + ); + } const { type: _type, ...goalStatus } = projection.goalStatus; yield emit( createTranscriptMessageUpdate({ @@ -1121,6 +1138,40 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { } } +function projectGoalControlCommand( + cause: GoalStateCause, + snapshot: GoalSnapshotV2, +): string | undefined { + switch (cause) { + case 'create': + case 'replace': + return snapshot.goal ? `/goal ${snapshot.goal.objective}` : undefined; + case 'edit': + return snapshot.goal + ? `/goal edit ${snapshot.goal.objective}` + : undefined; + case 'pause': + case 'resume': + case 'clear': + return `/goal ${cause}`; + case 'turn_finished': + case 'checkpoint': + case 'verifier_accept': + case 'verifier_reject': + case 'complete': + case 'blocked': + case 'usage_limited': + case 'migrated': + return undefined; + default: + return assertNever(cause); + } +} + +function assertNever(value: never): never { + throw new Error(`Unsupported Goal state cause: ${String(value)}`); +} + function parseTranscriptGoalStatus( value: unknown, ): TranscriptGoalStatus | undefined { diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index b3d6350c3a..3276722c7f 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -222,6 +222,19 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ GoalPersistenceUnavailableError: ( await importOriginal() ).GoalPersistenceUnavailableError, + parseGoalControlRequest: ( + await importOriginal() + ).parseGoalControlRequest, + // The real classes for the same reason as above: `mapGoalControlError` + // narrows on them with `instanceof`, and a stand-in (or an omission, which + // resolves to undefined) makes every conflict/transition branch throw before + // it can be asserted. + GoalConflictError: ( + await importOriginal() + ).GoalConflictError, + GoalInvalidTransitionError: ( + await importOriginal() + ).GoalInvalidTransitionError, SessionIdCaseConflictError: ( await importOriginal() ).SessionIdCaseConflictError, @@ -945,6 +958,8 @@ import { APPROVAL_MODES, ToolNames, GoalPersistenceUnavailableError, + GoalConflictError, + GoalInvalidTransitionError, } from '@qwen-code/qwen-code-core'; import { ndJsonStream } from '@qwen-code/acp-bridge/ndJsonStream'; import { SESSION_SOURCE_META_KEY } from '@qwen-code/acp-bridge'; @@ -3891,6 +3906,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getHookSystem: vi.fn().mockReturnValue(undefined), getDisableAllHooks: vi.fn().mockReturnValue(true), hasHooksForEvent: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(true), }; } @@ -9851,6 +9867,158 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('allows reducing Goal work in an untrusted workspace but rejects starting it', async () => { + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const snapshot = goalSnapshot({ objective: 'ship it', turnCount: 1 }); + const dispatch = vi.fn().mockResolvedValue({ snapshot }); + Object.assign(innerConfig, { + isTrustedFolder: vi.fn().mockReturnValue(false), + getGoalRuntimeReady: vi.fn().mockResolvedValue({ + getSnapshot: () => snapshot, + dispatch, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, { + sessionId, + request: { + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 1, + }, + }), + ).resolves.toEqual({ snapshot }); + // Every action that starts or expands Goal work is gated, not just create: + // dropping any one of them restarts work in an untrusted workspace. + for (const request of [ + { action: 'create' as const, objective: 'new work' }, + { + action: 'replace' as const, + objective: 'new work', + expectedGoalId: 'goal-1', + expectedRevision: 1, + }, + { + action: 'edit' as const, + objective: 'revised work', + expectedGoalId: 'goal-1', + expectedRevision: 1, + }, + { + action: 'resume' as const, + expectedGoalId: 'goal-1', + expectedRevision: 1, + }, + ]) { + await expect( + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, { + sessionId, + request, + }), + ).rejects.toMatchObject({ + code: -32003, + data: { errorKind: 'untrusted_workspace', httpStatus: 403 }, + }); + } + expect(dispatch).toHaveBeenCalledOnce(); + + mockConnectionState.resolve(); + await agentPromise; + }); + + it('maps a Goal control dispatch failure onto its wire contract', async () => { + // The client's 409 resync reads `data.errorKind` and `data.current`: a + // refactor that drops `current`, swaps the `instanceof` order, or changes + // the code breaks resync silently. The only other coverage here is the + // success path and the untrusted gate, and the gate throws before this + // mapping is reachable. + const sessionId = '11111111-1111-1111-1111-111111111111'; + const innerConfig = await setupSessionMocks(sessionId); + const current = goalSnapshot({ objective: 'ship it', revision: 4 }); + const persistFallback = goalSnapshot({ objective: 'from the runtime' }); + const dispatch = vi.fn(); + Object.assign(innerConfig, { + isTrustedFolder: vi.fn().mockReturnValue(true), + getGoalRuntime: vi.fn().mockReturnValue({ + getSnapshot: () => persistFallback, + dispatch, + }), + getGoalRuntimeReady: vi.fn().mockResolvedValue({ + getSnapshot: () => persistFallback, + dispatch, + }), + }); + + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + const control = (request: Record) => + agent.extMethod(SERVE_CONTROL_EXT_METHODS.sessionGoalControl, { + sessionId, + request, + }); + const pause = { + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 1, + }; + + // A CAS miss carries the daemon's own snapshot so the client can resync + // against it rather than re-reading. + dispatch.mockRejectedValueOnce(new GoalConflictError(current)); + await expect(control(pause)).rejects.toMatchObject({ + code: -32009, + data: { errorKind: 'goal_conflict', current }, + }); + + // Same code, different kind: the two are distinguished only by errorKind. + dispatch.mockRejectedValueOnce( + new GoalInvalidTransitionError('cannot pause a completed goal', current), + ); + await expect(control(pause)).rejects.toMatchObject({ + code: -32009, + message: 'cannot pause a completed goal', + data: { errorKind: 'goal_invalid_transition', current }, + }); + + // Anything else is a persistence failure, and its `current` comes from the + // runtime — the failure carries no snapshot of its own. + dispatch.mockRejectedValueOnce(new Error('disk full')); + await expect(control(pause)).rejects.toMatchObject({ + code: -32603, + message: 'disk full', + data: { errorKind: 'goal_persist_failed', current: persistFallback }, + }); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('returns cleared false when no session goal is active', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 5d5909e2da..5dfeaa1ae4 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -99,8 +99,14 @@ import { extractDaemonTraceContext, withDaemonSpan, emptyGoalSnapshot, + GoalConflictError, + GoalInvalidTransitionError, GoalPersistenceUnavailableError, + parseGoalControlRequest, + type GoalControlRequest, + type GoalRuntime, type GoalSnapshotV2, + type GoalStateResponse, type AgentParams, ApprovalMode, type Config, @@ -424,6 +430,68 @@ const ACP_REASONING_EFFORT_NAMES: Record = { // Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts. const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000; +function currentGoalSnapshot( + config: Config, + runtime?: GoalRuntime, +): GoalSnapshotV2 { + try { + return (runtime ?? config.getGoalRuntime()).getSnapshot(); + } catch { + return emptyGoalSnapshot(); + } +} + +function mapGoalControlError( + error: unknown, + config: Config, + runtime?: GoalRuntime, +): RequestError { + if (error instanceof GoalConflictError) { + return new RequestError(-32009, error.message, { + errorKind: 'goal_conflict', + current: error.current, + }); + } + if (error instanceof GoalInvalidTransitionError) { + return new RequestError(-32009, error.message, { + errorKind: 'goal_invalid_transition', + current: error.current, + }); + } + return new RequestError( + -32603, + error instanceof Error ? error.message : 'Goal persistence failed', + { + errorKind: 'goal_persist_failed', + current: currentGoalSnapshot(config, runtime), + }, + ); +} + +async function dispatchGoalControl( + config: Config, + request: GoalControlRequest, +): Promise { + const requiresTrustedWorkspace = + request.action === 'create' || + request.action === 'replace' || + request.action === 'edit' || + request.action === 'resume'; + if (requiresTrustedWorkspace && !config.isTrustedFolder()) { + throw new RequestError(-32003, 'Workspace is not trusted.', { + errorKind: 'untrusted_workspace', + httpStatus: 403, + }); + } + let runtime: GoalRuntime | undefined; + try { + runtime = await config.getGoalRuntimeReady(); + return await runtime.dispatch(request); + } catch (error) { + throw mapGoalControlError(error, config, runtime); + } +} + const TURN_STATUS_SCAN_PAGE_LIMIT = 500; const TURN_STATUS_SCAN_MAX_PAGES = 10; @@ -11114,6 +11182,28 @@ class QwenAgent implements Agent { snapshot: response.snapshot, }; } + case SERVE_CONTROL_EXT_METHODS.sessionGoalControl: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const request = parseGoalControlRequest(params['request']); + if (!request) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing Goal control request', + ); + } + const session = this.sessionOrThrow(sessionId); + const response = await dispatchGoalControl( + session.getConfig(), + request, + ); + return { snapshot: response.snapshot }; + } case SERVE_CONTROL_EXT_METHODS.sessionGoalGet: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 7d030f26da..91a03d6381 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -17709,6 +17709,62 @@ describe('Session', () => { ).not.toHaveBeenCalled(); }); + it('notifies the bridge that the Goal turn ended', async () => { + const permit: core.GoalTurnPermit = { + goalId: 'goal-1', + revision: 1, + turnId: 'turn-end-signal', + }; + mockGoalRuntime.getSnapshot.mockReturnValue({ + v: 2, + activity: 'running', + goal: { + goalId: 'goal-1', + revision: 1, + objective: 'check weather', + status: 'active', + evidenceCursor: { recordId: 'cursor-1' }, + turnCount: 0, + activeTimeMs: 0, + createdAt: 1234, + updatedAt: 1234, + }, + }); + mockGoalRuntime.permitForTurn.mockImplementation((turnKey: string) => + turnKey === 'goal-runtime:turn-end-signal' ? permit : undefined, + ); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + expect(boundGoalHost).toBeDefined(); + await boundGoalHost!.startGoalTurn({ + permit, + continuationContext: 'check weather', + }); + + await vi.waitFor(() => { + expect(mockClient.extNotification).toHaveBeenCalledWith( + '_qwencode/end_turn', + { + sessionId: 'test-session-id', + reason: 'end_turn', + source: 'goal', + promptId: expect.stringMatching( + /^test-session-id########\d+$/, + ) as unknown as string, + }, + ); + }); + expect(mockClient.extNotification).toHaveBeenCalledWith( + '_qwencode/start_turn', + { + sessionId: 'test-session-id', + source: 'goal', + }, + ); + }); + it('settles a Goal turn whose prompt rejects before the turn body runs', async () => { // `prompt()` rejects ahead of the try whose finally settles the turn // when `assertCanStartTurn` throws — a session that began closing diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 73e1fd40cc..c60c5998bd 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -2208,8 +2208,10 @@ export class Session implements SessionContext { this.goalProcessing = true; this.activeGoalTurn = turn; const parts = buildGoalContinuationParts(turn); + let result: PromptResponse | undefined; + await this.#emitGoalStartTurn(); try { - await this.prompt( + result = await this.prompt( { sessionId: this.sessionId, prompt: parts.map((part) => ({ @@ -2239,6 +2241,7 @@ export class Session implements SessionContext { }`, ); } finally { + await this.#emitGoalEndTurn(result); if (this.activeGoalTurn === turn) this.activeGoalTurn = undefined; this.goalProcessing = false; void this.#drainCronQueue(); @@ -8489,6 +8492,40 @@ export class Session implements SessionContext { } } + /** + * Goal turns run inside this child via `prompt()` directly, so the daemon + * bridge never observes a `session/prompt` RPC boundary for them and would + * otherwise publish no `turn_complete` — leaving SSE clients (Web Shell, + * SDK) with a streaming state that never settles. + */ + async #emitGoalStartTurn(): Promise { + try { + await this.client.extNotification('_qwencode/start_turn', { + sessionId: this.sessionId, + source: 'goal', + }); + } catch (error) { + debugLogger.debug( + `Goal start-turn extNotification dropped: ${this.#formatError(error)}`, + ); + } + } + + async #emitGoalEndTurn(result: PromptResponse | undefined): Promise { + try { + await this.client.extNotification('_qwencode/end_turn', { + sessionId: this.sessionId, + reason: result?.stopReason ?? 'cancelled', + source: 'goal', + promptId: this.config.getSessionId() + '########' + String(this.turn), + }); + } catch (error) { + debugLogger.debug( + `Goal end-turn extNotification dropped: ${this.#formatError(error)}`, + ); + } + } + async sendAvailableCommandsUpdate(): Promise { try { await this.sendAvailableCommandsUpdateOrThrow(); diff --git a/packages/cli/src/acp-integration/session/history-replay-page.test.ts b/packages/cli/src/acp-integration/session/history-replay-page.test.ts index 7b82a6046b..2f75c51d9d 100644 --- a/packages/cli/src/acp-integration/session/history-replay-page.test.ts +++ b/packages/cli/src/acp-integration/session/history-replay-page.test.ts @@ -603,7 +603,11 @@ describe('history replay page', () => { return 'next-cursor'; }, }); - expect(firstPage.updates).toHaveLength(2); + expect(firstPage.updates).toHaveLength(3); + expect(firstPage.updates[0]).toMatchObject({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: `/goal ${goal.objective}` }, + }); expect(nextReplay).toMatchObject({ goalCause: 'verifier_reject' }); const recommittedGoal = { diff --git a/packages/cli/src/commands/review/agent-prompt.test.ts b/packages/cli/src/commands/review/agent-prompt.test.ts index 6261103db8..5a4ad52a92 100644 --- a/packages/cli/src/commands/review/agent-prompt.test.ts +++ b/packages/cli/src/commands/review/agent-prompt.test.ts @@ -2865,7 +2865,7 @@ describe('buildRoleBrief — every agent, not just the territory ones', () => { incremental: { since: 'a'.repeat(40), effective: false, - reason: 'hunks-outside-pr-diff', + reason: 'nothing-to-narrow', diffBase: 'de17aba5e', }, }, diff --git a/packages/cli/src/commands/review/agent-prompt.ts b/packages/cli/src/commands/review/agent-prompt.ts index 6db8ccf459..f3d93588b4 100644 --- a/packages/cli/src/commands/review/agent-prompt.ts +++ b/packages/cli/src/commands/review/agent-prompt.ts @@ -1805,11 +1805,13 @@ export function buildRoleBrief( `\`${wt}\`. Do not \`cd\` elsewhere and do not build the user's main checkout.`, ); } - // On a delta-scoped incremental round the probe's range must match the - // round's scope: test-efficacy recomputes its own diff as base..HEAD, and - // handed the merge base it would reverse hunks and delete mutants from - // commits an earlier round already reviewed — spending the probe budget - // out of scope and reporting survivors this round's diff never contains. + // On a narrowed incremental round the probe's range must cover the + // published scope: test-efficacy recomputes its own diff as base..HEAD. + // The published hunks are hunks of `diffBase..head` — the merge-base + // range the producer assembled them from — so that range covers every + // one of them and never a byte the PR's diff does not display; the + // anchor range, by contrast, can carry hunks an undo round netted out + // of the PR's diff, which no comment can anchor on. const inc = report.incremental as | { effective?: unknown; upToDate?: unknown; diffBase?: unknown } | undefined; diff --git a/packages/cli/src/commands/review/capture-local.ts b/packages/cli/src/commands/review/capture-local.ts index 71abad28ce..0505e3f26d 100644 --- a/packages/cli/src/commands/review/capture-local.ts +++ b/packages/cli/src/commands/review/capture-local.ts @@ -20,11 +20,7 @@ import type { CommandModule } from 'yargs'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; -import { - repoRelativeOf, - REVIEW_TMP_DIR, - tmpFile, -} from './lib/paths.js'; +import { repoRelativeOf, REVIEW_TMP_DIR, tmpFile } from './lib/paths.js'; import { safeTarget } from '../../utils/paths.js'; import { planEffortField } from './lib/effort.js'; import type { ReviewEffort } from './parse-args.js'; @@ -224,7 +220,7 @@ function runCaptureLocal(args: CaptureLocalArgs): void { // against the worktree and nothing else, and `git diff -M HEAD` renders a // move as delete + add rather than pairing it (measured — a staged move of // a 95%-similar file still comes back as two sections). So no local plan - // carries `renamedFrom` today. The line is here because the cost is one + // carries `renameFrom` today. The line is here because the cost is one // set union and the failure it prevents is silent, and because the moment // this capture grows a `--cached` range — the obvious next step for staged // review — renames appear and the anchor would be wrong without it. There @@ -233,8 +229,8 @@ function runCaptureLocal(args: CaptureLocalArgs): void { const planPaths = [ ...new Set( fullPlan.files.flatMap((f) => - f.renamedFrom && f.renamedFrom !== f.path - ? [f.path, f.renamedFrom] + f.renameFrom && f.renameFrom !== f.path + ? [f.path, f.renameFrom] : [f.path], ), ), diff --git a/packages/cli/src/commands/review/fetch-pr.test.ts b/packages/cli/src/commands/review/fetch-pr.test.ts index 9d1a66d8ba..1bb9c17484 100644 --- a/packages/cli/src/commands/review/fetch-pr.test.ts +++ b/packages/cli/src/commands/review/fetch-pr.test.ts @@ -14,7 +14,6 @@ import { computeDiffStats, isEmptyDiff, isCollapsedFromUpstream, - decodeWasLossy, resolveIncrementalAnchor, type AnchorProbe, } from './fetch-pr.js'; @@ -245,16 +244,11 @@ const producerMocks = vi.hoisted(() => ({ refExists: vi.fn((..._refs: unknown[]): boolean => false), releaseWorktree: vi.fn(() => ({ existed: false, freed: true })), gitOpt: vi.fn((..._args: string[]): string | null => null), - /** - * Per-test override for the status-preserving probe (`gitProbe`, imported - * as `gitExit`); null lets the default mapping run. The default cannot - * express the killed/spawn-failed shapes (no status at all, or an exit - * above 1), and a restoration probe that could not ANSWER is exactly that - * class. - */ - gitProbeOverride: null as - | ((...args: string[]) => { out: string | null; status: number | null }) - | null, + // The exit-status-aware probe as its own vi.fn: the default mapping (set + // in beforeEach) can only produce exit 0 and the DEFINITIVE no (exit 1), + // so a test that wants a git-surface-unavailable shape — an exit-128 + // fatal, a timeout kill's null status — overrides this. + gitExit: vi.fn(), statSync: vi.fn((path?: unknown): { mtimeMs: number } | undefined => String(path).endsWith('-fetch.json') || String(path).endsWith('fetch-report.json') @@ -266,7 +260,6 @@ const producerMocks = vi.hoisted(() => ({ (..._args: unknown[]): MergeBaseResult => ({ sha: null, baseFetchFailed: false, - probeUnavailable: false, }), ), // Defaults to the REAL implementation (captured by the module mock below); @@ -340,31 +333,7 @@ vi.mock('./lib/gh.js', async (importOriginal) => { vi.mock('./lib/git.js', () => ({ git: producerMocks.git, gitOpt: producerMocks.gitOpt, - // Moved out of fetch-pr.ts into lib/git.ts (capture-local reads it too), so - // the mock owes it an export. Expressed over the same `gitRaw` fixture the - // real one reads, rather than a constant: a report's line counts decide - // heaviness, and a stub returning 0 everywhere would make every plan light. - fileLineCount: (ref: string, path: string) => { - try { - const buf = producerMocks.gitRaw('show', `${ref}:${path}`); - if (!buf || buf.length === 0) return 0; - let n = 0; - for (const b of buf) if (b === 0x0a) n++; - return buf[buf.length - 1] === 0x0a ? n : n + 1; - } catch { - return 0; - } - }, - // The exit-code-aware probe, expressed in terms of the same mock: a null - // answer is the DEFINITIVE no (exit 1), which is what these fixtures mean. - // A test that wants the git-surface-unavailable shape overrides this. - gitProbe: (...args: string[]) => { - if (producerMocks.gitProbeOverride) { - return producerMocks.gitProbeOverride(...args); - } - const out = producerMocks.gitOpt(...args); - return { out, status: out === null ? 1 : 0 }; - }, + gitProbe: (...args: string[]) => producerMocks.gitExit(...args), gitRaw: producerMocks.gitRaw, gitWithInput: vi.fn((): string => ''), refExists: producerMocks.refExists, @@ -423,25 +392,6 @@ vi.mock('./lib/diff-plan.js', async (importOriginal) => { return { ...actual, buildDiffPlan: producerMocks.buildDiffPlan }; }); -/** - * The anchor RULING, without the scope it produced. - * - * `incremental` answers two questions in one object: MAY this anchor scope the - * round (`since`/`effective`/`reason`/`upToDate`/`diffBase`), and — when it - * may — WHICH files it scoped to (`scope`, `fullDiffPath`). Nearly every - * assertion below is about the first, and folding the second into their exact - * shapes would make each of them fail on any change to the widening. The - * scope has its own tests. - */ -function ruling(report: { incremental?: unknown }): Record { - const { - scope: _scope, - fullDiffPath: _fullDiffPath, - ...rest - } = (report.incremental ?? {}) as Record; - return rest; -} - describe('fetch-pr report assembly', () => { const savedEnv: { sessionId?: string; promptId?: string } = {}; @@ -460,12 +410,16 @@ describe('fetch-pr report assembly', () => { args[0] === 'rev-parse' ? 'f00df00df00d' : '', ); producerMocks.gitOpt.mockImplementation(() => null); - producerMocks.gitProbeOverride = null; + // The default exit-status mapping, expressed over gitOpt: a null answer + // is the DEFINITIVE no (exit 1), which is what these fixtures mean. + producerMocks.gitExit.mockImplementation((...args: string[]) => { + const out = producerMocks.gitOpt(...args); + return { out, status: out === null ? 1 : 0 }; + }); producerMocks.gitRaw.mockImplementation(() => Buffer.from('')); producerMocks.resolveMergeBase.mockImplementation(() => ({ sha: null, baseFetchFailed: false, - probeUnavailable: false, })); producerMocks.buildDiffPlan.mockImplementation((...a: unknown[]) => producerMocks.actualBuildDiffPlan(...a), @@ -709,7 +663,7 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockImplementation((...args: unknown[]) => { const probe = args[3] as { fetch: (r: string, b: string) => boolean }; const ok = probe.fetch('origin', 'v1.0'); - return { sha: null, baseFetchFailed: !ok, probeUnavailable: false }; + return { sha: null, baseFetchFailed: !ok }; }); const report = await reportFor({}); expect(report.baseFetchFailed).toBe(true); @@ -744,11 +698,7 @@ describe('fetch-pr report assembly', () => { }); producerMocks.resolveMergeBase.mockImplementation((...args: unknown[]) => { const probe = args[3] as { fetch: (r: string, b: string) => boolean }; - return { - sha: null, - baseFetchFailed: !probe.fetch('origin', 'v1.0'), - probeUnavailable: false, - }; + return { sha: null, baseFetchFailed: !probe.fetch('origin', 'v1.0') }; }); await reportFor({}); expect(checked).toContain('refs/remotes/origin/v1.0'); @@ -785,7 +735,7 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockImplementation((...args: unknown[]) => { const probe = args[3] as { fetch: (r: string, b: string) => boolean }; probe.fetch('origin', 'v1.0'); - return { sha: 'mb1', baseFetchFailed: false, probeUnavailable: false }; + return { sha: 'mb1', baseFetchFailed: false }; }); await reportFor({}); expect(fetched).toEqual([ @@ -811,7 +761,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: 'beef0000', baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockReturnValue( Buffer.from(makeDiff('src/huge.ts', 9000)), @@ -1194,140 +1143,85 @@ describe('fetch-pr report assembly', () => { const ANCHOR = 'a'.repeat(40); const BASE = 'b'.repeat(40); /** - * `anchor..head` for ONE coherent history, so the pair below can be read as - * a real round rather than two unrelated captures: + * ONE coherent history across TWO files, because narrowing is per FILE: * - * base [line, line2, tail] - * anchor [line, added, line2, tail] - * head [line, added, line2, bulk × 200, tail] + * base a.ts [line, line2, line3, ctx, ctx2] b.ts [x, y] + * anchor a.ts + `added` b.ts + `y2` + * head a.ts + 200 bulk lines b.ts unchanged since anchor * - * The old pair gave the same head commit two different trees — a 3-line - * file here and a 204-line one in FULL_DIFF — which no capture can produce, - * and which a later case extending either side would be written against. - */ - const DELTA_DIFF = [ - 'diff --git a/a.ts b/a.ts', - '--- a/a.ts', - '+++ b/a.ts', - '@@ -1,4 +1,204 @@', - ' line', - ' added', - ' line2', - ...Array.from({ length: 200 }, (_, i) => `+bulk ${i}`), - ' tail', - '', - ].join('\n'); - /** - * The PR's whole diff, of which DELTA_DIFF's hunk is a proper part — the - * ordinary shape of an incremental round. The containment check refuses a - * delta whose hunks this does NOT cover, so a fixture that means "a valid - * incremental round" has to supply it. + * The round touches only `a.ts`, so the published scope is `a.ts`'s section + * ENTIRE — both hunks, including the one the anchor round already covered — + * and `b.ts` is dropped. The saving is the untouched file; over-inclusion + * inside a touched file is the deliberate price of never dropping a hunk + * two independent Myers alignments place differently. */ const FULL_DIFF = [ 'diff --git a/a.ts b/a.ts', '--- a/a.ts', '+++ b/a.ts', - '@@ -1,3 +1,204 @@', + '@@ -1,3 +1,4 @@', ' line', '+added', ' line2', + ' line3', + '@@ -50,2 +51,202 @@', + ' ctx', ...Array.from({ length: 200 }, (_, i) => `+bulk ${i}`), - ' tail', - '', - ].join('\n'); - /** - * A TWO-file PR whose delta touches only the first — the shape that makes a - * slice smaller than the full range, and therefore the only shape in which - * "scoped" and "not scoped" are distinguishable at all. `ls-tree` is mocked - * per file so the restoration probe can be steered. - */ - const FULL_TWO = [ - 'diff --git a/a.ts b/a.ts', - '--- a/a.ts', - '+++ b/a.ts', - '@@ -1,2 +1,3 @@', - ' one', - '+two', - ' three', + ' ctx2', 'diff --git a/b.ts b/b.ts', '--- a/b.ts', '+++ b/b.ts', - '@@ -1,2 +1,3 @@', - ' alpha', - '+beta', - ' gamma', + '@@ -1,2 +1,2 @@', + ' x', + '+y2', '', ].join('\n'); - /** Just `a.ts`'s section of FULL_TWO, byte-for-byte. */ - const SLICE_A = FULL_TWO.split('diff --git a/b.ts')[0]; - /** - * Just `b.ts`'s section — the full range a repository ACTUALLY renders when - * `a.ts` is restored. A file whose tree entry is identical at both ends of - * the PR has no hunks there, so a fixture pairing "restored" with a section - * of its own describes a state git cannot produce. - */ - const FULL_B_ONLY = - 'diff --git a/b.ts' + FULL_TWO.split('diff --git a/b.ts')[1]; - const DELTA_A = [ + /** `anchor..head`: only `a.ts` changed since the anchor. */ + const DELTA_DIFF = [ 'diff --git a/a.ts b/a.ts', '--- a/a.ts', '+++ b/a.ts', - '@@ -1,2 +1,3 @@', - ' one', - '+two', - ' three', + '@@ -51,2 +51,202 @@', + ' ctx', + ...Array.from({ length: 200 }, (_, i) => `+bulk ${i}`), + ' ctx2', '', ].join('\n'); - /** - * A rename×restored history the two batteries below read: - * - * base { a.ts: A, q.ts: Q } - * anchor { a.ts: A′ } (round 1 edited a.ts, deleted q.ts) - * head { q.ts: Q } (the fix round moved a.ts onto q.ts) - * - * `--find-renames` renders `anchor..head` as one rename section labelled - * with the NEW name, and the net `merge-base..head` diff renders the - * source as a plain deletion — the restored target contributes nothing on - * either side, so those hunks sit under no other name. - */ - const DELTA_RENAME = [ - 'diff --git a/a.ts b/q.ts', - 'similarity index 90%', - 'rename from a.ts', - 'rename to q.ts', - '--- a/a.ts', - '+++ b/q.ts', - '@@ -1,1 +1,1 @@', - '-A prime', - '+Q', - '', - ].join('\n'); - const FULL_SOURCE_DELETED = [ + /** `a.ts`'s section whole; `b.ts` gone. */ + const NARROWED = [ 'diff --git a/a.ts b/a.ts', - 'deleted file mode 100644', '--- a/a.ts', - '+++ /dev/null', - '@@ -1,1 +0,0 @@', - '-A prime', + '+++ b/a.ts', + '@@ -1,3 +1,4 @@', + ' line', + '+added', + ' line2', + ' line3', + '@@ -50,2 +51,202 @@', + ' ctx', + ...Array.from({ length: 200 }, (_, i) => `+bulk ${i}`), + ' ctx2', '', ].join('\n'); - /** ls-tree steering for that history: q.ts restored (identical entries on - * both sides), a.ts present at base and absent at head (the `` answer is - * "no such entry" — an ANSWER, not a failure). */ - function renameHistoryProbes() { - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) { - if (args.includes('q.ts')) return '100644 blob deadbeef\tq.ts'; - if (args.includes('a.ts')) { - return args.includes(BASE) ? '100644 blob cafe\ta.ts' : ''; - } - return ''; - } - return null; - }); - } + + /** + * The scope the default fixture yields: `a.ts` moved since the anchor, and + * `b.ts` is a clean source file the widening weighed and passed over — it + * imports nothing that changed. + */ + const SCOPE_A = { + anchor: ANCHOR, + deltaFiles: ['a.ts'], + interaction: [], + contextFileCount: 1, + }; + /** Anchor at the merge base: the delta is the full range, so both moved. */ + const SCOPE_AB = { + anchor: BASE, + deltaFiles: ['a.ts', 'b.ts'], + interaction: [], + contextFileCount: 0, + }; /** Serve the delta for `ANCHOR..head` and the full range for `BASE..head`. */ function servesBothRanges(full = FULL_DIFF, delta = DELTA_DIFF) { @@ -1347,18 +1241,52 @@ describe('fetch-pr report assembly', () => { ? '' : args[0] === 'rev-parse' ? ANCHOR - : args.includes('ls-tree') - ? '' // answered: the entry is absent from that tree - : null, + : null, ); } + it('pulls a still-clean importer of a changed file back into the scope', async () => { + // The narrowing is sound in one direction only. `b.ts` has not changed + // since the anchor, so the delta capture cannot show it and the narrowed + // scope drops its section — but the round before cleared it against + // `a.ts`'s OLD shape, and (b.ts@head × a.ts@head) is a pairing no round + // has seen. Left out, that seam retires the moment the ledger certifies + // this head as the next anchor. + anchorIsValid(); + producerMocks.resolveMergeBase.mockReturnValue({ + sha: BASE, + baseFetchFailed: false, + }); + servesBothRanges(); + producerMocks.readFileSync.mockImplementation((path?: unknown) => { + if (String(path).endsWith('b.ts')) return "import './a.js';\n"; + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const report = await reportFor({ since: ANCHOR }); + + expect(report.incremental).toEqual({ + since: ANCHOR, + effective: true, + diffBase: BASE, + scope: { + anchor: ANCHOR, + deltaFiles: ['a.ts'], + interaction: [{ path: 'b.ts', importsChanged: ['a.ts'] }], + contextFileCount: 0, + }, + }); + // …and the widened file is PUBLISHED, carrying its own full-range hunks: + // the plan naming it is worth nothing if no chunk holds its diff. + expect(writtenDiff()).toContain('b/a.ts'); + expect(writtenDiff()).toContain('b/b.ts'); + }); + it('scopes the plan to a valid anchor and suppresses the full-range flags', async () => { anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); // Advertised stat large enough that an ungated collapse ratio WOULD fire @@ -1379,38 +1307,22 @@ describe('fetch-pr report assembly', () => { }), ); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, + scope: SCOPE_A, + diffBase: BASE, }); expect(report.diffPath).not.toBeNull(); // The DISK payload, not just the report: a write unpaired from the text // the report describes hands every agent a diff whose chunks and // diffBase advertise something else — the same mismatch class as the // diffPath leak this PR shipped and fixed. - // - // And what is published is the PR's OWN section for the scoped file - // (`@@ -1,3`), not the delta's re-capture of it (`@@ -1,4`). That is the - // property the slice buys: every hunk an agent can anchor a comment on - // exists byte-identically in the diff GitHub renders, so an anchored - // comment cannot 422 and take the whole Create Review call with it. The - // delta is read for WHICH files changed, never for their hunks. - expect(writtenDiff()).toBe(FULL_DIFF); - expect(writtenDiff()).not.toBe(DELTA_DIFF); + expect(writtenDiff()).toBe(NARROWED); expect(report.diffPathAbsolute).toBe(resolve(report.diffPath as string)); - // The plan is the SLICE's — here the whole full range, because the one - // changed file is the PR's only file. `scopes to the delta's files` below - // is where a slice smaller than the full range is pinned. - expect(report.diffLines).toBe(FULL_DIFF.trimEnd().split('\n').length); - // The scope block names what it kept, and the superseded full range stays - // on disk for the steps that want the whole PR. - expect( - (report.incremental as { scope?: { deltaFiles?: string[] } }).scope - ?.deltaFiles, - ).toEqual(['a.ts']); - expect( - (report.incremental as { fullDiffPath?: string }).fullDiffPath, - ).toMatch(/diff-full\.txt$/); + // …and the PLAN is the delta's, not the full range's: a re-plan over + // fullText would pair a 200-line plan with an 8-line published diff. + expect(report.diffLines).toBe(NARROWED.trimEnd().split('\n').length); expect(report.emptyDiff).toBeUndefined(); expect(report.collapsedFromUpstream).toBeUndefined(); // The probe wiring, pinned by invocation shape: a transposed @@ -1437,563 +1349,6 @@ describe('fetch-pr report assembly', () => { ]); }); - it('scopes the slice to the delta files, keeping the PR\u2019s own bytes', async () => { - // The core of an incremental round: `b.ts` is in the PR but not in the - // delta and nothing imports `a.ts`, so it is out of scope — and what is - // published is `a.ts`'s section of the PR's own diff, byte-identical. - anchorIsValid(); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_TWO, DELTA_A); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ - since: ANCHOR, - effective: true, - }); - expect(writtenDiff()).toBe(SLICE_A); - expect(writtenDiff()).not.toContain('b.ts'); - // The PLAN is built over the slice, not over the full range. The sibling - // test pins this only where the slice IS the full range, so planning over - // `fullText` while publishing the slice was indistinguishable there — - // and a plan describing hunks the published file does not contain sends - // every agent to line ranges that are not in their diff. - expect(report.diffLines).toBe(SLICE_A.trimEnd().split('\n').length); - expect(report.files.map((f: { path: string }) => f.path)).toEqual(['a.ts']); - const scope = (report.incremental as { scope: Record }) - .scope; - expect(scope).toMatchObject({ - anchor: ANCHOR, - deltaFiles: ['a.ts'], - interaction: [], - restoredFileCount: 0, - }); - // `b.ts` was CONSIDERED and left out — counted, so a reader can tell - // "nothing imports the change" from "there was nothing to consider". - expect(scope['contextFileCount']).toBe(1); - }); - - it('widens one import hop: a still-clean importer re-enters the scope', async () => { - // `b.ts` has no change of its own, so no delta capture can show it — and - // that is exactly why it needs reviewing. Round 1 cleared it against - // `a.ts`'s OLD shape; (b.ts@head \u00d7 a.ts@head) is a pairing no round - // has seen. The slice is what makes this expressible: `b.ts` is pulled in - // carrying its own full-range hunks. - anchorIsValid(); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_TWO, DELTA_A); - producerMocks.readFileSync.mockImplementation((path?: unknown) => { - if (String(path).endsWith('b.ts')) return "import './a.js';\n"; - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const report = await reportFor({ since: ANCHOR }); - expect(writtenDiff()).toBe(FULL_TWO); // both sections, in PR order - const scope = (report.incremental as { scope: Record }) - .scope; - expect(scope['deltaFiles']).toEqual(['a.ts']); - expect(scope['interaction']).toEqual([ - { path: 'b.ts', importsChanged: ['a.ts'] }, - ]); - // Pulled in, so no longer merely context. - expect(scope['contextFileCount']).toBe(0); - }); - - it('a file undone since the anchor owes no review but still moves its importers', async () => { - // `a.ts` is in the delta and its tree entry is identical at both ends of - // the PR: the fix round undid it. It has no hunks left to review, so it - // is not in `deltaFiles` — a plan naming a file with zero hunks sends - // agents hunting for scope that does not exist — but the undoing IS a - // change its importers were cleared against, so `b.ts` still enters. - anchorIsValid(); - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - // Same tree entry at base and head \u2014 restored. - if (args.includes('ls-tree') && args.includes('a.ts')) { - return '100644 blob deadbeef\ta.ts'; - } - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_B_ONLY, DELTA_A); - producerMocks.readFileSync.mockImplementation((path?: unknown) => { - if (String(path).endsWith('b.ts')) return "import './a.js';\n"; - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); - const report = await reportFor({ since: ANCHOR }); - const scope = (report.incremental as { scope: Record }) - .scope; - expect(scope['deltaFiles']).toEqual([]); - expect(scope['restoredFileCount']).toBe(1); - expect(scope['interaction']).toEqual([ - { path: 'b.ts', importsChanged: ['a.ts'] }, - ]); - // Only the importer's section is published. With an honest full range - // that is structural rather than a filter doing work — git renders no - // section for a file identical at both ends — so the load-bearing - // assertions are the three above, not this one. - expect(writtenDiff()).not.toContain('a/a.ts'); - expect(writtenDiff()).toContain('a/b.ts'); - }); - - it('the restoration probe compares WHOLE tree entries, mode included', () => { - // Both existing fixtures return the SAME ls-tree entry for every ref, so - // two mutants survived the whole suite: dropping the equality - // (`return b !== null && h !== null`) marks every live delta file - // restored and slices its section out, and comparing only the oid half - // reads a `chmod +x` with unchanged bytes as a restoration — while the - // mode-only section IS in the PR's diff and would go unreviewed. - // - // Steered per REF, which is what the two-mock fixture cannot express. - const entries = (base: string, head: string) => - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) { - return args.includes(BASE) ? base : head; - } - return null; - }); - - const restoredOf = async (base: string, head: string) => { - entries(base, head); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_TWO, DELTA_A); - const report = await reportFor({ since: ANCHOR }); - const scope = (report.incremental as { scope?: { deltaFiles: string[] } }) - .scope; - // `a.ts` out of deltaFiles ⇔ the probe called it restored. - return !(scope?.deltaFiles ?? []).includes('a.ts'); - }; - - return (async () => { - // Identical entries — restored. - expect(await restoredOf('100644 blob dead', '100644 blob dead')).toBe( - true, - ); - // Different OIDs — a live change, not a restoration. - expect(await restoredOf('100644 blob dead', '100644 blob beef')).toBe( - false, - ); - // Same bytes, MODE flipped — `chmod +x`. Not a restoration: its - // mode-only section is in the PR's diff and owes a review. - expect(await restoredOf('100644 blob dead', '100755 blob dead')).toBe( - false, - ); - })(); - }); - - it('stops the round when everything changed since the anchor was undone', async () => { - // Every delta file restored and nothing imports them: there is genuinely - // nothing to re-review. Same outcome as an empty delta \u2014 `upToDate`, - // which the skill turns into "No new changes since last review" \u2014 - // and the full range is still published for the flows that continue - // anyway (a model change, --comment). - anchorIsValid(); - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) return '100644 blob deadbeef\tpath'; - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_TWO, DELTA_A); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ - since: ANCHOR, - effective: true, - upToDate: true, - }); - expect(writtenDiff()).toBe(FULL_TWO); - }); - - it('keeps a restored rename TARGET\u2019s source-deletion hunks in scope', async () => { - // A rename section in the delta contributes only its NEW-side path, and - // a target restored to the merge-base state drops out of `deltaLive` — - // the lineage check used to pass vacuously there and the round stopped - // `nothing-new` while the source's net-deletion hunks, content no round - // ever saw, retired at the next re-anchor. The deleted source must ride - // beside the restored target's name so the check can see it. - renameHistoryProbes(); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_SOURCE_DELETED, DELTA_RENAME); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ since: ANCHOR, effective: true }); - const scope = (report.incremental as { scope: Record }) - .scope; - expect(scope['deltaFiles']).toEqual(['a.ts']); - expect(scope['restoredFileCount']).toBe(1); - // Published is the source's own full-range deletion section. - expect(writtenDiff()).toBe(FULL_SOURCE_DELETED); - }); - - it('a LIVE rename target still owes its source when the ranges STRADDLE', async () => { - // Rename detection is a similarity threshold, and the two ranges compare - // different pairs of blobs — so the delta can pair a rename that the full - // range does not. Round 1 rewrites `a.ts` past the threshold; round 2 - // moves it to `q.ts` with an edit. `anchor..head` renders one rename - // section; `merge-base..head` renders a plain deletion of `a.ts` beside - // an addition of `q.ts`. - // - // Keying the ride-along on "the target was restored" missed this - // entirely: the target is LIVE, so nothing rode along, the lineage check - // passed on `q.ts` alone, and the slice published only the addition — - // `a.ts`'s deletion hunks, which no round had seen, retired at the next - // re-anchor. The rule is about the FULL range: does it carry a section - // under the source's name? - const FULL_STRADDLE = [ - 'diff --git a/a.ts b/a.ts', - 'deleted file mode 100644', - '--- a/a.ts', - '+++ /dev/null', - '@@ -1,1 +0,0 @@', - '-A original', - 'diff --git a/q.ts b/q.ts', - 'new file mode 100644', - '--- /dev/null', - '+++ b/q.ts', - '@@ -0,0 +1,1 @@', - '+Q', - '', - ].join('\n'); - // Both live: q.ts differs across the range, a.ts is gone at head. - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) { - if (args.includes('q.ts')) { - return args.includes(BASE) ? '' : '100644 blob feed\tq.ts'; - } - if (args.includes('a.ts')) { - return args.includes(BASE) ? '100644 blob cafe\ta.ts' : ''; - } - return ''; - } - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_STRADDLE, DELTA_RENAME); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ since: ANCHOR, effective: true }); - const scope = (report.incremental as { scope: Record }) - .scope; - // BOTH halves are in scope — the source is not restored here, it is a - // live deletion the full range names on its own. - expect((scope['deltaFiles'] as string[]).sort()).toEqual(['a.ts', 'q.ts']); - // …and the published slice carries the deletion hunks that used to drop. - expect(writtenDiff()).toContain('-A original'); - expect(writtenDiff()).toContain('+Q'); - }); - - it('a LIVE rename rides the carrier section when the ranges pair DIFFERENT targets', async () => { - // The ride-along asks whether the full range carries a section under the - // SOURCE name. The two ranges can also pair the same deletion with - // DIFFERENT targets: base has `a.ts = A`; the anchor round rewrites - // `a.ts` to `A′` and adds `r.ts ≈ A`; the fix round deletes `a.ts` - // and adds `q.ts` as an exact copy of `A′`. `anchor..head` pairs - // `a.ts→q.ts` (100% similarity, zero hunks); `merge-base..head` pairs - // `a.ts→r.ts` and renders `q.ts` as a plain addition. The source's net - // hunks then sit under a section labelled `r.ts` — nothing names `a.ts`, - // so the source-name guard saw nothing to ride along, the lineage check - // passed on `q.ts` alone, and the slice retired hunks no round had - // reviewed at the next re-anchor. The carrier section must ride instead. - const FULL_SPLIT_TARGETS = [ - 'diff --git a/a.ts b/r.ts', - 'similarity index 96%', - 'rename from a.ts', - 'rename to r.ts', - '--- a/a.ts', - '+++ b/r.ts', - '@@ -1,2 +1,2 @@', - ' one', - '-kept', - '+kept, edited', - 'diff --git a/q.ts b/q.ts', - 'new file mode 100644', - '--- /dev/null', - '+++ b/q.ts', - '@@ -0,0 +1,2 @@', - '+one prime', - '+kept', - '', - ].join('\n'); - const DELTA_RENAME_CLEAN = [ - 'diff --git a/a.ts b/q.ts', - 'similarity index 100%', - 'rename from a.ts', - 'rename to q.ts', - '', - ].join('\n'); - // q.ts and r.ts are both additions since the base: absent there, live at - // the head — one-sided absence is not a restoration. - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) { - if (args.includes('q.ts') || args.includes('r.ts')) { - const name = args.includes('q.ts') ? 'q.ts' : 'r.ts'; - return args.includes(BASE) ? '' : `100644 blob feed\t${name}`; - } - return ''; - } - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_SPLIT_TARGETS, DELTA_RENAME_CLEAN); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ since: ANCHOR, effective: true }); - const scope = (report.incremental as { scope: Record }) - .scope; - // The carrier rides beside the delta's own target. - expect((scope['deltaFiles'] as string[]).sort()).toEqual(['q.ts', 'r.ts']); - // …and the published slice keeps the hunks the full range labelled with - // the other target's name. - expect(writtenDiff()).toContain('rename to r.ts'); - expect(writtenDiff()).toContain('-kept'); - expect(writtenDiff()).toContain('+one prime'); - }); - - it('a LIVE rename target still scopes by its new name only', async () => { - // Control for the test above: when the target is NOT restored, the - // rename's net hunks sit under the new-side section and scoping is - // unchanged — the source's name must not be added then (it is absent at - // head, and would demand a section the full diff labels with the NEW - // name, refusing a round that scopes cleanly today). - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - if (args.includes('ls-tree')) { - // q.ts differs between base and head — a live change, no restoration. - if (args.includes('q.ts')) { - return args.includes(BASE) - ? '100644 blob dead\tq.ts' - : '100644 blob beef\tq.ts'; - } - if (args.includes('a.ts')) { - return args.includes(BASE) ? '100644 blob cafe\ta.ts' : ''; - } - return ''; - } - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - const FULL_RENAME = [ - 'diff --git a/a.ts b/q.ts', - 'similarity index 90%', - 'rename from a.ts', - 'rename to q.ts', - '--- a/a.ts', - '+++ b/q.ts', - '@@ -1,1 +1,1 @@', - '-A', - '+Q', - '', - ].join('\n'); - servesBothRanges(FULL_RENAME, DELTA_RENAME); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ since: ANCHOR, effective: true }); - const scope = (report.incremental as { scope: Record }) - .scope; - expect(scope['deltaFiles']).toEqual(['q.ts']); - expect(scope['restoredFileCount']).toBe(0); - expect(writtenDiff()).toBe(FULL_RENAME); - }); - - it('files an unanswerable restoration probe as retryable infrastructure', async () => { - // One transient ls-tree failure (a timeout kill, a spawn failure) over a - // genuinely restored delta file must not read as "the entry changed": - // that puts the file in `deltaLive`, finds no section under its name, - // and refuses `lineage-unfollowable` — a DETERMINISTIC reason the - // recovery flow never retries — for what is a retryable infrastructure - // fault, the exact conflation the gitProbe {out, status} split exists - // to forbid. The unanswerable probe demotes to `base-untrusted`, the - // retryable class. - producerMocks.gitOpt.mockImplementation((...args: string[]) => { - if (args[0] === 'cat-file' || args[0] === 'merge-base') return ''; - if (args[0] === 'rev-parse') return ANCHOR; - // The kill shape on the OLD seam as well: gitOpt null is what the - // pre-status probe saw on a kill (and what the default mapping files - // as exit 1), so the pre-fix code reads this exact fixture as - // "changed" and refuses lineage-unfollowable. - return null; - }); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - servesBothRanges(FULL_SOURCE_DELETED, DELTA_RENAME); - // The kill shape on the status-preserving seam: no status at all (the - // 120s timeout ends in SIGTERM — execFileSync throws with null status). - producerMocks.gitProbeOverride = (...args: string[]) => { - if (args.includes('ls-tree')) return { out: null, status: null }; - const out = producerMocks.gitOpt(...args); - return { out, status: out === null ? 1 : 0 }; - }; - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ - since: ANCHOR, - effective: false, - reason: 'base-untrusted', - }); - // The round still reviews — the full range. - expect(report.diffPath).not.toBeNull(); - expect(writtenDiff()).toBe(FULL_SOURCE_DELETED); - }); - - it('a LITERAL U+FFFD in the content is ordinary, not a lossy decode', () => { - // The guard measures the DECODE, not the decoded text. Scanning the text - // for U+FFFD cannot tell a substitution from the code point itself, and - // the code point is ordinary content — this repository holds four - // literal ones in source. A delta touching any of them, even as context, - // used to demote the round to a reason filed under "deterministic for the - // same sha and must NOT be retried", so that PR paid a full review every - // round afterwards under a cause that had not happened. - // - // Only INVALID bytes produce a substitution, and only substitution - // creates the name collision this guards. - const legitimate = Buffer.from('a � b', 'utf8'); - expect(legitimate.includes(0xef)).toBe(true); // the code point IS present - expect(decodeWasLossy(legitimate)).toBe(false); - - // An invalid byte is lossy. - expect(decodeWasLossy(Buffer.from([0x61, 0xe9, 0x62]))).toBe(true); - // …and a truncated multi-byte sequence, the other shape a capture cut - // mid-character produces. - expect(decodeWasLossy(Buffer.from([0xe4, 0xb8]))).toBe(true); - - // LENGTH-PRESERVING substitutions, which a byte-length round-trip calls - // clean: Node emits one U+FFFD per maximal ill-formed subpart, and a - // 3-byte subpart substitutes to a 3-byte replacement character. These are - // exactly the shape a capture cut mid-character produces, and they are - // the ones that collide two filenames onto one string. - expect(decodeWasLossy(Buffer.from([0xf0, 0x9f, 0x98]))).toBe(true); - expect(decodeWasLossy(Buffer.from([0xf0, 0x9f, 0x98, 0x41]))).toBe(true); - expect(decodeWasLossy(Buffer.from([0xf4, 0x8f, 0xbf]))).toBe(true); - expect(decodeWasLossy(Buffer.from([0x61, 0xf1, 0x80, 0x80, 0x62]))).toBe( - true, - ); - // Ordinary multi-byte content is clean. - expect(decodeWasLossy(Buffer.from('héllo 世界', 'utf8'))).toBe(false); - }); - - it('scopes a capture whose CONTENT holds a literal U+FFFD', async () => { - // The end-to-end half of the guard's contract. Four files in this - // repository carry a literal U+FFFD in source, so a delta touching any of - // them — even as context — met a guard that scanned the decoded text and - // could not tell the character from a substitution. The round demoted to - // `containment-unverified`, which the recovery contract files under - // "deterministic for the same sha and must NOT be retried", so that PR - // paid a full review every round afterwards under a cause that had not - // happened. - anchorIsValid(); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - const withChar = (body: string) => - Buffer.from( - [ - 'diff --git a/a.ts b/a.ts', - '--- a/a.ts', - '+++ b/a.ts', - '@@ -1,2 +1,3 @@', - ' one', - `+${body}`, - ' three', - '', - ].join('\n'), - 'utf8', - ); - // The code point itself, as ordinary content — valid UTF-8 throughout. - const CLEAN = withChar("const replacement = '\uFFFD';"); - producerMocks.gitRaw.mockImplementation((...args: string[]) => - args.includes(`${ANCHOR}..f00df00df00d`) || - args.includes(`${BASE}..f00df00df00d`) - ? CLEAN - : Buffer.from(''), - ); - const report = await reportFor({ since: ANCHOR }); - // Scoped, not demoted. - expect(ruling(report)).toEqual({ since: ANCHOR, effective: true }); - expect(writtenDiff()).toContain('\uFFFD'); - }); - - it('refuses to scope on a lossily decoded capture', async () => { - // The containment battery this slicing retired pinned it: invalid-UTF-8 - // bytes decode onto U+FFFD, and two filenames differing only in such a - // byte COLLIDE — scope membership decided on the collided strings - // republishes a sibling's already-certified hunks. The scope ruling - // fails closed to `containment-unverified` (full review) instead; the - // round's published bytes stay raw, so nothing is lost by falling back. - anchorIsValid(); - producerMocks.resolveMergeBase.mockReturnValue({ - sha: BASE, - baseFetchFailed: false, - probeUnavailable: false, - }); - const LOSSY = Buffer.concat([ - Buffer.from('diff --git a/data_'), - Buffer.from([0xe9]), - Buffer.from('.log b/data_'), - Buffer.from([0xe9]), - Buffer.from('.log\n--- a/data_'), - Buffer.from([0xe9]), - Buffer.from('.log\n+++ b/data_'), - Buffer.from([0xe9]), - Buffer.from('.log\n@@ -1,1 +1,2 @@\n one\n+two\n'), - ]); - producerMocks.gitRaw.mockImplementation((...args: string[]) => - args.includes(`${ANCHOR}..f00df00df00d`) - ? LOSSY - : args.includes(`${BASE}..f00df00df00d`) - ? LOSSY - : Buffer.from(''), - ); - const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ - since: ANCHOR, - effective: false, - reason: 'containment-unverified', - }); - expect(report.diffPath).not.toBeNull(); - }); - it('refuses an anchor another identity certified, before touching history', async () => { // "Clean up to this sha" is the recorded identity's verdict, and this // command validates an anchor against the HISTORY, never against who @@ -2008,7 +1363,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); @@ -2017,7 +1371,7 @@ describe('fetch-pr report assembly', () => { since: ANCHOR, sinceModel: 'fixture-model@9f8e7d6c', }); - expect(ruling(other)).toEqual({ + expect(other.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'cross-model-anchor', @@ -2034,7 +1388,7 @@ describe('fetch-pr report assembly', () => { // An anchor nobody certified (a cache written before the field) is a // mismatch, not a pass. expect( - ruling(await reportFor({ since: ANCHOR, sinceModel: undefined })), + (await reportFor({ since: ANCHOR, sinceModel: undefined })).incremental, ).toEqual({ since: ANCHOR, effective: false, @@ -2043,9 +1397,11 @@ describe('fetch-pr report assembly', () => { // …and the matching identity scopes, which is what makes the refusals // above about the gate rather than about the anchor. - expect(ruling(await reportFor({ since: ANCHOR }))).toEqual({ + expect((await reportFor({ since: ANCHOR })).incremental).toEqual({ since: ANCHOR, effective: true, + scope: SCOPE_A, + diffBase: BASE, }); }); @@ -2054,27 +1410,26 @@ describe('fetch-pr report assembly', () => { // array — the recovery flow produces one — and the array stringifies to // "shaA,shaB", which the hex gate refuses with zero git probes. And the // ruling must scope from what rev-parse RESOLVED, not from the string - // that came in: `diffBase` is welded into Agent 7's `--base`, where an - // abbreviation is ambiguous once the repo grows. + // that came in: the delta capture is keyed on the resolved sha, where + // an abbreviation is ambiguous once the repo grows. producerMocks.gitOpt.mockImplementation((...args: string[]) => args[0] === 'cat-file' || args[0] === 'merge-base' ? '' : args[0] === 'rev-parse' ? ANCHOR // the full sha for the abbreviation - : args.includes('ls-tree') - ? '' // answered: the entry is absent from that tree - : null, + : null, ); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: ['0'.repeat(40), 'abc1234'] }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: 'abc1234', effective: true, + scope: SCOPE_A, + diffBase: BASE, }); // The probes ran against the LAST value, not the first or the join. expect(producerMocks.gitOpt.mock.calls).toContainEqual([ @@ -2087,62 +1442,46 @@ describe('fetch-pr report assembly', () => { it('still flags an emptied PR on a delta round — the full range rules it', async () => { // The PR collapses between rounds (a revert, or the work landing in the // base another way): the full range is empty while `anchor..head` is - // not. Both guards fire, and both matter — there is no section of the - // PR's own diff for the changed file to be sliced from (so the anchor is - // refused rather than scoped), and the published full range is empty (so - // the skill stops and recommends close-as-superseded instead of reviewing - // hunks GitHub's empty PR diff does not contain, where one anchored - // comment 422s the whole review). - // - // `lineage-unfollowable` is the name for that under slicing, where the - // pre-slice code said `hunks-outside-pr-diff`: the delta is no longer - // checked for containment — it cannot fail containment, because it is - // never published — so what refuses here is the file having no section - // to slice. Same class, same fallback, and the reason still names a - // deterministic cause the recovery flow must not retry. + // not. Both guards fire, and both matter — the delta's hunks are not in + // the PR's diff (so the anchor is refused rather than scoped), and the + // published full range is empty (so the skill stops and recommends + // close-as-superseded instead of reviewing hunks GitHub's empty PR diff + // does not contain, where one anchored comment 422s the whole review). anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(''); const report = await reportFor({ since: ANCHOR }); expect(report.emptyDiff).toBe(true); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, - reason: 'lineage-unfollowable', + reason: 'nothing-to-narrow', }); // A base resolved from a possibly stale local ref cannot rule it — the // same fail-closed conjunct the text path has always had. producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: true, - probeUnavailable: false, }); expect((await reportFor({ since: ANCHOR })).emptyDiff).toBeUndefined(); }); - it('reviews an "undo per feedback" file at its FULL-RANGE hunks', async () => { - // The case that used to cost the whole round. An "undo per feedback" - // commit reverts some of the previous round's lines back to base - // content: those lines are changed in `anchor..head` and unchanged in - // `base..head`, so a delta capture carries hunks the PR's own diff does - // not contain — and a comment anchored on one 422s the entire Create - // Review call. Ancestry cannot see it; the anchor is a perfectly good - // ancestor. The pre-slice code therefore refused the anchor outright - // (`hunks-outside-pr-diff`) and re-reviewed the whole PR. - // - // Slicing dissolves it. The delta says WHICH file changed; the hunks come - // from the PR's own diff, where the reverted lines simply are not. So the - // round stays incremental, reviews `a.ts` at the shape GitHub renders, - // and every anchor it can produce is one GitHub accepts. + it('publishes the full section when the delta carries hunks the PR diff does not contain', async () => { + // An "undo per feedback" commit reverts some of the previous round's + // lines back to base content: those lines are changed in `anchor..head` + // and unchanged in `base..head`. Ancestry cannot see it — the anchor is + // a perfectly good ancestor — and the revert hunk is corroborated by no + // full hunk, so the join fails closed and publishes the section whole. + // The scope is assembled from the PR's own diff either way, so a + // comment anchored on any hunk of it is a comment on a line GitHub's + // PR diff displays. anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); const REVERT_DELTA = [ 'diff --git a/a.ts b/a.ts', @@ -2155,17 +1494,22 @@ describe('fetch-pr report assembly', () => { ].join('\n'); servesBothRanges(FULL_DIFF, REVERT_DELTA); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, + scope: SCOPE_A, + diffBase: BASE, }); - // The published bytes are the PR's own section for `a.ts`, so the - // `-experiment/+original` pair the delta carried — the pair that would - // have 422'd — is nowhere in what agents read. + // The round reviews the PR's own diff — and the FILE agents read must + // be that diff, never the delta: a publish left at capture time would + // hand them hunks GitHub's PR diff does not display. expect(report.diffPath).not.toBeNull(); expect(report.diffLines).toBeGreaterThan(0); - expect(writtenDiff()).toBe(FULL_DIFF); - expect(writtenDiff()).not.toContain('-experiment'); + expect(writtenDiff()).toBe(NARROWED); + // `a.ts`'s section ENTIRE — the revert's own hunks are absent because the + // PR's diff never displays them, which is exactly why they are not + // reviewable. `b.ts`, untouched this round, is what the narrowing drops. + expect(writtenDiff()).not.toContain('b.ts'); // `read_file` rejects a relative path, so every agent dereferences this // one — a relative leak fails the whole fan-out. expect(report.diffPathAbsolute).toBe(resolve(report.diffPath as string)); @@ -2181,7 +1525,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => { if (args.includes(`${BASE}..f00df00df00d`)) throw new Error('timed out'); @@ -2191,14 +1534,17 @@ describe('fetch-pr report assembly', () => { // The reason names the CAUSE and keeps naming it: the capture threw. // Whether a plan exists is `diffPath`, reported separately — one field // meaning both is what used to rename this into the retryable class. - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', }); expect(report.diffPath).toBeNull(); - // What this pins beyond the reason: the delta did NOT become the scope. - expect(writtenDiff()).not.toBe(DELTA_DIFF); + // What this pins beyond the reason: NOTHING was written. The only + // wrongful write a fail-open producer can produce here is the delta it + // did receive — a NARROWED cannot exist, because narrowing assembles + // from the full capture, which threw. + expect(writtenDiff()).toBeNull(); expect( producerMocks.writeStderrLine.mock.calls .map((c) => String(c[0])) @@ -2206,31 +1552,28 @@ describe('fetch-pr report assembly', () => { ).toContain('capture-failed'); }); - it('names an UNRULEABLE oracle apart from a disproved delta', async () => { - // A path the parser cannot name leaves the oracle unavailable; saying - // `hunks-outside-pr-diff` there asserts a containment failure that was - // never established, and steers recovery on a false reason. + it('narrows to nothing when the delta capture does not parse', async () => { + // There is no oracle to be unavailable any more, so the old split between + // "containment disproved" and "containment unruleable" is gone with it: a + // delta the parser cannot read yields no ranges, nothing overlaps, and the + // round keeps the full range under the one reason that names that. anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); - // Not a diff at all — a capture that returned an error stream, say. The - // delta is read for one fact, the list of changed files, and a stream - // that names none leaves that list empty: nothing to scope to, and no - // basis to claim the round has nothing new either, because the emptiness - // is the parser's not the tree's. + // Not a diff at all — the state where the oracle genuinely cannot rule + // (a capture that returned an error stream, say). Path shapes that used + // to land here are handled by the shared parser now. const UNPARSEABLE = 'fatal: bad revision\nnoise\n'; servesBothRanges(FULL_DIFF, UNPARSEABLE); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, - reason: 'containment-unverified', + reason: 'nothing-to-narrow', }); - // …and the round reviews the PR's own diff, the fallback every refusal - // lands on. + // …and the round still reviews the PR's own diff. expect(writtenDiff()).toBe(FULL_DIFF); }); @@ -2242,11 +1585,10 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: true, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'base-untrusted', @@ -2254,73 +1596,131 @@ describe('fetch-pr report assembly', () => { expect(report.diffPath).not.toBeNull(); }); - it('splits a base-free round by WHY there is no base — retryable or not', async () => { - // This used to scope, on the reasoning that the delta range needs no base - // and so a deleted or renamed base branch should not cost a valid anchor - // its scope. The capture reasoning is right; the SCOPE reasoning is not. - // With no base there is nothing for the slice to come FROM, so the round - // reviews the full range — but WHICH reason it reports decides whether - // the recovery flow ever retries the anchor, and the two causes are not - // the same class. + it('splits a base-free round by WHY there is no base', async () => { + // No base at all used to scope anyway, on the reasoning that the delta + // range needs no base. The capture reasoning is right; the SCOPE + // reasoning is not — with no PR diff there is nothing to narrow from. + // But `mergeBaseSha === null` has two causes and only one is retryable, + // which is the distinction SKILL.md's recovery paragraph already draws + // and this pair holds the code to. + // + // The fetch FAILED: infrastructure, and the re-run re-runs the component + // that failed, so the reason must be the retryable one. anchorIsValid(); - servesBothRanges(); - - // The fetch FAILED: the anchor was never ruled invalid, and a re-run - // repeats exactly the component that failed. `base-untrusted` is the - // infrastructure-retryable name; reporting `containment-unverified` here - // files a transient blip under "deterministic for the same sha and must - // NOT be retried", so a CI checkout with a flappy base fetch pays a full - // review every round from then on — and the reason misnames the cause, - // because the delta read fine. producerMocks.resolveMergeBase.mockReturnValue({ sha: null, baseFetchFailed: true, - probeUnavailable: false, }); - const transient = await reportFor({ since: ANCHOR }); - expect(ruling(transient)).toEqual({ + servesBothRanges(); + const fetchFailed = await reportFor({ since: ANCHOR }); + expect(fetchFailed.incremental).toEqual({ since: ANCHOR, effective: false, - reason: 'base-untrusted', + reason: 'capture-failed', }); - // The base fetch worked and the merge-base PROBE could not answer — a - // 128, or the 120s timeout a large long-lived PR under CI load reaches. - // Nothing about the histories was established, so this is infrastructure - // like the fetch failure above, not the deterministic shape below. + // The fetch SUCCEEDED and `git merge-base` found no common ancestor — an + // unrelated-history PR. Nothing threw, and a re-run reproduces it exactly, + // so the reason is the deterministic one and the recovery flow must not + // spend a re-run on it. + vi.clearAllMocks(); + producerMocks.writeFileSync.mockImplementation(() => undefined); + anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: null, baseFetchFailed: false, - probeUnavailable: true, }); - expect(ruling(await reportFor({ since: ANCHOR }))).toEqual({ + servesBothRanges(); + const noAncestor = await reportFor({ since: ANCHOR }); + expect(noAncestor.incremental).toEqual({ since: ANCHOR, effective: false, - reason: 'base-untrusted', - }); - - // The fetch SUCCEEDED and `git merge-base` found no common ancestor at - // all — a cross-fork PR with unrelated history. A re-run reproduces that - // exactly, so it is the deterministic class and must not be retried. - producerMocks.resolveMergeBase.mockReturnValue({ - sha: null, - baseFetchFailed: false, - probeUnavailable: false, - }); - const permanent = await reportFor({ since: ANCHOR }); - expect(ruling(permanent)).toEqual({ - since: ANCHOR, - effective: false, - reason: 'containment-unverified', + reason: 'nothing-to-narrow', }); // Either way nothing is published, which is what a base-free round does // ANYWAY: with no merge base there is no full range either, and the // command already tells agents to fall back to running `git diff` - // themselves. So this costs no review that existed — it removes the one - // arm that shipped a scope no containment check had ever seen. - expect(transient.diffPath).toBeNull(); - expect(permanent.diffPath).toBeNull(); + // themselves. The reason is what differs, and it is what the recovery + // flow acts on. + expect(fetchFailed.diffPath).toBeNull(); + expect(noAncestor.diffPath).toBeNull(); + }); + + it('splits merge-base probe exits — only exit 1 is "no common ancestor"', async () => { + // The probe folded every non-zero `git merge-base` exit onto the same + // null, so the nothing-to-narrow arm stamped its deterministic reason + // over exit-128 fatals and the 120s timeout kill. Only exit 1 is "no + // common ancestor"; the rest are the surface, and they demote to the + // retryable class instead. + producerMocks.refExists.mockReturnValue(true); + // Drive the seam the way the real resolveMergeBase does, so it is the + // REAL probe's exit split — and its throw — that runs. + producerMocks.resolveMergeBase.mockImplementation((...args: unknown[]) => { + const probe = args[3] as { + fetch: (remote: string, ref: string) => boolean; + refExists: (ref: string) => boolean; + mergeBase: (a: string, b: string) => string | null; + }; + const baseFetchFailed = !probe.fetch('origin', 'main'); + const sha = probe.refExists('refs/remotes/origin/main') + ? probe.mergeBase('refs/remotes/origin/main', 'refs/heads/feat/x') + : null; + return { sha, baseFetchFailed }; + }); + const drive = (mergeBase: { + out: string | null; + status: number | null; + }) => { + producerMocks.gitOpt.mockImplementation((...args: string[]) => + args[0] === 'fetch' || + args[0] === 'cat-file' || + args[0] === 'merge-base' + ? '' + : args[0] === 'rev-parse' + ? ANCHOR + : null, + ); + producerMocks.gitExit.mockImplementation((...args: string[]) => { + // Match the SUBCOMMAND, not argv[0]: the probe prefixes `-c` + // config pins (`core.commitGraph=false`), so a predicate keyed on + // the first argument silently stops matching when one is added — + // and the mock then answers from the default mapping, which can + // only produce exit 0 and exit 1, quietly turning a surface failure + // into "no common ancestor". + if (args.includes('merge-base') && !args.includes('--is-ancestor')) { + return mergeBase; + } + const out = producerMocks.gitOpt(...args); + return { out, status: out === null ? 1 : 0 }; + }); + }; + servesBothRanges(); + // Exit 128 (a fatal) and the timeout kill (a null status) are the + // surface, not the history: the retryable reason, nothing published. + for (const mergeBase of [ + { out: null, status: 128 }, + { out: null, status: null }, + ]) { + drive(mergeBase); + const report = await reportFor({ since: ANCHOR }); + expect(report.incremental).toEqual({ + since: ANCHOR, + effective: false, + reason: 'capture-failed', + }); + expect(report.diffPath).toBeNull(); + expect(report.mergeBaseSha).toBeNull(); + expect(report.baseFetchFailed).toBe(false); + } + // Exit 1 alone is the deterministic member: no throw, the deterministic + // reason, and the probe answers null. + drive({ out: null, status: 1 }); + expect((await reportFor({ since: ANCHOR })).incremental).toEqual({ + since: ANCHOR, + effective: false, + reason: 'nothing-to-narrow', + }); }); it('keeps upToDate through a partition failure — the stop flow needs no plan', async () => { @@ -2331,7 +1731,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); // Empty delta → upToDate; the full range is what gets partitioned. servesBothRanges(FULL_DIFF, ''); @@ -2342,7 +1741,7 @@ describe('fetch-pr report assembly', () => { return producerMocks.actualBuildDiffPlan(text, 400); }); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, upToDate: true, @@ -2368,11 +1767,10 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: 'f00df00df00d' }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: 'f00df00df00d', effective: true, upToDate: true, @@ -2400,20 +1798,19 @@ describe('fetch-pr report assembly', () => { ? '' : args[0] === 'rev-parse' ? BASE // the anchor resolves to the merge base - : args.includes('ls-tree') - ? '' // answered: the entry is absent from that tree - : null, + : null, ); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: BASE }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: BASE, effective: true, + scope: SCOPE_AB, + diffBase: BASE, }); // Exactly one capture: the delta arm read no second range. const ranges = producerMocks.gitRaw.mock.calls.filter((c) => @@ -2431,7 +1828,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); // The fault must land on ANCESTRY: a blanket error makes `cat-file` @@ -2450,7 +1846,7 @@ describe('fetch-pr report assembly', () => { ); try { const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', @@ -2527,7 +1923,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const spy = vi @@ -2541,7 +1936,7 @@ describe('fetch-pr report assembly', () => { ); try { const report = await reportFor({ since: ANCHOR }); - expect({ what, ...ruling(report) }).toEqual({ + expect({ what, ...report.incremental }).toEqual({ what, since: ANCHOR, effective: false, @@ -2553,32 +1948,30 @@ describe('fetch-pr report assembly', () => { } }); - it("welds Agent 7's --base to the range the round actually PUBLISHED", async () => { - // The only test that crosses the producer→consumer seam, and slicing - // inverted what it must assert. Agent 7 recomputes its own diff as - // `base..HEAD`, so `--base` has to name the left side of the bytes the - // round published — and those are now sections of `merge-base..head`, - // not a capture of `anchor..head`. Welding the ANCHOR here would send - // the probe over hunks the round never reviewed while missing the ones - // it did: the exact error `diffBase` was introduced to prevent, arrived - // at from the other side. - // - // So the producer stops writing `diffBase` on a sliced round and the - // consumer falls back to `mergeBaseSha`, which is the correct answer. - // The fallback is not a degradation here — it is the answer. (A plan an - // older CLI wrote still carries `diffBase`, and the consumer still - // honours it, because a delta-range publish made it true there.) + it("welds Agent 7's --base to the range the published scope came from", async () => { + // The producer half of the producer→consumer seam, end to end: the REAL + // report the handler writes carries `diffBase: BASE` on an effective + // round, and the REAL brief builder welds `--base BASE`, never the + // ANCHOR — welding the anchor would send the probe over a range carrying + // hunks the PR's diff does not display (an undo round's reverted lines) + // and report survivors no comment can anchor on. The consumer half — + // reading `diffBase` at all, and the guards on it — is pinned where the + // two sources are distinguishable: agent-prompt's suite hand-builds a + // report whose `diffBase` differs from `mergeBaseSha` and fails a + // consumer that stops reading it. This fixture cannot distinguish them, + // because the producer writes the two equal by design. anchorIsValid(); producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, + scope: SCOPE_A, + diffBase: BASE, }); // The REAL brief builder, over the REAL report the handler just wrote. // The probe block is gated on a PR number and a plan path — the shape @@ -2603,7 +1996,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); // Advertised 900 against a full range of 4 changed lines: 4 × 4 ≤ 900, @@ -2623,17 +2015,14 @@ describe('fetch-pr report assembly', () => { ); const report = await reportFor({ since: ANCHOR }); // Still delta-scoped… - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, + scope: SCOPE_A, + diffBase: BASE, }); - // The slice of the PR's own diff, not the delta's re-capture of it. - expect(writtenDiff()).toBe(FULL_DIFF); - // …and the full-range fact is still reported. It is computed off - // `fullText` on every round, so a slice that happens to equal the full - // range here does not make the assertion vacuous: the mutant this test - // was written for suppresses the flag on delta-scoped rounds outright, - // and `effective: true` above is what makes this one of those. + expect(writtenDiff()).toBe(NARROWED); + // …and the full-range fact is still reported. expect(report.collapsedFromUpstream).toBe(true); }); @@ -2645,7 +2034,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: '' }); @@ -2668,14 +2056,13 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => { if (args.includes(`${BASE}..f00df00df00d`)) throw new Error('timed out'); return Buffer.from(''); }); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, upToDate: true, @@ -2694,7 +2081,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); producerMocks.buildDiffPlan.mockImplementation((text: unknown) => { @@ -2704,7 +2090,7 @@ describe('fetch-pr report assembly', () => { return producerMocks.actualBuildDiffPlan(text, 400); }); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'not-an-ancestor', @@ -2720,7 +2106,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); producerMocks.writeFileSync.mockImplementation((path: unknown) => { @@ -2738,7 +2123,7 @@ describe('fetch-pr report assembly', () => { // NOT empty: a mutant computing it from the published round state sees // an empty published diff here and would recommend closing a live PR. expect(report.emptyDiff).toBeUndefined(); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', @@ -2753,7 +2138,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); for (const since of [false, 42, null]) { @@ -2772,11 +2156,10 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); const report = await reportFor({ since: '0'.repeat(40) }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: '0'.repeat(40), effective: false, reason: 'unknown-commit', @@ -2791,7 +2174,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => args.includes(`${BASE}..f00df00df00d`) @@ -2799,7 +2181,7 @@ describe('fetch-pr report assembly', () => { : Buffer.from(''), ); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'not-an-ancestor', @@ -2826,7 +2208,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => args.includes(`${BASE}..f00df00df00d`) @@ -2834,7 +2215,7 @@ describe('fetch-pr report assembly', () => { : Buffer.from(''), ); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'behind-merge-base', @@ -2850,15 +2231,10 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); - // Two files, one in the delta, so the slice is a PROPER part of the full - // range and "the partitioner refused the scoped diff" is a state that - // exists. With the one-file fixture the two texts are identical and the - // rescue could not be told from the first attempt. - servesBothRanges(FULL_TWO, DELTA_A); + servesBothRanges(); producerMocks.buildDiffPlan.mockImplementation((text: unknown) => { - if (text === SLICE_A) throw new Error('chunks do not tile the diff'); + if (text === NARROWED) throw new Error('chunks do not tile the diff'); return producerMocks.actualBuildDiffPlan(text, 400); }); const report = await reportFor({ since: ANCHOR }); @@ -2866,11 +2242,11 @@ describe('fetch-pr report assembly', () => { expect(report.diffLines).toBeGreaterThan(0); // The rescue republished the FULL range — the file agents read must be // the range the report now describes. - expect(writtenDiff()).toBe(FULL_TWO); + expect(writtenDiff()).toBe(FULL_DIFF); // The anchor cannot stay effective over a full-range plan — one round, // two scopes is what that would mean for Agent 7's welded --base — and // the reason names what actually happened, not a capture that worked. - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'partition-failed', @@ -2889,16 +2265,13 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); - // Two files, one in the delta — see the sibling above for why the - // one-file fixture cannot express a scoped-then-rescued round. - servesBothRanges(FULL_TWO, DELTA_A); + servesBothRanges(); producerMocks.buildDiffPlan.mockImplementation((text: unknown) => { - if (text === SLICE_A) throw new Error('chunks do not tile the diff'); + if (text === NARROWED) throw new Error('chunks do not tile the diff'); return producerMocks.actualBuildDiffPlan(text, 400); }); - // Write 1 is the scoped publish and succeeds; write 2 is the rescue. + // Write 1 is the delta publish and succeeds; write 2 is the rescue. let diffWrites = 0; producerMocks.writeFileSync.mockImplementation((path: unknown) => { if (String(path).endsWith('diff.txt') && ++diffWrites === 2) { @@ -2910,7 +2283,7 @@ describe('fetch-pr report assembly', () => { const report = await reportFor({ since: ANCHOR }); expect(report.diffPath).toBeNull(); expect(report.diffPathAbsolute).toBeNull(); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', @@ -2945,7 +2318,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: null, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); producerMocks.buildDiffPlan.mockImplementation((text: unknown) => { @@ -2956,16 +2328,15 @@ describe('fetch-pr report assembly', () => { }); const report = await reportFor({ since: ANCHOR }); expect(report.diffPath).toBeNull(); - // The base-free arm now refuses for containment BEFORE anything is - // partitioned, so the reason names the earlier cause. That also makes the - // rescue's `fullText !== null` guard unreachable from here: `scopedDelta` - // can no longer be true without a base, so it now implies a non-null - // `fullText`. The guard stays as a guard; what changed is that this shape - // no longer reaches it. - expect(ruling(report)).toEqual({ + // The base-free arm refuses BEFORE anything is partitioned, so the reason + // names the earlier cause — and it is the deterministic one, because no + // capture threw. That also makes the rescue's `fullText !== null` guard + // unreachable from here: `scopedDelta` cannot be true without a base, so + // it now implies a non-null `fullText`. + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, - reason: 'containment-unverified', + reason: 'nothing-to-narrow', }); }); @@ -2983,7 +2354,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); producerMocks.buildDiffPlan.mockImplementation((text: unknown) => { @@ -2994,7 +2364,7 @@ describe('fetch-pr report assembly', () => { }); const report = await reportFor({ since: ANCHOR }); // The anchor keeps its own cause… - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'not-an-ancestor', @@ -3013,7 +2383,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); servesBothRanges(); // A large advertised stat, so the collapse ratio WOULD fire if the @@ -3045,7 +2414,7 @@ describe('fetch-pr report assembly', () => { // identically — SKILL's same-sha retry must keep excluding this reason. // Planless-ness is on the report as `diffPath: null`, which is what the // degraded flow reads. - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'partition-failed', @@ -3059,7 +2428,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => { if (args.includes(`${ANCHOR}..f00df00df00d`)) { @@ -3070,7 +2438,7 @@ describe('fetch-pr report assembly', () => { const report = await reportFor({ since: ANCHOR }); // The full-range fallback DID produce a plan, so the reason stays the // one that names why the delta was abandoned. - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', @@ -3091,7 +2459,7 @@ describe('fetch-pr report assembly', () => { }); const report = await reportFor({ since: ANCHOR }); expect(report.diffPath).toBeNull(); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: false, reason: 'capture-failed', @@ -3108,7 +2476,6 @@ describe('fetch-pr report assembly', () => { producerMocks.resolveMergeBase.mockReturnValue({ sha: BASE, baseFetchFailed: false, - probeUnavailable: false, }); producerMocks.gitRaw.mockImplementation((...args: string[]) => args.includes(`${BASE}..f00df00df00d`) @@ -3116,7 +2483,7 @@ describe('fetch-pr report assembly', () => { : Buffer.from(''), ); const report = await reportFor({ since: ANCHOR }); - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, upToDate: true, @@ -3145,7 +2512,7 @@ describe('fetch-pr report assembly', () => { // anchor, proven by the delta capture, and the flow it serves — "No new // changes since last review" → cleanup, stop — consumes no plan. The // continuing flows read `diffPath` like any other degraded round. - expect(ruling(report)).toEqual({ + expect(report.incremental).toEqual({ since: ANCHOR, effective: true, upToDate: true, @@ -3866,7 +3233,6 @@ describe('fetch-pr diff identity (diffSha256)', () => { vi.mocked(resolveMergeBase).mockReturnValue({ sha: 'base123', baseFetchFailed: false, - probeUnavailable: false, }); vi.mocked(gitRaw).mockImplementation((...args: string[]) => args.includes('diff') ? Buffer.from(diff) : Buffer.from(''), @@ -3895,7 +3261,6 @@ describe('fetch-pr diff identity (diffSha256)', () => { vi.mocked(resolveMergeBase).mockReturnValue({ sha: 'base123', baseFetchFailed: false, - probeUnavailable: false, }); vi.mocked(gitRaw).mockImplementation((...args: string[]) => args.includes('diff') ? (bytes as unknown as Buffer) : Buffer.from(''), @@ -3917,7 +3282,6 @@ describe('fetch-pr diff identity (diffSha256)', () => { vi.mocked(resolveMergeBase).mockReturnValue({ sha: null, baseFetchFailed: false, - probeUnavailable: false, }); const report = await reportFor(); expect(report.diffSha256).toBeNull(); @@ -3946,7 +3310,6 @@ describe('fetch-pr run-session ledger wiring', () => { vi.mocked(resolveMergeBase).mockReturnValue({ sha: null, baseFetchFailed: false, - probeUnavailable: false, }); vi.mocked(gitRaw).mockImplementation(() => Buffer.from('')); producerMocks.readFileSync.mockImplementation(() => { @@ -4108,7 +3471,6 @@ describe('fetch-pr --resume', () => { vi.mocked(resolveMergeBase).mockImplementation(() => ({ sha: 'baseb45eb45e', baseFetchFailed: false, - probeUnavailable: false, })); vi.mocked(gitRaw).mockImplementation((...args: string[]) => args.includes('ls-tree') || args.includes('cat-file') @@ -4614,7 +3976,6 @@ describe('fetch-pr --resume bookkeeping is counted, not merely called', () => { vi.mocked(resolveMergeBase).mockImplementation(() => ({ sha: 'baseb45eb45e', baseFetchFailed: false, - probeUnavailable: false, })); const { gitRaw } = await import('./lib/git.js'); vi.mocked(gitRaw).mockImplementation((...args: string[]) => diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index 3338669ec4..588214f86d 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -29,7 +29,7 @@ import type { CommandModule } from 'yargs'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { clearReviewWorktreeLeaseIfOwned, @@ -44,7 +44,6 @@ import { getPlatformReader } from './lib/platform/registry.js'; import type { ReviewPlatformReader } from './lib/platform/types.js'; import type { ReviewEffort } from './parse-args.js'; import { - fileLineCount, git, gitOpt, gitProbe as gitExit, @@ -52,11 +51,14 @@ import { refExists, releaseWorktree, } from './lib/git.js'; -import { - LITERAL_PATHSPECS, - PINNED_DIFF_CONFIG, - PINNED_DIFF_FLAGS, -} from './lib/diff-flags.js'; +import type { NarrowSelection } from './lib/narrow-diff.js'; +import { assembleSections, selectNarrowing } from './lib/narrow-diff.js'; +import type { + IncrementalScope, + WidenedScope, +} from './lib/incremental-scope.js'; +import { widenScope } from './lib/incremental-scope.js'; +import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './lib/diff-flags.js'; import { REVIEW_TMP_DIR, reviewBranch, @@ -66,7 +68,6 @@ import { import { planEffortField } from './lib/effort.js'; import { buildDiffPlan, - parseDiff, DEFAULT_MAX_CHUNK_LINES, READ_FILE_CHAR_CAP, } from './lib/diff-plan.js'; @@ -99,10 +100,6 @@ import { clearRoundStamps, } from './lib/deadline.js'; import { certifierMatchesRound, roundModelIdFrom } from './lib/round-model.js'; -import { - computeIncrementalScope, - type IncrementalScope, -} from './lib/incremental-scope.js'; interface PrMetadata { headRefName: string; @@ -251,7 +248,8 @@ type FetchPrResult = PlanReport & { * Present when `--since ` was passed: the incremental-review scoping * decision, validated HERE so the orchestrator never hand-runs git against * an anchor. `effective: true` without `upToDate` means the diff and plan - * in this report cover `since..fetchedSha` instead of the merge-base range. + * in this report are the merge-base range narrowed to what changed since + * the anchor, rather than the whole merge-base range. * `upToDate: true` means nothing has landed since the anchor (the anchor is * the head, or the commits since it change no bytes) — a fact about the * anchor, proven without consulting the base. The diff and plan then cover @@ -264,22 +262,22 @@ type FetchPrResult = PlanReport & { * reason names a CAUSE: a rebase or force-push (`not-an-ancestor`), a sha * this history has never seen (`unknown-commit`), an anchor older than the * merge base that would scope WIDER than the PR's diff - * (`behind-merge-base`), an anchor certified by another identity - * (`cross-model-anchor`), a delta file with no section of the PR's own - * diff under that name (`lineage-unfollowable` — a rename before the - * anchor), nothing to slice FROM and no re-run that would change it - * (`containment-unverified` — an unreadable delta, or a successful - * merge-base with no common ancestor), a base whose fetch or whose - * probes could not answer (`base-untrusted` — the merge-base probe or a - * restoration probe; infrastructure, and retryable for that reason), a - * capture that threw - * (`capture-failed`), or a partitioner that refused to tile - * (`partition-failed`). - * - * `hunks-outside-pr-diff` is gone with the oracle that emitted it: the - * published diff is a SLICE of the PR's own, so containment holds by - * construction and the "undo per feedback" revert it existed for is - * simply reviewed at its full-range hunks. + * (`behind-merge-base`), a merge base too stale to rule the clamp on + * (`base-untrusted`), a capture that threw OR a base-side fault — the + * base fetch or the merge-base resolution — failed (`capture-failed`), a + * partitioner that refused to tile (`partition-failed`), or a narrowing + * that found nothing it could publish (`nothing-to-narrow`). That last one + * exists because the scope is BUILT from the PR's own diff rather than + * checked against it, and it covers every shape the build can refuse, + * deliberately alike: an "undo per feedback" round whose commits put lines + * back the way the base had them, so the PR no longer displays the undone + * FILE at all (a file the PR still carries publishes its section whole + * instead of refusing); a capture on either side whose bytes do not + * survive UTF-8; a delta the + * parser cannot read; and the fail-closed refusal — the two captures key + * the same change differently (a path or a rename git resolves differently + * across the two ranges), so narrowing would drop a change the PR's diff + * displays. Every shape keeps the full range: wider, never wrong. * * Whether a PLAN exists is a separate fact, and it is `diffPath`: null * means this round has no diff to review, whatever refused the anchor. A @@ -298,47 +296,31 @@ export interface IncrementalDecision { | 'unknown-commit' | 'not-an-ancestor' | 'behind-merge-base' - | 'containment-unverified' - | 'lineage-unfollowable' + | 'nothing-to-narrow' | 'cross-model-anchor' | 'base-untrusted' | 'capture-failed' | 'partition-failed'; /** - * LEGACY — the scoped range's left side as a FULL sha, written only by - * plans an older CLI produced, when the published diff was a capture of - * `since..head` and a consumer recomputing its own range (Agent 7's - * test-efficacy probe welds `--base` into its brief) needed the anchor. - * This CLI never writes it: a sliced round publishes sections of - * `merge-base..head`, so the published range's left side IS - * `mergeBaseSha`, and the weld falls back to exactly that. Honoured when - * an older report still carries it; deliberately absent on new rounds. + * The left side of the range the published scope was assembled from, as a + * FULL sha, present exactly when the report's diff is the narrowed scope + * (`effective` and not `upToDate`). Downstream consumers that recompute + * their own ranges read it — Agent 7's test-efficacy probe welds `--base` + * into its brief. It is the merge base, never the anchor: the published + * hunks are byte-identical hunks of `mergeBase..head`, so that range + * covers every one of them and never a byte the PR's diff does not + * display, while the anchor range can carry hunks an undo round netted + * out of the PR's diff. */ diffBase?: string; /** - * WHICH files this round reviews and why, present exactly when `diffBase` - * is absent: this CLI writes it on the sliced rounds where it no longer - * writes `diffBase`, and an older report carrying `diffBase` never carried - * this. The scoped diff is a slice of the PR's own, so a file can be in - * scope with no change of its own: `interaction` names the still-clean - * importers the one-hop widening pulled in, with the changed files each of - * them imports, and a brief built for one points its agent at that seam - * instead of a from-scratch re-review. + * Which files the published scope holds and why, present exactly when the + * scope is the narrowed one. `deltaFiles` are what the round touched; + * `interaction[]` are still-clean files the one-hop widening pulled back + * in, each with the edges that did it, so a chunk brief can point its agent + * at the seam rather than order a from-scratch re-review. */ scope?: IncrementalScope; - /** - * Where the superseded FULL-range diff was kept, as an ABSOLUTE path — - * `read_file` rejects a relative one, and an agent runs inside - * `worktreePath` where `.qwen/tmp/…` resolves to nothing. - * - * NOTHING READS IT at this commit. It is kept because the bytes are - * already in hand and a later step that needs the whole PR (Agent 0's - * issue fidelity, the reverse audit's whole-file lens) would otherwise - * re-run the capture. Named as groundwork rather than as a shipped - * consumer, deliberately. Absent when it could not be written; the round - * is unaffected. - */ - fullDiffPath?: string; } /** Thrown when a probe could not answer — the git surface, not a verdict. */ @@ -453,6 +435,20 @@ export function resolveIncrementalAnchor( return { incremental: { since, effective: true }, diffBase: resolved }; } +/** Count lines of `:`, or 0 if it does not exist there. */ +function fileLineCount(ref: string, path: string): number { + try { + const buf = gitRaw('show', `${ref}:${path}`); + if (buf.length === 0) return 0; + let n = 0; + for (const b of buf) if (b === 0x0a) n++; + // A final line without a trailing newline still counts. + return buf[buf.length - 1] === 0x0a ? n : n + 1; + } catch { + return 0; // absent at this ref: created by the PR, or deleted by it + } +} + /** * Allowlist shape for a server-controlled branch name reaching git's argv: * a plain branch name and nothing else (twin of aone.ts's guard — see that @@ -505,21 +501,36 @@ const gitProbe: GitProbe = { // refs/tags and refs/heads FIRST, so a tag or branch named // `origin/` — likewise pushable, auto-carried at clone time — would // satisfy the check with no tracking ref present. + // + // The exit status is KEPT (gitExit, not gitOpt), like the sibling probes, + // but it splits nothing here: git exits 128 identically for a transient + // fault and for a deterministic refusal (the base branch deleted on the + // remote — the refspec fetch fails every time), so the bound on retrying + // the deterministic member lives where the class is ruled — the demotion + // arm below and SKILL.md's once-cap — never on the status. fetch: (remote, ref) => - gitOpt( + gitExit( 'fetch', remote, '--', `+refs/heads/${ref}:refs/remotes/${remote}/${ref}`, - ) !== null && refExists(`refs/remotes/${remote}/${ref}`), + ).status === 0 && refExists(`refs/remotes/${remote}/${ref}`), refExists, - // `gitExit`, not `gitOpt`: the exit code is what tells "no common ancestor" - // (1) apart from "the probe could not answer" (128, or a kill — the 120s - // timeout a large long-lived PR under CI load reaches), and the two lead to - // opposite recovery flows. `core.commitGraph=false` is main's pin and stays - // — a stale commit-graph answers merge-base from a cache the refs have - // moved past. mergeBase: (a, b) => { + // Three-way exit split like the anchor probes: exit 1 is the only + // deterministic "no common ancestor"; any other status — an exit-128 + // fatal, the 120s timeout kill, a spawn failure — is the surface being + // unavailable, thrown so the round demotes to the retryable class + // instead of folding onto the same null and stamping the deterministic + // reason. One member folds in anyway, and no exit-status resolution can + // split it: git ALSO exits 1 when it cannot read the object store on + // the walk, so a fault there is indistinguishable from an orphan + // history. The arm below discloses it. + // + // `core.commitGraph=false` is #9092's pin, kept: the commit-graph is a + // cache, and a stale or truncated one answers this walk from data the + // object store no longer agrees with — a wrong merge base, which is the + // one input every clamp and the whole narrowing are computed against. const { out, status } = gitExit( '-c', 'core.commitGraph=false', @@ -527,7 +538,9 @@ const gitProbe: GitProbe = { a, b, ); - return { sha: out, status }; + if (status === 0) return out; + if (status === 1) return null; + throw new GitUnavailable(); }, }; @@ -566,58 +579,6 @@ function cleanStale(prNumber: string): void { } } -/** - * Is `path`'s tree entry identical at both ends of the PR? - * - * The whole ENTRY — ` ` — not the blob. `rev-parse :` - * yields the oid alone, so a fix round that reverts the content and keeps - * `chmod +x`, or swaps a file for a symlink with the same text, compared equal - * and was scoped out as "restored". Its mode-only section IS in the PR's diff, - * so dropping it put a change nobody reviewed past the next round's anchor. - * - * Absent on BOTH sides is deliberately NOT a restoration. Two shapes produce - * it and this layer cannot tell them apart: a file the PR added and this round - * deleted (net-zero — safe), and a file renamed before the anchor and deleted - * now, whose unreviewed deletion hunks sit in the PR diff under its pre-rename - * name (dropping it loses them). Refusing costs a full review on the first - * shape; dropping loses scope on the second, so the refusal wins. - * - * NULL is a third answer: git could not answer — an exit above 0, or a kill. - * The surface failing is not a verdict about the entry, and the caller demotes - * the round under a retryable reason rather than read it as "changed": - * folded together, one transient failure over a genuinely restored file - * became a deterministic lineage refusal — the exact conflation the - * {out, status} split in lib/git.ts was written to forbid. - */ -function treeEntryUnchanged( - baseSha: string, - headSha: string, - path: string, -): boolean | null { - const at = (ref: string): { entry: string | null } | null => { - // `gitExit`, not `gitOpt`: exit 0 IS the answer — possibly empty, which - // means "no such entry in that tree". Any other status, a kill included, - // is the probe failing to answer. - const { out, status } = gitExit( - LITERAL_PATHSPECS, - 'ls-tree', - ref, - '--', - path, - ); - if (status !== 0) return null; - const line = out ?? ''; - if (line === '') return { entry: null }; - const tab = line.indexOf('\t'); - const meta = (tab < 0 ? line : line.slice(0, tab)).split(' '); - return { entry: meta.length >= 3 ? `${meta[0]} ${meta[2]}` : null }; - }; - const b = at(baseSha); - const h = at(headSha); - if (b === null || h === null) return null; - return b.entry !== null && h.entry !== null && b.entry === h.entry; -} - /** sha256 of a file's raw bytes, or null when it cannot be read. */ function sha256OfFile(path: string): string | null { try { @@ -756,35 +717,6 @@ function tryResume( return { resumed: true }; } -/** - * Are these bytes valid UTF-8 — i.e. would decoding them lose information? - * - * Asked of the decoder, which is the only thing that knows. The distinction - * that matters is between an invalid sequence SUBSTITUTED with U+FFFD and the - * code point appearing as ordinary content: the first collides two filenames - * onto one string, the second is a character like any other, and this - * repository carries four literal ones in its own source. - * - * A byte-length round-trip looks like it answers this and does not. Node - * emits one U+FFFD per maximal ill-formed subpart, and a 3-byte ill-formed - * subpart substitutes to a 3-byte U+FFFD — `F0 9F 98`, a truncated - * 4-byte sequence, decodes to one replacement character of exactly the - * length it replaced. Every length-preserving substitution passed as clean, - * which left the collision this guards wide open for precisely the shape a - * truncated capture produces. - */ -export function decodeWasLossy(bytes: Buffer): boolean { - try { - STRICT_UTF8.decode(bytes); - return false; - } catch { - return true; - } -} - -/** Constructed once: a decoder is stateless here and the guard runs per round. */ -const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true }); - async function runFetchPr(args: FetchPrArgs): Promise { // Sampled HERE, at the start of the round: see `reviewModelId`. const roundModelId = roundModelIdFrom(process.env); @@ -1018,18 +950,29 @@ async function runFetchPr(args: FetchPrArgs): Promise { // chunk agents read ranges out of it and `diffHashOf` hashes it. What // the round trip does not do is normalise CRLF (that would rewrite // every hunk of a CRLF file) or drop the trailing newline. - const { - sha: mergeBaseSha, - baseFetchFailed, - probeUnavailable: baseProbeUnavailable, - } = resolveMergeBase( - remote, - meta.baseRefName, - // QUALIFIED — the head side is dwim-shadowable exactly like the - // fetchedSha read above. - `refs/heads/${ref}`, - gitProbe, - ); + let mergeBaseSha: string | null; + let baseFetchFailed: boolean; + /** The merge-base probe threw: the surface, not the history. */ + let mergeBaseUnavailable = false; + try { + ({ sha: mergeBaseSha, baseFetchFailed } = resolveMergeBase( + remote, + meta.baseRefName, + // QUALIFIED — the head side is dwim-shadowable exactly like the + // fetchedSha read above. + `refs/heads/${ref}`, + gitProbe, + )); + } catch (err) { + if (!(err instanceof GitUnavailable)) throw err; + // An exit other than the deterministic "no common ancestor" — the + // probe's exit split throws it. The round degrades like any base-less + // one; the fetch result is lost in the throw, and with no sha and the + // retryable reason stamped below, nothing rules on it. + mergeBaseSha = null; + baseFetchFailed = false; + mergeBaseUnavailable = true; + } if (baseFetchFailed) { writeStderrLine( `WARNING: could not fetch ${remote}/${meta.baseRefName}. The merge-base ` + @@ -1259,12 +1202,13 @@ async function runFetchPr(args: FetchPrArgs): Promise { } /** True when the FINAL published diff is the incremental delta. */ let scopedDelta = false; + /** The PR's own hunks, narrowed to what changed since the anchor. */ + let narrowed: Buffer | null = null; + /** What the narrowing selected, before the widening adds to it. */ + let selection: NarrowSelection | null = null; + /** The selection plus one import hop, and the record of why. */ + let widened: WidenedScope | null = null; if (anchor?.diffBase) { - // The delta is read for ONE fact: which files changed since the anchor. - // Their hunks come from the full-range diff — see - // `computeIncrementalScope` for why the published bytes are a SLICE of - // the PR's own diff and never a re-capture. - // // An anchor that resolved to the merge base names the range already in // hand: re-running the identical `git diff` would spend the capture (and // its timeout) twice on the same bytes. Reachable without adversary — @@ -1284,211 +1228,103 @@ async function runFetchPr(args: FetchPrArgs): Promise { // below for the flows that continue anyway (a model change, // --comment). anchor.incremental.upToDate = true; + } else if (mergeBaseUnavailable) { + // `git merge-base` could not answer: the probe's exit split throws on + // every status except the deterministic exit-1 "no common ancestor" + // — an exit-128 fatal, the 120s timeout kill, a spawn failure. + // Something did fail, and the re-run re-runs exactly that probe, so + // this is the retryable class — the same ruling the anchor probes' + // GitUnavailable gets. + demote('capture-failed'); + } else if (mergeBaseSha === null && baseFetchFailed) { + // No merge base because the FETCH failed and no local base ref + // remained to resolve one from. (A merge-base walk that failed on the + // surface is the arm above, not this one.) The class has TWO members + // the exit + // status cannot split — git exits 128 for BOTH: a transient fault (a + // fresh CI clone whose base fetch hit a network blip), where the + // re-run re-runs exactly the component that failed and can succeed, + // and a deterministic refusal (the base branch deleted on the remote + // — the refspec fetch fails identically every time), where it never + // will. Something did fail, so this keeps the retryable reason; + // SKILL.md's recovery paragraph bounds the retry to ONCE so the + // deterministic member cannot re-fail every round until the cap. + demote('capture-failed'); + } else if (mergeBaseSha === null) { + // No merge base although the fetch SUCCEEDED: `git merge-base` found + // no common ancestor — an unrelated-history PR. There is no PR diff to + // narrow against, so no scope is built; but nothing THREW, and calling + // it `capture-failed` asserts an infrastructure fault that did not + // happen and puts the round in the class SKILL.md's recovery flow + // retries. A re-run reproduces this exactly, so it names the + // deterministic reason instead. Exit 1 is the only "no common + // ancestor" signal the probe keeps — every other exit takes the + // retryable arm above. One member folds in anyway: git ALSO exits 1 + // when it cannot read the object store on the walk, so a fault there + // stamps this reason at any exit-status resolution, and the + // determinism claimed here is unprovable for that member. + demote('nothing-to-narrow'); + } else if (fullBytes === null || fullText === null) { + // A base WAS resolved and its capture threw — the 120s git timeout the + // large long-lived PR `--since` exists for. That is infrastructure, + // and a re-run can succeed, so this one keeps `capture-failed`. + demote('capture-failed'); } else if ( - mergeBaseSha === null || - fullBytes === null || - fullText === null + (selection = selectNarrowing(fullBytes, deltaBytes)) === null ) { - // No full range to slice, and the reason must name WHICH cause, - // because the recovery flow retries one class and not the other. - // - // `base-untrusted` when the base FETCH failed: the anchor was never - // ruled invalid, and the component that failed is one the re-run - // repeats, so a flappy fetch must not cost this PR its incremental - // scope for ever. `capture-failed` when a base existed and reading - // the range threw (the 120s git timeout the large long-lived PR - // `--since` exists for) — also infrastructure, also retryable. - // `containment-unverified` only for the genuinely base-FREE case: - // `git merge-base` found no common ancestor at all (a cross-fork PR - // with unrelated history), which a re-run reproduces exactly. - // - // Collapsing the first into the last is how the split was lost once - // already: `containment-unverified` is filed under "deterministic for - // the same sha and must NOT be retried", so a CI checkout whose base - // fetch blips would pay a full review every round from then on, under - // a reason that also misnames the cause — the delta read fine. - // - // `mergeBaseSha === null` is also the load-bearing FIRST conjunct - // rather than a nested check: "fullBytes !== null implies a base" is - // true at runtime and invisible to the compiler, so without it the - // narrowing does not reach the else branch and the package does not - // build. - demote( - baseFetchFailed || baseProbeUnavailable - ? 'base-untrusted' - : mergeBaseSha === null - ? 'containment-unverified' - : 'capture-failed', - ); - } else if (decodeWasLossy(deltaBytes) || decodeWasLossy(fullBytes)) { - // A capture that does not decode cleanly names its files with - // collision-prone U+FFFD strings: two paths differing only in an - // invalid byte decode to the SAME name, and scope membership decided - // on the collided strings would republish a sibling's - // already-certified hunks (or widen over a file the anchor cleared). - // The containment battery this slicing retired refused the exact - // shape; the slice path fails closed the same way. The full-range - // BYTES stay raw, so the fallback loses nothing. - // - // Measured on the DECODE, not on the decoded text. Scanning the text - // for U+FFFD cannot tell a substitution from the code point itself, - // and the code point is ordinary content — this repository holds four - // literal ones in source. A delta touching any of them, even as - // context, demoted the round to a reason filed under "deterministic - // for the same sha and must NOT be retried", so that PR paid a full - // review every round from then on, under a cause that had not - // happened. Only INVALID bytes produce a substitution, and only - // substitution creates the collision this guards. - demote('containment-unverified'); - } else if (parseDiff(delta).files.length === 0) { - // Non-empty bytes that name no file: the capture returned something - // this parser cannot read (an error stream on stdout, a shape it does - // not model). The empty file list is the PARSER's, not the tree's — - // the `delta.trim() === ''` arm above is what an actually-empty range - // takes — so this must not read as "nothing changed since the - // anchor", which would stop the round. An oracle that could not rule - // is exactly `containment-unverified`. - demote('containment-unverified'); - } else { - const deltaSections = parseDiff(delta).files; - // Restoration, probed ONCE per file with the probe's exit status - // kept: `treeEntryUnchanged` answers null when git could not answer, - // and that third state is ruled on below — folded into "changed" it - // converted one transient ls-tree failure into a deterministic - // lineage refusal. - const restoredAt = new Map(); - const restored = (path: string): boolean | null => { - let v = restoredAt.get(path); - if (v === undefined) { - v = treeEntryUnchanged(mergeBaseSha, fetchedSha, path); - restoredAt.set(path, v); - } - return v; - }; - // A rename section names only its NEW side, so the source can own - // hunks in the PR's diff that nothing in the delta names. Whether it - // does is a question about the FULL range, and only the full range - // can answer it: rename detection is a similarity threshold, and the - // two ranges compare different pairs of blobs, so one can pair a - // rename while the other renders a plain deletion beside a plain - // addition — or each can pair the SAME deletion with a different - // target. - // - // The rule is therefore the direct one — does the full diff carry a - // section under the SOURCE name? If it does, that section is - // unreviewed content the slice would drop, so the source rides along - // and the lineage check keeps it. If it does not, ask where the full - // range put the deletion: when it paired it with a DIFFERENT target, - // the source's net hunks sit under that section, and it rides along - // instead (a rename target of the full range is absent at the base - // by construction, so the restoration probe cannot misread it as - // restored and drop it). Otherwise the full range paired the delta's - // own rename, the net hunks already sit under the new-side section, - // and riding anything along would demand a section that does not - // exist and refuse the round. - // - // Keying on `restored(target)` instead covered only the case where - // the target dropped out of the live set. A LIVE target whose ranges - // straddle the similarity threshold — round 1 rewrites `a.ts` past - // it, round 2 moves it to `b.ts` — left the source's net-deletion - // hunks out of the slice with the anchor advancing past them, and - // content no round had seen retired for good. - const fullFiles = parseDiff(fullText).files; - const fullSectionPaths = new Set(fullFiles.map((f) => f.path)); - const fullRenameTargets = new Map(); - for (const f of fullFiles) { - if (f.renamedFrom) fullRenameTargets.set(f.renamedFrom, f.path); - } - const deltaFiles: string[] = []; - for (const f of deltaSections) { - deltaFiles.push(f.path); - if (!f.renamedFrom || f.renamedFrom === f.path) continue; - if (fullSectionPaths.has(f.renamedFrom)) { - deltaFiles.push(f.renamedFrom); - } else { - const carrier = fullRenameTargets.get(f.renamedFrom); - if (carrier && carrier !== f.path) deltaFiles.push(carrier); - } - } - const unanswerable = deltaFiles.filter((p) => restored(p) === null); - if (unanswerable.length > 0) { - // The surface failing, not the anchor: a probe against the base - // tree that could not answer is infrastructure, and the re-run - // repeats it — retryable like its merge-base sibling. - writeStderrLine( - `Incremental scope refused: a restoration probe could not ` + - `answer for ${unanswerable.length} file(s) ` + - `(${unanswerable.slice(0, 3).join(', ')}` + - `${unanswerable.length > 3 ? ', …' : ''}) — base-untrusted. ` + - `Reviewing the full range.`, - ); - demote('base-untrusted'); - } else { - const ruling = computeIncrementalScope({ - anchor: anchor.diffBase, - fullDiff: fullBytes, - deltaFiles, - restored: (path) => restored(path) === true, - readWorktree: (rel) => { - try { - return readFileSync(join(wt, rel), 'utf8'); - } catch { - return null; - } - }, - }); - if (ruling.kind === 'refuse') { - writeStderrLine( - `Incremental scope refused: ${ruling.detail} Reviewing the full range.`, - ); - demote(ruling.reason); - } else if (ruling.kind === 'nothing-new') { - // Every changed file was undone and nothing imports them. That is - // the same state as an empty delta, and it takes the same exit. - writeStderrLine(`Nothing new since the anchor: ${ruling.detail}`); - anchor.incremental.upToDate = true; - } else if (publish(ruling.diff)) { - scopedDelta = true; - // `diffBase` is deliberately NOT written here. It exists so a - // consumer that recomputes its own diff uses the range the round - // published (Agent 7's test-efficacy probe welds it into - // `--base`), and under slicing that range is the MERGE BASE, not - // the anchor: the published bytes are sections of - // `merge-base..head`. Writing the anchor would send the probe - // over `anchor..HEAD` — hunks the round did not review, and - // missing the ones it did — which is the very error the field - // was added to prevent, inverted. The reader falls back to - // `report.mergeBaseSha`, which is the correct answer for a - // sliced round, and it keeps honouring the field on a plan an - // older CLI wrote, where a delta-range publish made it true. - anchor.incremental.scope = ruling.scope; - // The superseded full diff stays on disk beside the scoped one. - // ABSOLUTE, like `diffPathAbsolute` and for the same reason: - // agents read through `read_file`, which rejects a relative - // path, and they run inside `worktreePath` where a - // `.qwen/tmp/…` relative path resolves to nothing. Nothing reads - // it at this commit — say so rather than name a consumer, which - // is how the last round's docs came to certify a transfer that - // never happened. + // The narrowing found nothing it could publish — all safe, because + // keeping the full range costs a wider review and never a wrong one: + // the "undo per feedback" round whose commits put lines back the way + // the base had them, so the undone FILE no longer appears in + // `base..head` at all (an undone file the PR's diff still carries + // does not land here — the join fails closed and publishes its + // section whole instead); a capture whose bytes do not survive + // UTF-8; a delta the parser cannot read; and the fail-closed + // refusal — the two captures key the same change differently (a path + // or a rename), so narrowing would drop a change the PR's diff + // displays. + demote('nothing-to-narrow'); + } else if ( + // One import hop past what the round touched. The narrowing is sound + // in one direction only: a caller cleared against the callee's OLD + // shape is unchanged by definition, so no delta capture shows it, and + // a scope holding only the touched files retires that seam at the + // next re-anchor. The widening never narrows — with no edge to follow + // it returns the narrowing's own paths — so the unwidened round is + // the floor rather than a second path that could disagree with it. + ((widened = widenScope({ + anchor: anchor.diffBase ?? anchor.incremental.since, + selection, + readWorktree: (rel) => { try { - const fullPath = resolve( - tmpFile(`pr-${prNumber}`, 'diff-full.txt'), - ); - writeFileSync(fullPath, fullBytes); - anchor.incremental.fullDiffPath = fullPath; - } catch (err) { - // A convenience artefact must never take the round with it. - writeStderrLine( - `Could not keep the full-range diff beside the scoped one ` + - `(${(err as Error).message}); steps that want the whole ` + - `PR will have to re-capture it.`, - ); + return readFileSync(resolve(wt, rel), 'utf8'); + } catch { + return null; } - } else { - // The slice captured but could not be written: degrade like any - // other capture failure rather than scoping to a file nobody has. - demote('capture-failed'); - } + }, + })), + (narrowed = assembleSections(selection, widened.paths)) === null) + ) { + // `assembleSections` selects nothing only when the widened set names + // no section the full capture carries, which the guards above already + // rule out — but it is the same "nothing to publish" either way, and + // the full range is the safe answer to it. + demote('nothing-to-narrow'); + } else { + if (publish(narrowed)) { + scopedDelta = true; + anchor.incremental.scope = widened.scope; + // The published hunks are byte-identical hunks of + // `mergeBaseSha..head`, so that range is what downstream consumers + // recomputing their own diffs must probe (Agent 7's test-efficacy + // probe welds --base into its brief): it covers every published hunk + // and never a byte the PR's diff does not display, while the anchor + // range can carry hunks an undo round netted out of it. + anchor.incremental.diffBase = mergeBaseSha; + } else { + // The scope was built but could not be written: degrade like any + // other capture failure rather than scoping to a file nobody has. + demote('capture-failed'); } } } diff --git a/packages/cli/src/commands/review/lib/diff-plan.ts b/packages/cli/src/commands/review/lib/diff-plan.ts index de2c2c3eb8..03fb8f02fb 100644 --- a/packages/cli/src/commands/review/lib/diff-plan.ts +++ b/packages/cli/src/commands/review/lib/diff-plan.ts @@ -90,22 +90,18 @@ export function classifyPath(path: string): PathKind { export interface DiffFile { /** New-side path, or the old path for a deletion. */ path: string; + /** + * Old-side path of a rename (`rename from` header) — absent otherwise. + * The narrowing join keys a rename by BOTH paths: the two captures can + * resolve the same move differently, and the new path alone does not say + * whether they keyed the same change. + */ + renameFrom?: string; kind: PathKind; /** Range within the diff FILE, covering header + all hunks. */ diffStart: number; diffEnd: number; hunks: DiffHunk[]; - /** - * The old-side path of a rename section — what it was renamed FROM. - * - * Set only when rename detection rendered the section (the captures pin - * `--find-renames`), where `path` carries the NEW name alone. A delta - * whose rename target was restored to the merge-base state owes the - * source's net-deletion hunks a reviewer, and they sit in the PR's diff - * under this name — a scope decision that reads `path` alone cannot see - * them. - */ - renamedFrom?: string; /** * New-side line ranges the PR actually **wrote** — the `+` lines, coalesced. * @@ -389,7 +385,7 @@ export function parseDiff(diffText: string): { // file's path and swallows the line from the add/remove counts. if (!curHunk) { if (line.startsWith('rename from ')) { - cur.renamedFrom = unquote(line.slice('rename from '.length)); + cur.renameFrom = unquote(line.slice('rename from '.length)); continue; } if (line.startsWith('rename to ')) { diff --git a/packages/cli/src/commands/review/lib/git.integration.test.ts b/packages/cli/src/commands/review/lib/git.integration.test.ts index bbc60ffa98..ef0a95a461 100644 --- a/packages/cli/src/commands/review/lib/git.integration.test.ts +++ b/packages/cli/src/commands/review/lib/git.integration.test.ts @@ -20,12 +20,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { - fileLineCount, - gitProbe, - gitRawTolerateDiff, - releaseWorktree, -} from './git.js'; +import { gitProbe, gitRawTolerateDiff, releaseWorktree } from './git.js'; import { NULL_DEVICE } from './diff-flags.js'; import { isolateHostGitConfig } from './test-utils.js'; @@ -264,42 +259,6 @@ describe('gitRawTolerateDiff', () => { }); }); -describe('fileLineCount', () => { - // The one consumer chain that matters: `buildPlanReport`'s post-image - // resolver. A count that drifts (or an import that silently breaks — the - // move out of fetch-pr was measured unguarded by any test) mis-classifies - // heaviness and silently drops invariant agents from rosters. - it('counts lines at a ref: trailing newline, no trailing newline, absent, empty', () => { - const dir = realpathSync(mkdtempSync(join(tmpdir(), 'flc-'))); - const prev = process.cwd(); - process.chdir(dir); - const iso = isolateHostGitConfig(); - try { - const g = (...args: string[]) => - execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim(); - g('init', '-q', '--template=', '.'); - g('config', 'user.email', 'a@b'); - g('config', 'user.name', 'a'); - g('config', 'commit.gpgsign', 'false'); - writeFileSync(join(dir, 'three.txt'), 'a\nb\nc\n'); - writeFileSync(join(dir, 'no-nl.txt'), 'a\nb'); - writeFileSync(join(dir, 'empty.txt'), ''); - g('add', '-A'); - g('commit', '-q', '--no-verify', '-m', 'one'); - const sha = g('rev-parse', 'HEAD'); - expect(fileLineCount(sha, 'three.txt')).toBe(3); - expect(fileLineCount(sha, 'no-nl.txt')).toBe(2); - expect(fileLineCount(sha, 'empty.txt')).toBe(0); - expect(fileLineCount(sha, 'absent.txt')).toBe(0); - expect(fileLineCount('not-a-ref', 'three.txt')).toBe(0); - } finally { - process.chdir(prev); - iso.dispose(); - rmSync(dir, { recursive: true, force: true }); - } - }); -}); - describe('gitProbe — the exit status the anchor taxonomy rests on', () => { // Every fetch-pr test mocks this module, so nothing else consumes the real // `status`. A rewrite returning `{status: 1}` for every failure, or diff --git a/packages/cli/src/commands/review/lib/git.ts b/packages/cli/src/commands/review/lib/git.ts index 56b800404c..2f5a2067b4 100644 --- a/packages/cli/src/commands/review/lib/git.ts +++ b/packages/cli/src/commands/review/lib/git.ts @@ -293,29 +293,6 @@ export function gitRaw(...args: string[]): Buffer { }); } -/** - * Count lines of `:`, or 0 if it does not exist there. - * - * Lives here rather than in `fetch-pr` because `buildPlanReport` derives - * heaviness from the count, and a second counter written beside another plan - * builder would classify the same file heavy in one plan and not the other. - * It took a `repoRoot` for a `-C` variant no caller ever set — a dead switch, - * kept for a command (`rescope`) that no longer exists — and it is gone; a - * caller that needs another cwd can pass a ref, which is what the ref is. - */ -export function fileLineCount(ref: string, path: string): number { - try { - const buf = gitRaw('show', `${ref}:${path}`); - if (buf.length === 0) return 0; - let n = 0; - for (const b of buf) if (b === 0x0a) n++; - // A final line without a trailing newline still counts. - return buf[buf.length - 1] === 0x0a ? n : n + 1; - } catch { - return 0; // absent at this ref: created by the PR, or deleted by it - } -} - /** * Like `gitRaw`, but treats "the inputs differ" — exit 1 **with output** — as * success and returns the diff the child produced anyway. diff --git a/packages/cli/src/commands/review/lib/incremental-scope.test.ts b/packages/cli/src/commands/review/lib/incremental-scope.test.ts index 77e5bac060..4f4ef9ad51 100644 --- a/packages/cli/src/commands/review/lib/incremental-scope.test.ts +++ b/packages/cli/src/commands/review/lib/incremental-scope.test.ts @@ -4,12 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -// `computeIncrementalScope` is pure but for two injected readers, which is -// what makes the whole scope decision testable without a repository — the -// property its docstring claims and nothing exercised directly until now. +// The widening is pure but for one injected reader, which is what makes it +// testable without a repository. The selection it widens comes from the real +// `selectNarrowing`, so these exercise the pair as the command wires it. import { describe, it, expect } from 'vitest'; -import { computeIncrementalScope } from './incremental-scope.js'; +import { widenScope } from './incremental-scope.js'; +import { assembleSections, selectNarrowing } from './narrow-diff.js'; /** A one-hunk section for `path`, as `parseDiff` reads it. */ function section(path: string): string { @@ -24,101 +25,84 @@ function section(path: string): string { ].join('\n'); } -describe('computeIncrementalScope — interaction ordering', () => { - it('puts SECTIONLESS interaction files first, ahead of the brief cap', () => { - // Two kinds of interaction file, and only one of them has a second - // surface. An importer that carries a section of the PR's diff is also - // named — uncapped — in the chunk brief holding that section. A RESTORED - // file pulled in by the second pass carries none: its own content is base - // content, no chunk holds it, and the capped whole-diff list is the only - // place its seam is briefed at all. - // - // Insertion order appended the restored ones LAST, so on any round past - // `SCOPE_LIST_CAP` they were the first elided into `(+N more)` — the seam - // unbriefed while `scope.interaction` recorded it as covered. The cap has - // to bite the redundantly-named entries first. - const changed = 'src/changed.ts'; - const restoredPath = 'src/restored.ts'; - const importers = Array.from({ length: 3 }, (_, i) => `src/imp${i}.ts`); +function selectionOf(fullPaths: string[], deltaPaths: string[]) { + const sel = selectNarrowing( + Buffer.from(fullPaths.map(section).join(''), 'utf8'), + Buffer.from(deltaPaths.map(section).join(''), 'utf8'), + ); + if (sel === null) throw new Error('the narrowing refused this fixture'); + return sel; +} - const fullDiff = Buffer.from( - [section(changed), ...importers.map(section)].join(''), - 'utf8', +describe('widenScope', () => { + it('pulls in a still-clean importer, and publishes its section', () => { + // `imp.ts` is untouched since the anchor, so no delta capture can show it + // — but the round before cleared it against `changed.ts`'s OLD shape, and + // (importer@head × callee@head) is a pairing no round has seen. + const selection = selectionOf( + ['src/changed.ts', 'src/imp.ts', 'src/other.ts'], + ['src/changed.ts'], ); const sources: Record = { - // The restored file imports the still-changing one: a live seam, and - // it has no section of its own anywhere in the PR's diff. - [restoredPath]: `import './changed.js';\n`, + 'src/imp.ts': `import './changed.js';\n`, + 'src/other.ts': `import './unrelated.js';\n`, }; - for (const p of importers) sources[p] = `import './changed.js';\n`; - const ruling = computeIncrementalScope({ + const { paths, scope } = widenScope({ anchor: 'a'.repeat(40), - fullDiff, - deltaFiles: [changed, restoredPath], - restored: (path) => path === restoredPath, + selection, readWorktree: (rel) => sources[rel] ?? null, }); - expect(ruling.kind).toBe('scoped'); - if (ruling.kind !== 'scoped') return; - const paths = ruling.scope.interaction.map((e) => e.path); - // The sectionless one leads, whatever the insertion order was. - expect(paths[0]).toBe(restoredPath); - // …and the sectioned importers follow, each of which a chunk brief also - // names in full. - expect(paths.slice(1).sort()).toEqual([...importers].sort()); - // The seam itself survives — an entry with no edge is not an interaction. - expect(ruling.scope.interaction[0].importsChanged).toEqual([changed]); - expect(ruling.scope.restoredFileCount).toBe(1); - // The restored file owes no review of its own. - expect(ruling.scope.deltaFiles).toEqual([changed]); - }); -}); + expect([...paths].sort()).toEqual(['src/changed.ts', 'src/imp.ts']); + expect(scope.deltaFiles).toEqual(['src/changed.ts']); + expect(scope.interaction).toEqual([ + { path: 'src/imp.ts', importsChanged: ['src/changed.ts'] }, + ]); + // `other.ts` was weighed and passed over — it imports nothing that moved. + expect(scope.contextFileCount).toBe(1); -describe('computeIncrementalScope — a revert against a still-live contract', () => { - it('scopes the callee a restored importer strands, instead of stopping', () => { - // The shape the second widening pass exists for, and the one it could not - // see. Round 1 changes `i.ts` (`foo(x)` → `foo(x, y)`) together with its - // caller `r.ts` and clears both at the anchor; the fix round reverts ONLY - // `r.ts`. So the delta is `{r.ts}` and it is restored — `deltaLive` is - // empty — while `i.ts` carries the PR's only section, changed before the - // anchor and unchanged since. - // - // Two layers stopped the round dead. Keyed on `deltaLive`, the pass - // resolved `r.ts`'s import against an EMPTY membership and found no edge; - // and even with the edge, `scoped` took only the importer side, so the - // section that actually moves was never kept and `kept.length === 0` - // ruled `nothing-new` anyway. `upToDate` does not advance the anchor, so - // every re-run rules the same: `r.ts@base × i.ts@head` — the base-era - // call against the new contract — reviewed by no round, and absent from - // every later delta by construction. - const i = 'src/i.ts'; - const r = 'src/r.ts'; - // `r.ts` is byte-identical to the merge base, so the PR's own diff - // carries no section for it. `i.ts` is the whole of the PR's diff. - const ruling = computeIncrementalScope({ + // The published bytes are the PR's own sections, both of them. + const diff = assembleSections(selection, paths); + expect(diff?.toString('utf8')).toContain('b/src/changed.ts'); + expect(diff?.toString('utf8')).toContain('b/src/imp.ts'); + }); + + it('returns exactly the narrowing when nothing imports what moved', () => { + // The floor: with no edge to follow the widened round must be the + // unwidened one, not a second path that could disagree with it. + const selection = selectionOf( + ['src/changed.ts', 'src/other.ts'], + ['src/changed.ts'], + ); + const { paths, scope } = widenScope({ anchor: 'a'.repeat(40), - fullDiff: Buffer.from(section(i), 'utf8'), - deltaFiles: [r], - restored: (path) => path === r, - readWorktree: (rel) => (rel === r ? `import './i.js';\n` : null), + selection, + readWorktree: () => `import './unrelated.js';\n`, }); - expect(ruling.kind).toBe('scoped'); - if (ruling.kind !== 'scoped') return; - // The seam is briefed… - expect(ruling.scope.interaction).toEqual([ - { path: r, importsChanged: [i] }, - ]); - // …and the moving side is actually published, which is the half the - // importer-only `scoped` set dropped. - expect(ruling.diff.toString('utf8')).toContain(`b/${i}`); - // The restored file owes no review of its own. - expect(ruling.scope.deltaFiles).toEqual([]); - expect(ruling.scope.restoredFileCount).toBe(1); - // `i.ts` was scoped IN as the seam's target, so it is not a file the - // widening considered and passed over. - expect(ruling.scope.contextFileCount).toBe(0); + expect([...paths]).toEqual(['src/changed.ts']); + expect(scope.interaction).toEqual([]); + expect(scope.contextFileCount).toBe(1); + expect(assembleSections(selection, paths)?.toString('utf8')).toBe( + assembleSections(selection, selection.touched)?.toString('utf8'), + ); + }); + + it('does not follow a test file into scope', () => { + // Re-running tests is `build-test`'s job; a test importing what moved is + // not a seam a reading agent owes a second look. + const selection = selectionOf( + ['src/changed.ts', 'src/changed.test.ts'], + ['src/changed.ts'], + ); + const { paths, scope } = widenScope({ + anchor: 'a'.repeat(40), + selection, + readWorktree: () => `import './changed.js';\n`, + }); + + expect([...paths]).toEqual(['src/changed.ts']); + expect(scope.interaction).toEqual([]); }); }); diff --git a/packages/cli/src/commands/review/lib/incremental-scope.ts b/packages/cli/src/commands/review/lib/incremental-scope.ts index e26c0e1494..b9a595165b 100644 --- a/packages/cli/src/commands/review/lib/incremental-scope.ts +++ b/packages/cli/src/commands/review/lib/incremental-scope.ts @@ -4,32 +4,41 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Which of a PR's diff belongs to an incremental round — decided as a SLICE of -// the PR's own full-range diff, never as a re-capture of `anchor..head`. +// One import hop past what the round actually touched. // -// `fetch-pr --since` already rules whether an anchor may scope a round at all -// (ancestry, merge-base clamp, base trust). This module answers the next -// question: given a valid anchor, WHICH files does the round review, and what -// bytes does it hand the agents. Two properties fall out of doing it by slicing -// that a re-capture cannot give: +// `narrow-diff.ts` decides which of the PR's own sections an incremental round +// publishes: the ones the delta touched, emitted whole. That is the saving +// incremental review exists for — a round touching 2 files of 40 reviews 2 — +// and it is also, on its own, unsound in one direction. // -// 1. Every hunk is byte-identical to a hunk of the PR's own diff. Comment -// anchoring can therefore never produce a line GitHub refuses — and an -// inline comment 422 is all-or-nothing, taking every other finding in the -// Create Review call with it. A re-captured `anchor..head` carries hunks -// the PR's diff does not contain whenever the fix round reverted lines -// back to base content ("undo per feedback"), which is an ordinary thing -// for a fix round to do. -// 2. A file with no hunks in `anchor..head` can still be IN scope. That is -// what makes the one-hop widening possible at all: an importer of a -// changed file is unchanged by definition, so a delta capture cannot show -// it, yet round 1 cleared it against the callee's OLD shape and -// (importer@head × callee@head) is a pairing no round has seen. +// "Clean" is a verdict about the code as it stood. The previous round cleared +// a caller against the callee it imported THEN; the fix under review moves the +// callee, and a scope holding only the touched files never re-opens the +// caller. The breakage retires silently and permanently, because the next +// clean round re-anchors past it. The caller is unchanged by definition, so no +// delta capture can show it — which is exactly why this cannot be a narrowing +// and has to be a widening. // -// Everything here is pure but for two injected readers, so the whole decision +// So every still-clean SOURCE file one import hop from a touched one re-enters +// the scope with its full-range hunks, and the plan records WHY +// (`incremental.scope.interaction[]`), so the chunk brief can point its agent +// at the seam — "do your uses of what changed still hold" — instead of a +// from-scratch re-review that re-reports what the earlier round already ruled +// on. +// +// One hop, dependents only, source files only. The callee-side risk lives in +// the changed file's own chunk (its agent reads callees from the worktree), +// test dependents are `build-test`'s job, and a barrel re-export between +// caller and callee hides the edge — a documented miss that leaves exactly the +// floor incremental review had before widening existed. The specifier scan is +// a regex heuristic on purpose, and its error directions are chosen: a false +// positive reviews a file once more than needed, a false negative never drops +// below the unwidened floor. +// +// Everything here is pure but for one injected reader, so the whole decision // is unit-testable without a repository. -import { parseDiff, sliceDiffByLines } from './diff-plan.js'; +import type { NarrowSelection } from './narrow-diff.js'; import { dependentsOfChanged, discoverWorkspacePackages, @@ -44,209 +53,67 @@ export interface InteractionFile { export interface IncrementalScope { /** The anchor this scope was computed against, full sha. */ anchor: string; - /** - * Changed since the anchor AND carrying hunks of the PR's own diff. A file - * restored to its merge-base state is changed since the anchor but has - * nothing left to review, so it is not here — a plan naming delta files - * with zero hunks sends agents hunting for scope that does not exist. - */ + /** Touched since the anchor, and carrying a section of the PR's own diff. */ deltaFiles: string[]; /** Still-clean files the widening pulled in, with the edges that did it. */ interaction: InteractionFile[]; /** Clean source files the widening considered and did NOT pull in. */ contextFileCount: number; - /** - * Files changed since the anchor whose content is byte-identical to the - * merge base's — the fix round undid them. Counted, not reviewed: they own - * no hunks, but they still moved their importers' seams, so the widening - * used them. - */ - restoredFileCount: number; } -export type ScopeRuling = - | { kind: 'scoped'; diff: Buffer; scope: IncrementalScope } - /** Nothing of the PR's diff is in scope — the round has nothing to review. */ - | { kind: 'nothing-new'; detail: string } - /** The slice cannot be trusted to hold everything owed a review. */ - | { kind: 'refuse'; reason: 'lineage-unfollowable'; detail: string }; +export interface WidenedScope { + /** Every path to publish: what the delta touched, plus what imports it. */ + paths: Set; + /** The record the plan carries and the chunk briefs read. */ + scope: IncrementalScope; +} -export interface ScopeInput { +export interface WidenInput { /** Full sha of the anchor, for the report. */ anchor: string; - /** The PR's own full-range diff — merge-base..head — as captured bytes. */ - fullDiff: Buffer; - /** Paths changed in `anchor..head`, as `parseDiff` labels them. */ - deltaFiles: readonly string[]; - /** - * Is this path's tree entry at the head identical to the merge base's? - * - * The whole ENTRY, mode included: a fix round that reverts the content and - * keeps `chmod +x` — or swaps a file for a symlink with the same text — is - * not a restoration, and its mode-only section IS in the PR's diff. A blob - * -only comparison read those as restored and scoped them out, which put a - * change nobody reviewed past the next round's anchor. - * - * Absent on BOTH sides must answer `false`, not `true`: this layer cannot - * tell a net-zero add-then-delete (safe to drop) from a file renamed before - * the anchor and deleted now, whose unreviewed deletion hunks sit in the PR - * diff under its pre-rename name (dropping it loses them). - */ - restored: (path: string) => boolean; + /** What `selectNarrowing` decided — its guards have already passed. */ + selection: NarrowSelection; /** Read a repo-relative file from the worktree; null when unreadable. */ readWorktree: (repoRelPath: string) => string | null; } /** - * Decide an incremental round's scope, or decline to. + * Widen a narrowing by one import hop. * - * Declining is always toward MORE review: `refuse` sends the round to the full - * range, and `nothing-new` only ever fires when the slice provably holds - * nothing. There is no path here that narrows scope on an uncertainty. + * This never declines and never narrows: with nothing to pull in it returns + * exactly the paths the narrowing selected, so the unwidened round is the + * floor rather than a separate path that could disagree with it. */ -export function computeIncrementalScope(input: ScopeInput): ScopeRuling { - const { anchor, fullDiff, deltaFiles, restored, readWorktree } = input; - const sections = parseDiff(fullDiff.toString('utf8')).files; +export function widenScope(input: WidenInput): WidenedScope { + const { anchor, selection, readWorktree } = input; + const touched = new Set(selection.touched); - // The restoration probe runs BEFORE the widening, not after it. A delta - // file the fix round restored to its merge-base state has no PR-diff - // section — nothing left to review in it — but it is also, by definition, a - // file whose CURRENT content is the base content, so if it imports a file - // that IS still changing, that seam is exactly what the widening exists to - // catch. Judged after the fact it fell between both classes: excluded from - // the delta readers for having no section, and excluded from the widening - // candidates for being in the delta. It is a CANDIDATE. - const restoredDelta = new Set(deltaFiles.filter(restored)); - // Two sets, because a restored file plays both parts. As a CHANGE it still - // pulls its importers in: round 1 cleared them against the pre-revert - // callee, and (importer@head × callee@base) is a pairing no round has seen. - // As a FILE it has nothing left to review. - const delta = new Set(deltaFiles); - const deltaLive = new Set(deltaFiles.filter((p) => !restoredDelta.has(p))); - - // One import hop over the PR's still-clean SOURCE files. Test and docs - // dependents stay out: re-running tests is `build-test`'s job, and prose - // does not call functions. - const candidates = sections - .filter((f) => f.kind === 'source' && !f.binary && !deltaLive.has(f.path)) + // Test and docs dependents stay out: re-running tests is `build-test`'s job, + // and prose does not call functions. + const candidates = selection.sections + .filter((f) => f.kind === 'source' && !f.binary && !touched.has(f.path)) .map((f) => f.path); const packages = discoverWorkspacePackages( - [...deltaFiles, ...candidates], + [...touched, ...candidates], readWorktree, ); const interaction = dependentsOfChanged( - delta, + touched, candidates, readWorktree, packages, ); - // A restored file is inside `delta`, so the pass above skips it as a - // candidate by construction (`dependentsOfChanged` never scans a file that - // is itself changed). It still needs one: its own imports of files that are - // STILL changing are live seams no other reader covers. - // - // The membership is every file the PR still changes, not just the ones the - // delta moved. Keyed on `deltaLive` alone it missed the whole reason the - // pass exists: the callee a revert strands is usually changed BEFORE the - // anchor and unchanged since, so it is not in `deltaFiles` at all. Round 1 - // changes `i.ts` and its caller `r.ts` together and clears both; the fix - // round reverts only `r.ts`. The delta is `{r.ts}`, restored, so `deltaLive` - // is EMPTY — the pass resolved `r.ts`'s import against nothing, found no - // edge, and the round stopped `nothing-new` with `r.ts@base × i.ts@head`, - // the base-era call against the still-live contract, reviewed by no round - // and gone from every later delta. Restored×restored pairs stay excluded - // for free: a restored file carries no section, so it is never a candidate. - for (const [path, edges] of dependentsOfChanged( - new Set([...deltaLive, ...candidates]), - [...restoredDelta], - readWorktree, - packages, - )) { - if (!interaction.has(path)) interaction.set(path, edges); - } - - // The edges' TARGETS are scoped too, not just their importer side. The - // moving half of a seam is the half that carries hunks: a restored importer - // has no section of its own, so scoping only `interaction.keys()` kept - // nothing, `kept` came back empty and the round still ruled `nothing-new` — - // the same stop, one layer down, surviving the membership fix above. Adding - // targets is inert everywhere else: pass 1's are `delta` members, already - // here when live and sectionless when restored. - const scoped = new Set([ - ...deltaLive, - ...interaction.keys(), - ...[...interaction.values()].flat(), - ]); - const kept = sections.filter((f) => scoped.has(f.path)); - const keptPaths = new Set(kept.map((f) => f.path)); - - // Every LIVE delta file must carry a section of the PR's own diff. One that - // does not is a lineage break — a file renamed before the anchor and - // deleted now is `new.ts` in the delta but `old.ts` on the PR diff's - // deletion section (a deletion is labelled with its left-side path), so the - // section holding its unreviewed hunks is scoped out under a name nothing - // matched. Restored files are already out of `deltaLive`. - const lineageLost = [...deltaLive].filter((p) => !keptPaths.has(p)); - if (lineageLost.length > 0) { - return { - kind: 'refuse', - reason: 'lineage-unfollowable', - detail: - `${lineageLost.length} file(s) changed since ${anchor} carry no ` + - `section of the PR's own diff under that name ` + - `(${lineageLost.slice(0, 3).join(', ')}` + - `${lineageLost.length > 3 ? ', …' : ''}) — a rename or lineage ` + - `change the scoped slice cannot follow.`, - }; - } - if (kept.length === 0) { - return { - kind: 'nothing-new', - detail: - `the files changed since ${anchor} carry no section of the PR's own ` + - `diff (restored to the merge-base state), and nothing imports them.`, - }; - } + const paths = new Set([...touched, ...interaction.keys()]); return { - kind: 'scoped', - diff: sliceDiffByLines( - fullDiff, - kept.map((f) => ({ startLine: f.diffStart, endLine: f.diffEnd })), - ), + paths, scope: { anchor, - deltaFiles: [...deltaLive].filter((p) => keptPaths.has(p)), - // SECTIONLESS entries first, and the order is load-bearing. - // - // An interaction file that carries a section of the PR's diff is named - // twice: here, and in the chunk brief of whichever chunk holds that - // section, uncapped. One that carries NONE — a restored file pulled in - // by the second pass, whose own content is base content — belongs to no - // chunk, so this capped list is the ONLY surface that briefs its seam. - // Appended last, as insertion order had them, they were the first - // elided into `(+N more)` on any round with more than `SCOPE_LIST_CAP` - // entries: the seam went unbriefed while `scope.interaction` recorded - // it as covered, which is coverage claimed and not delivered. - // - // So the cap now bites the redundantly-named entries first. It still - // bites — a round with more sectionless entries than the cap elides - // some — but that is the honest degradation, not the silent one. + deltaFiles: [...touched].sort(), interaction: [...interaction.entries()] - .sort( - ([a], [b]) => - Number(keptPaths.has(a)) - Number(keptPaths.has(b)) || - a.localeCompare(b), - ) - .map(([path, importsChanged]) => ({ - path, - importsChanged, - })), - // Considered and NOT scoped in — which is no longer the same as "not - // an interaction key", now that a seam can scope a candidate as an - // edge target rather than as an importer. - contextFileCount: candidates.filter((p) => !keptPaths.has(p)).length, - restoredFileCount: restoredDelta.size, + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, importsChanged]) => ({ path, importsChanged })), + contextFileCount: candidates.filter((p) => !interaction.has(p)).length, }, }; } diff --git a/packages/cli/src/commands/review/lib/merge-base.test.ts b/packages/cli/src/commands/review/lib/merge-base.test.ts index 961d79819e..4efb99501d 100644 --- a/packages/cli/src/commands/review/lib/merge-base.test.ts +++ b/packages/cli/src/commands/review/lib/merge-base.test.ts @@ -12,18 +12,6 @@ function fakeGit(opts: { fetchOk?: boolean; refs?: string[]; bases?: Record; - /** - * Probes that could not ANSWER, keyed like `bases`. Exit 1 is the answer - * "no common ancestor"; anything else — 128, or no status from a kill — - * says nothing about the histories, and the two lead to opposite recovery - * flows. - */ - unanswerable?: string[]; - /** - * Probes KILLED outright, keyed like `bases` — the 120s timeout shape, - * which yields no status at all (`status: null`), not 128. - */ - killed?: string[]; }): GitProbe & { calls: string[] } { const calls: string[] = []; return { @@ -38,14 +26,7 @@ function fakeGit(opts: { }, mergeBase(a, b) { calls.push(`mergeBase ${a} ${b}`); - const key = `${a}..${b}`; - const sha = opts.bases?.[key] ?? null; - if (sha) return { sha, status: 0 }; - if ((opts.killed ?? []).includes(key)) return { sha: null, status: null }; - return { - sha: null, - status: (opts.unanswerable ?? []).includes(key) ? 128 : 1, - }; + return opts.bases?.[`${a}..${b}`] ?? null; }, }; } @@ -57,11 +38,7 @@ describe('resolveMergeBase', () => { bases: { 'refs/remotes/origin/main..pr-head': 'aaa111' }, }); const r = resolveMergeBase('origin', 'main', 'pr-head', git); - expect(r).toEqual({ - sha: 'aaa111', - baseFetchFailed: false, - probeUnavailable: false, - }); + expect(r).toEqual({ sha: 'aaa111', baseFetchFailed: false }); // It never had to consult the local branch. expect(git.calls).not.toContain('mergeBase main pr-head'); }); @@ -99,124 +76,6 @@ describe('resolveMergeBase', () => { expect(resolveMergeBase('origin', 'main', 'pr-head', git)).toEqual({ sha: 'stale1', baseFetchFailed: true, - probeUnavailable: false, - }); - }); - - it('separates "no common ancestor" from a probe that could not answer', () => { - // `gitOpt` collapsed every failure to a null sha, so a 128 or a kill — - // the 120s timeout a large long-lived PR under CI load reaches — was - // indistinguishable from git's definitive exit-1 "these histories share - // nothing". The caller keys the RETRY class on the difference: a probe - // that could not answer is infrastructure, and a re-run repeats exactly - // the component that failed. - const definitive = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ refs: ['refs/remotes/origin/main'] }), - ); - expect(definitive).toEqual({ - sha: null, - baseFetchFailed: false, - probeUnavailable: false, - }); - - const unanswerable = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ - refs: ['refs/remotes/origin/main'], - unanswerable: ['refs/remotes/origin/main..pr-head'], - }), - ); - expect(unanswerable).toEqual({ - sha: null, - baseFetchFailed: false, - probeUnavailable: true, - }); - }); - - it('a KILLED probe — no status at all — is unanswerable like an exit 128', () => { - // The 120s merge-base timeout ends in a kill, and a kill yields - // `status: null`, NOT 128 — `gitProbe`'s doc in lib/git.ts says so. - // Keying the split on `status === 128` (or collapsing a kill to the - // definitive exit 1 on the producer seam) would classify exactly the - // motivating shape as "no common ancestor", filing a transient under a - // reason the recovery flow never retries. - const sole = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ - refs: ['refs/remotes/origin/main'], - killed: ['refs/remotes/origin/main..pr-head'], - }), - ); - expect(sole).toEqual({ - sha: null, - baseFetchFailed: false, - probeUnavailable: true, - }); - - // …and it taints the resolution even when the local fallback answers a - // definitive exit 1: one unanswerable probe means determinism was never - // established, whatever the other candidate says. - const tainted = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ - refs: ['refs/remotes/origin/main', 'main'], - killed: ['refs/remotes/origin/main..pr-head'], - }), - ); - expect(tainted.sha).toBeNull(); - expect(tainted.probeUnavailable).toBe(true); - }); - - it('an unanswerable probe on ONE candidate taints the whole resolution', () => { - // The tracking ref cannot answer and the local fallback says "no common - // ancestor". Reporting that as the definitive shape would file a - // transient failure under a reason the recovery flow never retries — the - // round has not established determinism, it has only heard one answer. - const r = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ - refs: ['refs/remotes/origin/main', 'main'], - unanswerable: ['refs/remotes/origin/main..pr-head'], - }), - ); - expect(r.sha).toBeNull(); - expect(r.probeUnavailable).toBe(true); - }); - - it('a SUCCESSFUL fallback resolution sheds the probe taint', () => { - // The tracking-ref probe is killed and the local fallback ANSWERS: a - // resolved base IS the deterministic shape — the clamp ran against a - // real sha — so a later full-range capture failure is filed as - // `capture-failed`, not `base-untrusted`. The consumer tests the taint - // flag before `mergeBaseSha === null`, so stickiness riding into a - // success misnames the cause in the one field whose contract is "every - // reason names a CAUSE". Stickiness serves the no-ancestor question — - // no candidate answered — never a successful resolution. - const r = resolveMergeBase( - 'origin', - 'main', - 'pr-head', - fakeGit({ - refs: ['refs/remotes/origin/main', 'main'], - bases: { 'main..pr-head': 'ddd444' }, - killed: ['refs/remotes/origin/main..pr-head'], - }), - ); - expect(r).toEqual({ - sha: 'ddd444', - baseFetchFailed: false, - probeUnavailable: false, }); }); @@ -225,7 +84,6 @@ describe('resolveMergeBase', () => { expect(resolveMergeBase('origin', 'main', 'pr-head', git)).toEqual({ sha: null, baseFetchFailed: false, - probeUnavailable: false, }); }); @@ -243,6 +101,19 @@ describe('resolveMergeBase', () => { expect(git.calls[1]).toBe('refExists refs/remotes/upstream/develop'); }); + it('propagates a mergeBase throw — a surface failure is not "none"', () => { + // The caller demotes on this propagation: a catch added HERE would fold + // a surface failure back into {sha: null} and let the deterministic + // reason be stamped over an exit that a re-run might fix. + const git = fakeGit({ refs: ['refs/remotes/origin/main'] }); + git.mergeBase = () => { + throw new Error('surface unavailable'); + }; + expect(() => resolveMergeBase('origin', 'main', 'pr-head', git)).toThrow( + 'surface unavailable', + ); + }); + it('never merge-bases through an origin/ shadow tag', () => { // A tag literally named `origin/main` — a pushable, server-controlled // refname a plain clone auto-carries — resolves FIRST for the diff --git a/packages/cli/src/commands/review/lib/merge-base.ts b/packages/cli/src/commands/review/lib/merge-base.ts index 67d9ea3094..0f17884803 100644 --- a/packages/cli/src/commands/review/lib/merge-base.ts +++ b/packages/cli/src/commands/review/lib/merge-base.ts @@ -16,18 +16,11 @@ export interface GitProbe { /** Does this ref resolve locally? */ refExists(ref: string): boolean; /** - * Merge-base of two refs. - * - * `status` is what separates "these histories share no ancestor" from "the - * probe could not answer": git exits 1 for the first, and 128 — or nothing - * at all, on a kill or a spawn failure — for the second. Collapsing both to - * a null sha is how a transient failure came to be reported as a - * deterministic refusal the recovery flow then refused to retry. + * Merge-base of two refs, or null when there is none. An implementation + * may THROW when the git surface cannot answer — distinct from answering + * "none" — and the throw propagates to the caller. */ - mergeBase( - a: string, - b: string, - ): { sha: string | null; status: number | null }; + mergeBase(a: string, b: string): string | null; } export interface MergeBaseResult { @@ -41,17 +34,6 @@ export interface MergeBaseResult { * and the review silently examines the wrong diff. The caller says so. */ baseFetchFailed: boolean; - /** - * True when a candidate ref resolved but the merge-base probe itself could - * not answer — an exit above 1, or a kill (the 120s timeout a large - * long-lived PR under CI load reaches). Distinct from `sha: null` with this - * false, which is the definitive shape: the probe ran and the histories - * genuinely share no ancestor, which a re-run reproduces exactly. - * - * The caller keys the RETRY class on it: a probe that could not answer is - * infrastructure, and the component that failed is one a re-run repeats. - */ - probeUnavailable: boolean; } /** @@ -76,27 +58,13 @@ export function resolveMergeBase( git: GitProbe, ): MergeBaseResult { const baseFetchFailed = !git.fetch(remote, baseRefName); - // Sticky across candidates: the tracking ref may fail to probe while the - // local fallback answers a definitive "no ancestor", and a round that saw - // one unanswerable probe has not established the deterministic shape. - // Stickiness serves that no-ancestor question ONLY: a resolution that - // succeeded is itself the deterministic shape — the clamp ran against a - // real sha — so the taint must not ride into it and misname a later - // capture failure `base-untrusted`. - let probeUnavailable = false; for (const candidate of [ `refs/remotes/${remote}/${baseRefName}`, baseRefName, ]) { if (!git.refExists(candidate)) continue; const mb = git.mergeBase(candidate, headRef); - if (mb.sha) { - return { sha: mb.sha, baseFetchFailed, probeUnavailable: false }; - } - // Exit 1 is the answer "no common ancestor". Anything else — 128, or no - // status at all from a kill or a spawn failure — is the probe failing to - // answer, which says nothing about the histories. - if (mb.status !== 1) probeUnavailable = true; + if (mb) return { sha: mb, baseFetchFailed }; } - return { sha: null, baseFetchFailed, probeUnavailable }; + return { sha: null, baseFetchFailed }; } diff --git a/packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts b/packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts new file mode 100644 index 0000000000..e5fd5f9811 --- /dev/null +++ b/packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts @@ -0,0 +1,1212 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Drives the narrowing against captures REAL git produced, on real histories, +// under the flags `fetch-pr` pins. +// +// The property under test is the one the containment oracle spent six review +// rounds failing to prove: every line of the published scope is a line the +// PR's own diff displays. Here it is checked as an invariant over each +// scenario rather than argued per shape — including the shapes that defeated +// the oracle, which now cannot arise because the delta's bytes never reach the +// output. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { narrowToDelta } from './narrow-diff.js'; +import { PINNED_DIFF_CONFIG, PINNED_DIFF_FLAGS } from './diff-flags.js'; +import { isolateHostGitConfig } from './test-utils.js'; + +let repo: string; +let env: NodeJS.ProcessEnv; +let gitIsolation: ReturnType; + +const git = (...args: string[]) => + execFileSync('git', args, { cwd: repo, encoding: 'utf8', env }); + +const captureBytes = (from: string, to: string) => + execFileSync( + 'git', + [...PINNED_DIFF_CONFIG, 'diff', ...PINNED_DIFF_FLAGS, from, to], + { cwd: repo, maxBuffer: 1 << 28, env }, + ); +const capture = (from: string, to: string) => + captureBytes(from, to).toString('utf8'); + +const commit = (msg: string, files: Record) => { + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(repo, name), body); + } + git('add', '-A'); + git('commit', '-qm', msg, '--no-verify'); + return git('rev-parse', 'HEAD').trim(); +}; + +const lines = (n: number, tag = 'L') => + Array.from({ length: n }, (_, i) => `${tag}${i + 1}`).join('\n') + '\n'; + +/** + * Commit after recording an exec-bit flip THROUGH GIT. `chmodSync` alone is + * invisible on Windows: libuv cannot set the exec bit there, and git's + * `core.fileMode` is false anyway, so the capture the test drives would + * carry no mode section on the Windows CI leg. The index-native form + * records the mode on every platform; the filesystem chmod keeps the + * worktree consistent with the index where `core.fileMode` IS true. + */ +const commitModeChange = ( + msg: string, + file: string, + exec: boolean, + files: Record, +) => { + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(repo, name), body); + } + chmodSync(join(repo, file), exec ? 0o755 : 0o644); + git('add', '-A'); + git('update-index', `--chmod=${exec ? '+x' : '-x'}`, file); + git('commit', '-qm', msg, '--no-verify'); + return git('rev-parse', 'HEAD').trim(); +}; + +/** + * The invariant, checked directly: every line of the narrowed text appears in + * the full capture. Not a sample of shapes — the whole output. + */ +const everyLineIsDisplayed = (narrowed: string, full: string) => { + const displayed = new Set(full.split('\n')); + return narrowed + .split('\n') + .filter((l) => l !== '') + .every((l) => displayed.has(l)); +}; + +beforeAll(() => { + repo = mkdtempSync(join(tmpdir(), 'narrow-')); + gitIsolation = isolateHostGitConfig(); + env = { ...process.env, GIT_TERMINAL_PROMPT: '0' }; + git('init', '-q', '--template=', '.'); + git('config', 'user.email', 'test@example.com'); + git('config', 'user.name', 'test'); + git('config', 'commit.gpgsign', 'false'); + git('config', 'core.autocrlf', 'false'); +}); + +afterAll(() => { + if (repo) rmSync(repo, { recursive: true, force: true }); + gitIsolation.dispose(); +}); + +describe('narrowToDelta on real-git captures', () => { + it('keeps only the PR hunks the anchor round did not already cover', () => { + // Two files edited before the anchor, a third edited after it. The round + // should review the third and nothing else. + const base = commit('base', { + 'a.ts': lines(40, 'A'), + 'b.ts': lines(40, 'B'), + 'c.ts': lines(40, 'C'), + }); + const anchor = commit('round 1', { + 'a.ts': lines(40, 'A').replace('A5\n', 'A5-EDIT\n'), + 'b.ts': lines(40, 'B').replace('B5\n', 'B5-EDIT\n'), + 'c.ts': lines(40, 'C'), + }); + const head = commit('round 2', { + 'a.ts': lines(40, 'A').replace('A5\n', 'A5-EDIT\n'), + 'b.ts': lines(40, 'B').replace('B5\n', 'B5-EDIT\n'), + 'c.ts': lines(40, 'C').replace('C20\n', 'C20-EDIT\n'), + }); + + const full = capture(base, head); + const deltaBytes = captureBytes(anchor, head); + const narrowed = + narrowToDelta(captureBytes(base, head), deltaBytes)?.toString('utf8') ?? + null; + + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('c.ts'); + expect(narrowed).toContain('+C20-EDIT'); + // The two files the anchor round already reviewed are gone… + expect(narrowed).not.toContain('a.ts'); + expect(narrowed).not.toContain('b.ts'); + // …and every surviving line came from the PR's own diff. + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('never emits a line the PR diff lacks, on the undo-per-feedback round', () => { + // The shape that defeated the oracle six times: round 1 adds lines, round + // 2 takes them back out, so the delta deletes text that stood at neither + // the base nor the head and the PR's diff displays it on neither side. + const base = commit('undo base', { 'u.ts': lines(30, 'U') }); + const anchor = commit('undo round 1', { + 'u.ts': lines(30, 'U').replace('U10\n', 'U10\nX1\nX2\nX3\n'), + }); + const head = commit('undo round 2', { + 'u.ts': lines(30, 'U').replace('U25\n', 'U25-EDIT\n'), + }); + + const full = capture(base, head); + const deltaBytes = captureBytes(anchor, head); + expect(deltaBytes.toString('utf8')).toContain('-X1'); // really carries it + expect(full).not.toContain('X1'); // and the PR's diff never mentions it + + const narrowed = + narrowToDelta(captureBytes(base, head), deltaBytes)?.toString('utf8') ?? + null; + // The scenario is constructed to narrow — the delta's surviving edit + // overlaps the full capture's one hunk — so assert it outright. A + // regression refusing on ANY missed delta range (all-or-nothing emission + // instead of per-hunk) must not ship green behind a null-tolerant check. + expect(narrowed).not.toBeNull(); + // Whatever it narrowed to, the deleted lines cannot be in it: the output + // is assembled from `full`, which does not contain them. + expect(narrowed!).not.toContain('X1'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('narrows to a post-anchor file the PR diff also carries', () => { + // The anchor round covered the original file; the only work since it is a + // brand-new file, which both captures carry. + const base = commit('quiet base', { 'q.ts': lines(30, 'Q') }); + const anchor = commit('quiet round 1', { + 'q.ts': lines(30, 'Q').replace('Q5\n', 'Q5-EDIT\n'), + }); + const head = commit('quiet round 2', { + 'q.ts': lines(30, 'Q').replace('Q5\n', 'Q5-EDIT\n'), + 'untracked-elsewhere.txt': 'noise\n', + }); + + const full = capture(base, head); + const deltaBytes = captureBytes(anchor, head); + const narrowed = + narrowToDelta(captureBytes(base, head), deltaBytes)?.toString('utf8') ?? + null; + // The scenario is constructed to narrow, so assert it outright: a + // regression returning null for new-file delta sections must not pass + // with zero assertions executed behind a null guard. + expect(narrowed).not.toBeNull(); + // `untracked-elsewhere.txt` IS in both captures, so this narrows to it — + // and the assertion that matters is the invariant, not the emptiness. + expect(narrowed).toContain('untracked-elsewhere.txt'); + expect(narrowed).not.toContain('q.ts'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('refuses to narrow a capture that does not round-trip through utf8', () => { + // Narrowing selects over decoded text, so a capture carrying bytes that + // are not valid UTF-8 cannot be reassembled faithfully — re-encoding + // would write bytes git never produced, and `diffSha256` would then name + // a file nobody captured. Checked by refusing to decode, not by hunting + // U+FFFD. + const invalid = Buffer.concat([ + Buffer.from('diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-'), + Buffer.from([0xff, 0xfe, 0x80]), + Buffer.from('\n+ok\n'), + ]); + expect(invalid.toString('utf8')).not.toBe(invalid.toString('latin1')); + expect( + narrowToDelta( + invalid, + Buffer.from('diff --git a/f b/f\n@@ -1,1 +1,1 @@\n+ok\n', 'utf8'), + ), + ).toBeNull(); + }); + + it('refuses to narrow a delta that does not round-trip through utf8', () => { + // Symmetric with the full-side refusal: the delta's decoded paths drive + // the guard and the join, so a lossily pre-decoded delta folds an + // invalid path byte onto U+FFFD, which can collide with a legitimate + // U+FFFD path the full capture carries and publish an unchanged file's + // hunks. Fatal-decoding the delta refuses the shape instead; the round + // keeps the full range. The full side carries the collision's other + // half — a legitimate U+FFFD path (bytes EF BF BD) whose hunk overlaps + // the delta's range — so a lossily decoded delta would pass the path + // guard and publish: the assertion answers null only while the fatal + // guard stands. + const delta = Buffer.concat([ + Buffer.from('diff --git a/f'), + Buffer.from([0xff]), + Buffer.from(' b/f'), + Buffer.from([0xff]), + Buffer.from('\n@@ -1,1 +1,1 @@\n-x\n+y\n'), + ]); + const fffd = Buffer.from([0xef, 0xbf, 0xbd]); + const full = Buffer.concat([ + Buffer.from('diff --git a/f'), + fffd, + Buffer.from(' b/f'), + fffd, + Buffer.from('\n--- a/f'), + fffd, + Buffer.from('\n+++ b/f'), + fffd, + Buffer.from('\n@@ -1,1 +1,1 @@\n-a\n+b\n'), + ]); + expect(narrowToDelta(full, delta)).toBeNull(); + }); + + it('falls back rather than scoping when the captures key a change differently', () => { + // Round 1 renames old.ts -> new.ts; round 2 deletes new.ts and edits + // other.ts. `base..head` nets the chain to a plain deletion keyed + // `old.ts`; `anchor..head` deletes `new.ts`. The change both the delta + // performed and the PR's diff displays sits under a key the delta does + // not carry, so narrowing would silently drop it — refuse instead. The + // round keeps the full range, which still displays it. + const base = commit('rename-fallback base', { + 'old.ts': lines(8, 'O'), + 'other.ts': lines(8, 'T'), + }); + git('mv', 'old.ts', 'new.ts'); + git('commit', '-qm', 'rename-fallback round 1', '--no-verify'); + const anchor = git('rev-parse', 'HEAD').trim(); + git('rm', '-q', 'new.ts'); + writeFileSync( + join(repo, 'other.ts'), + lines(8, 'T').replace('T3\n', 'T3-EDIT\n'), + ); + git('add', '-A'); + git('commit', '-qm', 'rename-fallback round 2', '--no-verify'); + + const deltaBytes = captureBytes(anchor, 'HEAD'); + const delta = deltaBytes.toString('utf8'); + expect(delta).toContain('b/new.ts'); + expect(delta).not.toContain('b/old.ts'); + expect(narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)).toBeNull(); + }); + + it('refuses to narrow a rewrite the delta keys as a rename', () => { + // Round 1 completely rewrites old.ts (similarity below git's rename + // threshold); round 2 renames it and edits another file. `base..head` + // nets the chain to a `new.ts` addition plus an `old.ts` deletion; + // `anchor..head` carries a 100%-similarity rename keyed on the NEW + // path. The path guard cannot see the divergence — the new path IS in + // the full capture, as the addition — while the rename's deletion half + // sits under the old path, keyed only there. Narrowing would publish + // the addition and silently drop the deletion, so the rename guard + // refuses instead: the round keeps the full range, which still displays + // it. + const base = commit('rewrite-rename base', { + 'rw-old.ts': lines(8, 'O'), + 'rw-other.ts': lines(8, 'T'), + }); + commit('rewrite-rename round 1', { + 'rw-old.ts': lines(8, 'W'), + 'rw-other.ts': lines(8, 'T'), + }); + const anchor = git('rev-parse', 'HEAD').trim(); + git('mv', 'rw-old.ts', 'rw-new.ts'); + writeFileSync( + join(repo, 'rw-other.ts'), + lines(8, 'T').replace('T3\n', 'T3-EDIT\n'), + ); + git('add', '-A'); + git('commit', '-qm', 'rewrite-rename round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + const delta = deltaBytes.toString('utf8'); + // The scenario's premise: the two captures key the move differently. + expect(delta).toContain('rename from rw-old.ts'); + expect(full).not.toContain('rename from'); + expect(full).toContain('-O1'); // the deletion the PR's diff displays + expect(narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)).toBeNull(); + }); + + it('emits the full section whole for a hunk-less delta touch', () => { + // Round 1 edits m.sh's content; round 2 chmods it and edits other.ts. + // The delta's m.sh section is mode-only — no hunks — and the change + // lives in the full section's header, so the section is emitted whole. + // A security-relevant executable-bit change must not drop from scope. + const base = commit('mode base', { + 'm.sh': lines(8, 'M'), + 'other.ts': lines(8, 'T'), + }); + const anchor = commit('mode round 1', { + 'm.sh': lines(8, 'M').replace('M2\n', 'M2-EDIT\n'), + 'other.ts': lines(8, 'T'), + }); + commitModeChange('mode round 2', 'm.sh', true, { + 'm.sh': lines(8, 'M').replace('M2\n', 'M2-EDIT\n'), + 'other.ts': lines(8, 'T').replace('T4\n', 'T4-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const narrowed = + narrowToDelta( + captureBytes(base, 'HEAD'), + captureBytes(anchor, 'HEAD'), + )?.toString('utf8') ?? null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('new mode 100755'); + expect(narrowed).toContain('+T4-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('emits the rename section for a hunk-less pure-rename delta', () => { + // Round 1 edits one line; round 2 renames the file. The delta is a + // hunk-less pure rename keyed on the new path; the full capture carries + // the same path with hunks. The rename — this round's work — must not + // drop. + const base = commit('pure-rename base', { + 'old.ts': lines(8, 'O'), + 'keep.ts': 'k\n', + }); + commit('pure-rename round 1', { + 'old.ts': lines(8, 'O').replace('O5\n', 'O5-EDIT\n'), + 'keep.ts': 'k\n', + }); + const anchor = git('rev-parse', 'HEAD').trim(); + git('mv', 'old.ts', 'new.ts'); + git('commit', '-qm', 'pure-rename round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('rename to new.ts'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('rename to new.ts'); + expect(narrowed).toContain('+O5-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries the section when a mode change reverts but content stays', () => { + // Round 1 chmods AND edits; round 2 reverts the mode only. The delta's + // section is mode-only while the full section carries the round-1 + // content hunks; the touch carries the section whole. Over-inclusion + // (re-reviewing those hunks) is the chosen semantics — every emitted + // line is still displayed. + const base = commit('mode-revert base', { + 'c.sh': lines(8, 'C'), + 'other.ts': lines(8, 'T'), + }); + const anchor = commitModeChange('mode-revert round 1', 'c.sh', true, { + 'c.sh': lines(8, 'C').replace('C3\n', 'C3-EDIT\n'), + 'other.ts': lines(8, 'T'), + }); + commitModeChange('mode-revert round 2', 'c.sh', false, { + 'c.sh': lines(8, 'C').replace('C3\n', 'C3-EDIT\n'), + 'other.ts': lines(8, 'T').replace('T6\n', 'T6-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const narrowed = + narrowToDelta( + captureBytes(base, 'HEAD'), + captureBytes(anchor, 'HEAD'), + )?.toString('utf8') ?? null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('c.sh'); + expect(narrowed).toContain('+T6-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a mode-only full section the delta touches with content hunks', () => { + // Mirror of the hunk-less-delta shape: round 1 chmods AND edits m.sh; + // round 2 reverts only the content. `base..head` nets to a mode-only + // section — no hunks — while the delta carries the content reversion's + // hunk. The emission must carry the full section whole; a hunkless full + // section must not be skipped because the delta has ranges at the path. + const base = commit('mode-net base', { 'mode-net.sh': lines(8, 'M') }); + const anchor = commitModeChange('mode-net round 1', 'mode-net.sh', true, { + 'mode-net.sh': lines(8, 'M').replace('M4\n', 'M4-EDIT\n'), + }); + commit('mode-net round 2', { 'mode-net.sh': lines(8, 'M') }); + + const full = capture(base, 'HEAD'); + // The scenario's premise: full nets to mode-only, delta carries hunks. + expect(full).toContain('new mode 100755'); + expect(full).not.toContain('@@'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('@@'); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('new mode 100755'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a post-anchor mode flip whose hunks all miss the full capture', () => { + // Round 1 edits lines 5 and 15; round 2 reverts line 5, keeps line 15, + // and flips the exec bit. The delta's section then carries BOTH the + // mode flip and a revert hunk whose new-side range overlaps no full + // hunk. The hunk miss must not drop the header-level change with it: + // the exec-bit flip sits in no other review chunk, and the ledger + // certifies head, so a dropped section never re-enters any later scope. + const base = commit('mode-miss base', { + 'mm.sh': lines(20, 'M'), + 'mm-other.ts': lines(8, 'T'), + }); + const anchor = commit('mode-miss round 1', { + 'mm.sh': lines(20, 'M') + .replace('M5\n', 'M5-EDIT\n') + .replace('M15\n', 'M15-EDIT\n'), + 'mm-other.ts': lines(8, 'T'), + }); + commitModeChange('mode-miss round 2', 'mm.sh', true, { + 'mm.sh': lines(20, 'M').replace('M15\n', 'M15-EDIT\n'), + 'mm-other.ts': lines(8, 'T').replace('T4\n', 'T4-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the mode flip rides a section whose only + // hunk — the line-5 revert — nets out of the full capture. + expect(full).toContain('new mode 100755'); + expect(deltaBytes.toString('utf8')).toContain('-M5-EDIT'); + expect(full).not.toContain('M5-EDIT'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('new mode 100755'); + expect(narrowed).toContain('+M15-EDIT'); + expect(narrowed).toContain('+T4-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a post-anchor rename whose hunks all miss the full capture', () => { + // Round 1 edits lines 5 and 15; round 2 renames the file and reverts + // line 5, keeping line 15. Both captures key the SAME rename, so the + // rename guard passes — and the delta's revert hunk then misses every + // full hunk exactly as in the mode-flip sibling. The rename — this + // round's work — must not drop with the missed hunks. + const base = commit('rename-miss base', { + 'rn-old.ts': lines(20, 'O'), + 'rn-other.ts': lines(8, 'T'), + }); + const anchor = commit('rename-miss round 1', { + 'rn-old.ts': lines(20, 'O') + .replace('O5\n', 'O5-EDIT\n') + .replace('O15\n', 'O15-EDIT\n'), + 'rn-other.ts': lines(8, 'T'), + }); + git('mv', 'rn-old.ts', 'rn-new.ts'); + writeFileSync( + join(repo, 'rn-new.ts'), + lines(20, 'O').replace('O15\n', 'O15-EDIT\n'), + ); + writeFileSync( + join(repo, 'rn-other.ts'), + lines(8, 'T').replace('T4\n', 'T4-EDIT\n'), + ); + git('add', '-A'); + git('commit', '-qm', 'rename-miss round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: BOTH captures key the move as the same + // rename, and the delta's only hunk — the line-5 revert — nets out of + // the full capture. + expect(deltaBytes.toString('utf8')).toContain('rename from rn-old.ts'); + expect(full).toContain('rename from rn-old.ts'); + expect(deltaBytes.toString('utf8')).toContain('-O5-EDIT'); + expect(full).not.toContain('O5-EDIT'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('rename to rn-new.ts'); + expect(narrowed).toContain('+O15-EDIT'); + expect(narrowed).toContain('+T4-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('narrows a plain edit of a file an earlier round renamed', () => { + // The rename guard's pass-through arm: the full capture keys the path + // as a rename (round 1 moved it), while the delta carries a plain edit + // of the new path with no `renameFrom` (round 2 edited it). The guard + // must skip the section and narrow — refusing here would answer + // nothing-to-narrow on every later round of such a PR, permanently + // losing the incremental optimization with no error surface. + const base = commit('pass-through base', { 'pt-old.ts': lines(20, 'O') }); + git('mv', 'pt-old.ts', 'pt-new.ts'); + git('commit', '-qm', 'pass-through round 1', '--no-verify'); + const anchor = git('rev-parse', 'HEAD').trim(); + commit('pass-through round 2', { + 'pt-new.ts': lines(20, 'O').replace('O10\n', 'O10-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: full keys a rename; the delta is a plain edit. + expect(full).toContain('rename to pt-new.ts'); + expect(deltaBytes.toString('utf8')).not.toContain('rename from'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('+O10-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('emits the rename header and matching hunks when one round does both', () => { + // Round 1 edits line 3; round 2 renames the file AND edits line 15. + // The delta's section carries the rename AND content hunks — the + // emission cell (guard equality passes) × (non-empty ranges → header + + // matching hunks), distinct from the hunk-less pass and the + // rewrite-rename refusal. + const base = commit('rename-edit base', { 're-old.ts': lines(20, 'O') }); + const anchor = commit('rename-edit round 1', { + 're-old.ts': lines(20, 'O').replace('O3\n', 'O3-EDIT\n'), + }); + git('mv', 're-old.ts', 're-new.ts'); + writeFileSync( + join(repo, 're-new.ts'), + lines(20, 'O') + .replace('O3\n', 'O3-EDIT\n') + .replace('O15\n', 'O15-EDIT\n'), + ); + git('add', '-A'); + git('commit', '-qm', 'rename-edit round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('rename to re-new.ts'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('rename to re-new.ts'); + expect(narrowed).toContain('+O15-EDIT'); + // The round-1 edit is correctly absent: a non-empty-range section + // emits matching hunks only. + // Carried, not excluded: narrowing is per FILE now, so a touched + // section arrives whole — including hunks the anchor round already + // covered. Over-inclusion inside a touched file is the deliberate price + // of never dropping one. + expect(narrowed).toContain('+O3-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('keeps every full hunk a single delta hunk overlaps', () => { + // Round 1 edits lines 5 and 25 and replaces lines 9..21; round 2 + // reverts the replacement. The delta is ONE hunk whose new-side range + // overlaps BOTH surviving full hunks — a first-match-per-range emission + // would drop the second edit from the published scope while the report + // still says the round narrowed. + const base = commit('two-overlap base', { 'ov.ts': lines(30, 'F') }); + const anchor = commit('two-overlap round 1', { + 'ov.ts': lines(30, 'F') + .replace('F5\n', 'F5-EDIT\n') + .replace( + Array.from({ length: 13 }, (_, i) => `F${9 + i}`).join('\n') + '\n', + Array.from({ length: 13 }, (_, i) => `Y${i + 1}`).join('\n') + '\n', + ) + .replace('F25\n', 'F25-EDIT\n'), + }); + commit('two-overlap round 2', { + 'ov.ts': lines(30, 'F') + .replace('F5\n', 'F5-EDIT\n') + .replace('F25\n', 'F25-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: full carries both edits and no replacement; + // the delta is the single revert hunk. + expect(full).toContain('+F5-EDIT'); + expect(full).toContain('+F25-EDIT'); + expect(full).not.toContain('Y1'); + expect(deltaBytes.toString('utf8')).toContain('-Y1'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('+F5-EDIT'); + expect(narrowed).toContain('+F25-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a netted-undo section whole while its sibling still narrows', () => { + // Round 1 edits f.ts line 10 and inserts X1–X3 after line 25; round 2 + // reverts the insertion, keeps the edit, and edits g.ts line 15. The + // f.ts delta hunk — the X revert — is corroborated by no full hunk: + // none overlaps its range and none shares its text. It IS a netted-out + // undo, but the captures cannot prove that — the same shape is how a + // Myers misplacement looks — so the join fails closed and carries the + // section whole: over-inclusion re-reviews the round-1 edit, while a + // dropped change would be certified unreviewed by the ledger. The + // treatment stays per-section: the corroborated sibling still narrows. + const base = commit('section-drop base', { + 'sd-f.ts': lines(30, 'F'), + 'sd-g.ts': lines(20, 'G'), + }); + const anchor = commit('section-drop round 1', { + 'sd-f.ts': lines(30, 'F') + .replace('F10\n', 'F10-EDIT\n') + .replace('F25\n', 'F25\nX1\nX2\nX3\n'), + 'sd-g.ts': lines(20, 'G').replace('G5\n', 'G5-EDIT\n'), + }); + commit('section-drop round 2', { + 'sd-f.ts': lines(30, 'F').replace('F10\n', 'F10-EDIT\n'), + 'sd-g.ts': lines(20, 'G') + .replace('G5\n', 'G5-EDIT\n') + .replace('G15\n', 'G15-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: full carries the f.ts edit but no insertion; + // the delta carries the revert. + expect(full).toContain('+F10-EDIT'); + expect(full).not.toContain('X1'); + expect(deltaBytes.toString('utf8')).toContain('-X1'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + // The uncorroborated section is carried whole — round-1 edit included + // — while the corroborated sibling narrows to its post-anchor hunk. + expect(narrowed).toContain('sd-f.ts'); + expect(narrowed).toContain('+F10-EDIT'); + expect(narrowed).toContain('+G15-EDIT'); + // Same file, so the whole section is carried — the sibling narrowing + // that matters is the FILE the round did not touch, asserted below. + expect(narrowed).toContain('+G5-EDIT'); + expect(narrowed!).not.toContain('X1'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries the section whole when the delta overlaps no hunk the PR diff still carries', () => { + // Round 1 inserts X1–X3 and edits U25; round 2 reverts the insertion, + // keeping the edit. The delta's one hunk — the X deletion — is + // corroborated by no full hunk: the full capture's single hunk (the + // U25 edit) neither overlaps its range nor shares its text. The old + // design read that as "nothing to narrow to" and fell back; the join + // now fails closed instead and carries the section whole. Every line + // of it is displayed by the PR's diff — and a dropped change here + // would be certified unreviewed by the ledger. + const base = commit('no-overlap base', { 'u.ts': lines(30, 'U') }); + const anchor = commit('no-overlap round 1', { + 'u.ts': lines(30, 'U') + .replace('U10\n', 'U10\nX1\nX2\nX3\n') + .replace('U25\n', 'U25-EDIT\n'), + }); + commit('no-overlap round 2', { + 'u.ts': lines(30, 'U').replace('U25\n', 'U25-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('-X1'); + expect(full).not.toContain('X1'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('+U25-EDIT'); + expect(narrowed!).not.toContain('X1'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a change the captures position disjointly in an identical run', () => { + // Round 1 edits the line before a run of 20 identical lines; round 2 + // deletes one line OF the run and edits a sibling file. Myers aligns a + // change inside an identical-line run against whatever surrounds it, + // and the two captures' old sides differ: `base..head` folds the + // deletion into the round-1 edit's hunk at the FRONT of the run, while + // `anchor..head` places the same deletion at the BACK. The ranges are + // disjoint for a change both captures display, and the ledger certifies + // head — a dropped change here would never re-enter any later scope. + const run = Array.from({ length: 20 }, () => 'R').join('\n') + '\n'; + const base = commit('disjoint base', { + 'dj-run.ts': 'E\n' + run, + 'dj-sib.ts': 'S1\nS2\nS3\n', + }); + const anchor = commit('disjoint round 1', { + 'dj-run.ts': 'E-EDIT\n' + run, + 'dj-sib.ts': 'S1\nS2\nS3\n', + }); + commit('disjoint round 2', { + 'dj-run.ts': + 'E-EDIT\n' + Array.from({ length: 19 }, () => 'R').join('\n') + '\n', + 'dj-sib.ts': 'S1\nS2-EDIT\nS3\n', + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the SAME deletion, displayed by BOTH + // captures, positioned disjointly on the head side. + expect(full).toContain('@@ -1,5 +1,4 @@'); + expect(deltaBytes.toString('utf8')).toContain('@@ -18,4 +18,3 @@'); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + // The divergent section is carried whole — over-inclusion is the + // chosen semantics — while the clean sibling still narrows. + expect(narrowed).toContain('dj-run.ts'); + expect(narrowed).toContain('-R\n'); + expect(narrowed).toContain('+S2-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries the divergent hunk when a sibling hunk of the section matches', () => { + // The partial-miss shape of the disjoint-run mechanism: round 1 edits + // the line before the run; round 2 deletes one line of the run AND + // edits F20 far away. The full capture folds the deletion into the + // front-of-run hunk, the delta places it at the back, and the F20 hunk + // aligns identically in both captures. One delta hunk matches, one + // misses disjointly — a per-section emission of header + matched hunks + // would drop the deletion while the file stays visible. + const run = Array.from({ length: 20 }, () => 'R').join('\n') + '\n'; + const front = 'F1\nF2\nF3\nF4\nF5\n'; + const tail = + Array.from({ length: 15 }, (_, i) => `F${i + 6}`).join('\n') + '\n'; + const base = commit('partial-miss base', { + 'pm.ts': front + 'B-LEAD\n' + run + tail, + }); + const anchor = commit('partial-miss round 1', { + 'pm.ts': front + 'B-LEAD-EDIT\n' + run + tail, + }); + commit('partial-miss round 2', { + 'pm.ts': + front + + 'B-LEAD-EDIT\n' + + Array.from({ length: 19 }, () => 'R').join('\n') + + '\n' + + tail.replace('F20\n', 'F20-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the deletion folded into the front hunk in + // full, positioned at the back in the delta; the F20 hunk matches. + expect(full).toContain('@@ -3,8 +3,7 @@'); + expect(deltaBytes.toString('utf8')).toContain('@@ -23,7 +23,6 @@'); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('+F20-EDIT'); // the matched hunk stays + expect(narrowed).toContain('-R\n'); // the divergent hunk is carried + expect(narrowed).toContain('+B-LEAD-EDIT'); // whole section: over-inclusion + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('narrows between FILES, never within one — the invariant, stated once', () => { + // What four rounds of position-divergence findings were really about: + // the delta and the full capture are independent Myers alignments, so + // which HUNK a change lands in is not stable between them, and every + // attempt to match hunks across them was defeated by a new shape. Which + // FILE it lands in IS stable — the path and rename guards above fail + // closed on the one way that could differ — so the file is the unit. + // + // Stated as a property rather than a shape: for every file the delta + // touched, the narrowed output contains that section's every line. + const base = commit('inv base', { + 'inv-a.ts': lines(30, 'A'), + 'inv-b.ts': lines(30, 'B'), + }); + const anchor = commit('inv round 1', { + 'inv-a.ts': lines(30, 'A').replace('A4\n', 'A4-EDIT\n'), + 'inv-b.ts': lines(30, 'B').replace('B4\n', 'B4-EDIT\n'), + }); + // Round 2 touches only inv-a, and does so near a run of identical lines — + // the shape that makes Myers place the same edit differently. + const head = commit('inv round 2', { + 'inv-a.ts': lines(30, 'A') + .replace('A4\n', 'A4-EDIT\n') + .replace('A20\n', 'A20-EDIT\n'), + 'inv-b.ts': lines(30, 'B').replace('B4\n', 'B4-EDIT\n'), + }); + + const full = capture(base, head); + const narrowed = narrowToDelta( + captureBytes(base, head), + captureBytes(anchor, head), + )!.toString('utf8'); + + // The untouched FILE is gone — that is the whole saving. + expect(narrowed).not.toContain('inv-b.ts'); + // The touched file arrives entire: every line of its full section, not a + // selection of it. Checked by containment of the section, not by naming + // hunks, so no future alignment change can quietly narrow it further. + const sectionOf = (text: string, path: string) => { + const all = text.split('\n'); + const start = all.findIndex((l) => l.startsWith(`diff --git a/${path}`)); + const rest = all + .slice(start + 1) + .findIndex((l) => l.startsWith('diff --git ')); + return all.slice(start, rest === -1 ? undefined : start + 1 + rest); + }; + for (const line of sectionOf(full, 'inv-a.ts')) { + expect(narrowed).toContain(line); + } + expect(everyLineIsDisplayed(narrowed, full)).toBe(true); + }); + + it('carries a change the captures display at disjoint positions under disjoint texts', () => { + // R9-1 entrance A: round 1 substitutes the line LEADING a run of + // identical lines with the run's own text, extending the run by one; + // round 2 deletes one line OF the run. `base..head` nets to the + // leader's deletion — the only one-edit script — which Myers displays + // at the FRONT of the file; `anchor..head` deletes one run line, which + // Myers displays at the run's BACK. The same change, displayed by both + // captures, sits at disjoint head-side ranges AND under disjoint + // changed texts (`-B` vs `-R`): the range join misses it, and a guard + // keyed on either conjunct alone misses it too. The section must still + // be carried — a dropped change here is certified unreviewed by the + // ledger. + const runOf = (n: number) => + Array.from({ length: n }, () => 'R').join('\n') + '\n'; + const base = commit('disjoint-text base', { + 'dt-run.ts': 'B\n' + runOf(24), + 'dt-sib.ts': lines(13, 'S'), + }); + const anchor = commit('disjoint-text round 1', { + 'dt-run.ts': 'R\n' + runOf(24), + 'dt-sib.ts': lines(13, 'S').replace('S2\n', 'S2-EDIT\n'), + }); + commit('disjoint-text round 2', { + 'dt-run.ts': runOf(24), + 'dt-sib.ts': lines(13, 'S') + .replace('S2\n', 'S2-EDIT\n') + .replace('S10\n', 'S10-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the SAME deletion displayed by BOTH + // captures, at disjoint head-side ranges under disjoint changed texts. + expect(full).toContain('@@ -1,4 +1,3 @@'); + expect(full).toContain('-B'); + expect(deltaBytes.toString('utf8')).toContain('@@ -22,4 +22,3 @@'); + expect(deltaBytes.toString('utf8')).not.toContain('-B'); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + // The divergent section is carried whole — over-inclusion is the + // chosen semantics — while the clean sibling still narrows. + expect(narrowed).toContain('dt-run.ts'); + expect(narrowed).toContain('-B'); + expect(narrowed).toContain('+S10-EDIT'); + // The divergent change is what must survive, and it does — carried + // inside the whole section rather than selected by a position match that + // two independent Myers alignments cannot be trusted to agree on. + expect(narrowed).toContain('+S2-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a divergent hunk that overlaps an unrelated bystander hunk', () => { + // R9-1 entrance B: round 1 edits the line before the run AND inserts a + // line in the tail region; round 2 deletes one line OF the run. The + // full capture folds the deletion into the front-of-run hunk; the + // delta places it at the run's back, where its new-side range overlaps + // the BYSTANDER insertion hunk — a change that predates the anchor, so + // the delta never performs it. An overlap precondition sees the + // bystander and stands down; the range join then carries the bystander + // alone and drops the front hunk that actually displays the deletion. + // The divergent hunk must still be carried. + const run = Array.from({ length: 20 }, () => 'R').join('\n') + '\n'; + const front = 'F1\nF2\nF3\nF4\nF5\n'; + const tail = + Array.from({ length: 14 }, (_, i) => `F${i + 7}`).join('\n') + '\n'; + const base = commit('bystander base', { + 'by-run.ts': front + 'B-LEAD\n' + run + 'F6\n' + tail, + }); + const anchor = commit('bystander round 1', { + 'by-run.ts': front + 'B-LEAD-EDIT\n' + run + 'F6\nINSERTED\n' + tail, + }); + commit('bystander round 2', { + 'by-run.ts': + front + + 'B-LEAD-EDIT\n' + + Array.from({ length: 19 }, () => 'R').join('\n') + + '\n' + + 'F6\nINSERTED\n' + + tail, + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the deletion folded into the front hunk in + // full, the delta's divergent hunk overlapping the bystander's range. + expect(full).toContain('@@ -3,8 +3,7 @@'); + expect(full).toContain('@@ -25,6 +24,7 @@'); + expect(deltaBytes.toString('utf8')).toContain('@@ -23,7 +23,6 @@'); + expect(deltaBytes.toString('utf8')).not.toContain('+INSERTED'); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('-R\n'); // the divergent hunk is carried + expect(narrowed).toContain('+B-LEAD-EDIT'); // whole section: over-inclusion + expect(narrowed).toContain('+INSERTED'); // the bystander stays + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a divergent hunk whose bystander shares its changed text', () => { + // R9-1 entrance C: round 1 edits the line before the run AND deletes + // the R line standing after a separator; round 2 deletes one line OF + // the run. The full capture folds the run deletion into the + // front-of-run hunk; the delta places it at the run's back, where its + // new-side range overlaps the round-1 deletion hunk — a bystander + // carrying the SAME changed text (`-R`) at a different new-side + // junction. Corroboration keyed on bare text sees the bystander and + // stands down; the range join then carries the bystander alone and + // drops the front hunk that actually displays the deletion. The + // divergent hunk must still be carried. + const run = Array.from({ length: 20 }, () => 'R').join('\n') + '\n'; + const front = 'F1\nF2\nF3\nF4\nF5\n'; + const tail = 'T1\nT2\nT3\nT4\nT5\n'; + const base = commit('shared-text base', { + 'st-run.ts': front + 'B-LEAD\n' + run + 'SEP\nR\n' + tail, + }); + const anchor = commit('shared-text round 1', { + 'st-run.ts': front + 'B-LEAD-EDIT\n' + run + 'SEP\n' + tail, + }); + commit('shared-text round 2', { + 'st-run.ts': + front + + 'B-LEAD-EDIT\n' + + Array.from({ length: 19 }, () => 'R').join('\n') + + '\n' + + 'SEP\n' + + tail, + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: the run deletion folded into the front hunk + // in full, the round-1 deletion as a tail hunk that shares the + // delta's changed text; the delta places the run deletion at the + // back, overlapping the bystander's range. + expect(full).toContain('@@ -3,8 +3,7 @@'); + expect(full).toContain('@@ -25,7 +24,6 @@'); + expect(full.match(/^-R$/gm)).toHaveLength(2); + expect(deltaBytes.toString('utf8')).toContain('@@ -23,7 +23,6 @@'); + expect(deltaBytes.toString('utf8').match(/^-R$/gm)).toHaveLength(1); + + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + // The front hunk — the only display of the round-2 deletion — is + // carried, and the bystander stays: the guard fails closed, so the + // whole section comes along. Over-inclusion is the chosen semantics. + expect(narrowed).toContain('@@ -3,8 +3,7 @@'); + expect(narrowed).toContain('@@ -25,7 +24,6 @@'); + expect(narrowed).toContain('+B-LEAD-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('accepts a delta whose deletion the PR diff performs too', () => { + // The control for the deletion shape: head deletes lines that stood at + // the base, so the delta's deletion hunk and the full capture's are the + // same hunk — the scope must carry it, not refuse it. + const base = commit('deletion base', { + 'd.ts': lines(30, 'D'), + 'e.ts': lines(10, 'E'), + }); + const anchor = commit('deletion round 1', { + 'd.ts': lines(30, 'D'), + 'e.ts': lines(10, 'E').replace('E2\n', 'E2-EDIT\n'), + }); + commit('deletion round 2', { + 'd.ts': lines(30, 'D').replace('D10\nD11\nD12\n', ''), + 'e.ts': lines(10, 'E').replace('E2\n', 'E2-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('-D10'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('-D10'); + expect(narrowed).not.toContain('e.ts'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('emits a whole-file deletion the delta performs too', () => { + // The whole-file-deletion shape rides two implementation choices at + // once: parseDiff clamps a `+0,0` hunk to the point range [0, 0], and + // `overlaps` is inclusive. An off-by-one in either would silently drop + // file deletions from the incremental scope while the PR diff displays + // them — the mid-file deletion control above cannot see it, its range + // shape is different. + const base = commit('rm base', { + 'f.ts': lines(10, 'F'), + 'g.ts': lines(10, 'G'), + }); + const anchor = commit('rm round 1', { + 'f.ts': lines(10, 'F').replace('F2\n', 'F2-EDIT\n'), + 'g.ts': lines(10, 'G').replace('G2\n', 'G2-EDIT\n'), + }); + git('rm', '-q', 'f.ts'); + writeFileSync( + join(repo, 'g.ts'), + lines(10, 'G').replace('G2\n', 'G2-EDIT\n').replace('G7\n', 'G7-EDIT\n'), + ); + git('add', '-A'); + git('commit', '-qm', 'rm round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + expect(deltaBytes.toString('utf8')).toContain('deleted file mode'); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('deleted file mode'); + expect(narrowed).toContain('--- a/f.ts'); + expect(narrowed).toContain('-F1'); + expect(narrowed).toContain('+G7-EDIT'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries the no-trailing-newline marker of a kept hunk', () => { + // Both rounds edit the file's last line, which ends without a trailing + // newline, so the full hunk — and the narrowed emission of it — carries + // git's `\ No newline at end of file` marker. `everyLineIsDisplayed` is + // membership-only and stays green when the marker drops or relocates (a + // trim, or a range off-by-one at the hunk's last line); pin its presence + // and position directly. + const base = commit('no-newline base', { + 'nl.ts': 'N1\nN2', + 'nl-keep.ts': lines(8, 'K'), + }); + const anchor = commit('no-newline round 1', { + 'nl.ts': 'N1\nN2-EDIT', + 'nl-keep.ts': lines(8, 'K').replace('K2\n', 'K2-EDIT\n'), + }); + commit('no-newline round 2', { + 'nl.ts': 'N1\nN2-EDIT2', + 'nl-keep.ts': lines(8, 'K').replace('K2\n', 'K2-EDIT\n'), + }); + + const full = capture(base, 'HEAD'); + const narrowed = + narrowToDelta( + captureBytes(base, 'HEAD'), + captureBytes(anchor, 'HEAD'), + )?.toString('utf8') ?? null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('nl.ts'); + expect(narrowed).not.toContain('nl-keep.ts'); + expect(narrowed).toContain('+N2-EDIT2'); + // Presence AND position: the marker follows the hunk's last line. + expect(narrowed).toContain('+N2-EDIT2\n\\ No newline at end of file'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('carries a binary-file delta section the full capture also holds', () => { + // Round 1 edits a text file; round 2 replaces a NUL-carrying blob. The + // delta's binary section names no range — the empty-range list reads as + // "emit the section whole", the hunk-less treatment — and the full + // capture renders the same change as a binary section. A battery that + // never commits binary content cannot see a regression that skips + // binary sections or mis-keys them. + writeFileSync( + join(repo, 'bin.dat'), + Buffer.from([0x47, 0x49, 0x00, 0x01, 0xff, 0x00]), + ); + const base = commit('binary base', { 'bin-text.ts': lines(8, 'T') }); + const anchor = commit('binary round 1', { + 'bin-text.ts': lines(8, 'T').replace('T3\n', 'T3-EDIT\n'), + }); + writeFileSync( + join(repo, 'bin.dat'), + Buffer.from([0x47, 0x49, 0x00, 0x02, 0xff, 0x00, 0x03]), + ); + git('add', '-A'); + git('commit', '-qm', 'binary round 2', '--no-verify'); + + const full = capture(base, 'HEAD'); + const deltaBytes = captureBytes(anchor, 'HEAD'); + // The scenario's premise: both captures render the blob change as a + // binary section with no hunks. + expect(full).toContain('Binary files a/bin.dat and b/bin.dat differ'); + expect(deltaBytes.toString('utf8')).toContain( + 'Binary files a/bin.dat and b/bin.dat differ', + ); + const narrowed = + narrowToDelta(captureBytes(base, 'HEAD'), deltaBytes)?.toString('utf8') ?? + null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain('Binary files a/bin.dat and b/bin.dat differ'); + expect(narrowed).not.toContain('bin-text.ts'); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('assembles a selected hunk beyond the argument-count ceiling', () => { + // A selected hunk over ~125k lines used to throw a RangeError from + // spreading it into a single `push` — crashing the whole fetch-pr round + // instead of degrading. A regenerated lockfile on a large long-lived PR + // is exactly such a hunk. + const N = 150_000; + const base = commit('huge base', { 'keep.ts': 'keep\n' }); + const anchor = commit('huge round 1', { + 'keep.ts': 'keep\n', + 'f.txt': lines(N, 'F'), + }); + commit('huge round 2', { + 'keep.ts': 'keep\n', + 'f.txt': lines(N, 'F').replace(`F${N / 2}\n`, `F${N / 2}-EDIT\n`), + }); + + const full = capture(base, 'HEAD'); + const narrowed = + narrowToDelta( + captureBytes(base, 'HEAD'), + captureBytes(anchor, 'HEAD'), + )?.toString('utf8') ?? null; + expect(narrowed).not.toBeNull(); + expect(narrowed).toContain(`+F${N / 2}-EDIT`); + expect(everyLineIsDisplayed(narrowed!, full)).toBe(true); + }); + + it('narrows to a subset that still parses as a diff', () => { + const base = commit('parse base', { + 'p1.ts': lines(50, 'P'), + 'p2.ts': lines(50, 'R'), + }); + const anchor = commit('parse round 1', { + 'p1.ts': lines(50, 'P').replace('P5\n', 'P5-EDIT\n'), + 'p2.ts': lines(50, 'R'), + }); + const head = commit('parse round 2', { + 'p1.ts': lines(50, 'P').replace('P5\n', 'P5-EDIT\n'), + 'p2.ts': lines(50, 'R') + .replace('R10\n', 'R10-EDIT\n') + .replace('R40\n', 'R40-EDIT\n'), + }); + + const full = capture(base, head); + const deltaBytes = captureBytes(anchor, head); + const narrowed = narrowToDelta( + captureBytes(base, head), + deltaBytes, + )!.toString('utf8'); + expect(narrowed).not.toBeNull(); + + // It is still a well-formed diff: git itself accepts it. + writeFileSync(join(repo, 'narrowed.patch'), narrowed); + expect(() => + git('apply', '--check', '--reverse', 'narrowed.patch'), + ).not.toThrow(); + expect(everyLineIsDisplayed(narrowed, full)).toBe(true); + // EVERY matching hunk survives, not just the first: p2.ts carries two + // post-anchor edit regions, and a first-match-only emission would drop + // the second from the scope while every check above stayed green. + expect(narrowed).toContain('+R10-EDIT'); + expect(narrowed).toContain('+R40-EDIT'); + }); +}); diff --git a/packages/cli/src/commands/review/lib/narrow-diff.ts b/packages/cli/src/commands/review/lib/narrow-diff.ts new file mode 100644 index 0000000000..525c1fdfba --- /dev/null +++ b/packages/cli/src/commands/review/lib/narrow-diff.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Narrow a PR's own diff to the part that changed since an anchor. +// +// This replaces a containment ORACLE. The previous design captured +// `anchor..head` separately, published it as the review scope, and then tried +// to prove after the fact that every hunk in it also appeared in the PR's own +// `base..head` diff — because a comment anchored on a line GitHub's PR diff +// does not display answers 422 and takes the whole all-or-nothing Create +// Review call with it. +// +// That proof was a hand-written match over two rendered unified diffs, and its +// acceptance surface was unbounded: six review rounds each closed the reported +// entrances and the next round found new ones — count-less headers, deletion +// junctions, lossy UTF-8 decodes, cross-hunk double-spends, content matched +// without position. Every one was the same shape: something the delta carried +// that the PR's diff did not display, arriving through a gap in the match. +// +// So the scope is not checked against the PR's diff any more; it is BUILT from +// it. The delta is read only to learn which post-image line ranges changed +// since the anchor, and the published text is assembled out of the full +// capture's own hunks. Every line the review sees is therefore a line GitHub +// displays, by construction rather than by proof, and the whole family of +// defects — along with the two refusal reasons that existed to report it — +// cannot recur. +// +// The one judgment left — which of the full capture's hunks the delta's +// ranges corroborate — fails closed the same way. A delta hunk no full hunk +// corroborates (overlaps its new-side range AND shares a changed line with, +// keyed by new-side junction) is a netted-out undo OR a Myers misplacement, +// and two alignment-dependent +// rendered diffs cannot tell those apart, so its section is emitted whole: +// over-inclusion re-reviews lines GitHub displays, while a dropped change +// would be certified unreviewed by the ledger. +// +// The two captures' NEW-side line numbers are comparable because both end at +// the same head commit. That is the only cross-capture fact this needs, and it +// is the one fact that was never in doubt. + +import { parseDiff } from './diff-plan.js'; + +/** + * The PR's own hunks that overlap what changed since the anchor. + * + * `fullBytes` is `base..head` — exactly what GitHub renders. `deltaBytes` is + * `anchor..head`, read for its post-image ranges and nothing else: not one of + * its bytes reaches the result. + * + * Returns null when there is nothing to narrow to — the caller keeps the full + * range, which is always safe because it is the review the round would have + * done anyway. Null covers, deliberately treated alike: a capture on EITHER + * side that did not decode, a delta carrying a path the full capture does not + * carry at all — the canonical "undo per feedback" round lands here when the + * undone file no longer appears in `base..head` — and a rename the full + * capture keys differently (git's rename detection resolved differently + * across the two ranges, so the change would drop from the scope under the + * key mismatch). A delta whose ranges miss the full capture's hunks does NOT + * land here: a missed hunk might be a netted-out undo, but it might equally + * be a change the two captures position disjointly, so the join fails closed + * for it — the section is emitted whole, never dropped. + */ +export function narrowToDelta( + fullBytes: Buffer, + deltaBytes: Buffer, +): Buffer | null { + const selection = selectNarrowing(fullBytes, deltaBytes); + return selection === null + ? null + : assembleSections(selection, selection.touched); +} + +/** A parsed section of the full capture, as `parseDiff` reads it. */ +type FullSection = ReturnType['files'][number]; + +/** + * What the narrowing decided, before it assembles anything. + * + * Exposed because the scope is not always exactly what the delta touched: the + * one-hop widening adds still-clean files that import a touched one, and it + * needs the same guards to have passed and the same sections to assemble out + * of. Keeping one selection means the widening cannot re-derive a set the + * refusals above already ruled out. + */ +export interface NarrowSelection { + /** The full capture's sections, in the order it rendered them. */ + readonly sections: readonly FullSection[]; + /** The full capture, decoded — the text every emitted line comes from. */ + readonly fullText: string; + /** Paths the delta touched. Every one is carried by the full capture. */ + readonly touched: ReadonlySet; +} + +/** The guard-and-select half of `narrowToDelta`. Null for the same reasons. */ +export function selectNarrowing( + fullBytes: Buffer, + deltaBytes: Buffer, +): NarrowSelection | null { + // Bytes in, bytes out. The selection below runs on decoded text, because + // that is what `parseDiff` reads — so a capture that does not survive UTF-8 + // cannot be reassembled faithfully: re-encoding would write bytes git never + // produced and give `diffSha256` a value naming a file nobody captured. A + // fatal decode rejects exactly those bytes, without materializing a + // re-encoded full-size copy just to compare, and it runs on BOTH captures: + // a lossily pre-decoded delta folds an invalid path byte onto U+FFFD, which + // can collide with a legitimate U+FFFD path the full capture carries and + // select hunks of a file that never changed since the anchor. Such a round + // keeps the full range, which is the original bytes untouched. + const decode = (bytes: Buffer): string | null => { + try { + return new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(bytes); + } catch { + return null; + } + }; + const fullText = decode(fullBytes); + const deltaText = decode(deltaBytes); + if (fullText === null || deltaText === null) return null; + if (fullText.trim() === '' || deltaText.trim() === '') return null; + const full = parseDiff(fullText); + const delta = parseDiff(deltaText); + if (full.files.length === 0 || delta.files.length === 0) return null; + + /** + * Every path the delta touched. + * + * A set, not ranges. Narrowing is per FILE now, so the only question a path + * has to answer is whether the round touched it at all — which also makes a + * hunk-less section (a mode change, a pure rename, a binary replacement) + * ordinary rather than a special case: it touches the path, so its section + * is emitted, exactly like any other. + */ + const touched = new Set(delta.files.map((f) => f.path)); + + // The two captures can key the same change differently whenever git's + // rename detection resolves differently across the two ranges — + // `base..head` is a two-tree diff with no intermediate tree. Either shape + // of divergence is a change the PR's diff displays that would silently drop + // from the published scope, so refuse to narrow instead: the round keeps + // the full range, which still displays it. + // + // Shape one: a delta path the full capture does not carry at all. + const fullPaths = new Set(full.files.map((f) => f.path)); + for (const p of touched) { + if (!fullPaths.has(p)) return null; + } + // Shape two: a rename the full capture does not key as the SAME rename. + // The path guard cannot see it — the delta keys the rename under the NEW + // path, which the full capture also carries (as a plain addition), while + // the rename's deletion half sits under the OLD path, keyed only in the + // full capture. + const fullRenames = new Map(); + for (const f of full.files) { + if (f.renameFrom !== undefined) fullRenames.set(f.path, f.renameFrom); + } + for (const f of delta.files) { + if ( + f.renameFrom !== undefined && + fullRenames.get(f.path) !== f.renameFrom + ) { + return null; + } + } + + // Whole SECTIONS, not selected hunks. + // + // The two captures are independent Myers alignments over overlapping + // content, so the hunk a change lands in is not stable between them: a run + // of identical lines — blank runs, repeated imports, regenerated tables — + // lets the same edit be attributed to the run's front in one capture and + // its back in the other. Four rounds of review each closed the reported + // position-divergence entrance and the next round found another, because + // matching hunks across two alignments is a heuristic over arbitrary + // content, exactly like the containment oracle this file replaced. The + // failure was worse than the oracle's, too: a dropped hunk left the round + // reporting `effective: true`, and the ledger then certified head as the + // next anchor, so the change was never reviewed by any round. + // + // What IS stable is which FILE a change belongs to — file identity, which + // the path and rename guards above already fail closed on. So the unit of + // narrowing is the file: a section the delta touched is emitted whole, and + // a section it did not touch is dropped. Nothing the delta performed can + // fall out of a section that is emitted entire, and every emitted line is + // still a line the PR's own diff displays. + // + // The cost is real and bounded: within a touched file the round reviews all + // of that file's PR hunks, not only the ones that moved since the anchor. + // The saving incremental review exists for is the untouched files — a round + // touching 2 of 40 reviews 2 — and that is untouched by this. + return { sections: full.files, fullText, touched }; +} + +/** + * The named sections of the full capture, in the capture's own order. + * + * Null when `paths` selects nothing — the same "nothing to narrow to" the + * caller turns into a full-range round. + */ +export function assembleSections( + selection: NarrowSelection, + paths: ReadonlySet, +): Buffer | null { + // 1-based line numbers throughout, matching `parseDiff`'s own coordinates. + const lines = selection.fullText.split('\n'); + const selected: Array<[number, number]> = []; + for (const file of selection.sections) { + if (!paths.has(file.path)) continue; + selected.push([file.diffStart, file.diffEnd]); + } + + if (selected.length === 0) return null; + // Assemble without spreading the ranges into a single `push`: a section can + // exceed the argument-count ceiling (~125k lines), and this path exists for + // exactly the large long-lived PRs that carry such sections. Safe to + // encode: every line here came from text that decoded cleanly above. + const parts = selected.map(([from, to]) => + lines.slice(from - 1, to).join('\n'), + ); + return Buffer.from(parts.join('\n') + '\n', 'utf8'); +} diff --git a/packages/cli/src/commands/review/lib/transcripts.test.ts b/packages/cli/src/commands/review/lib/transcripts.test.ts index c42ed2322e..eba2659b26 100644 --- a/packages/cli/src/commands/review/lib/transcripts.test.ts +++ b/packages/cli/src/commands/review/lib/transcripts.test.ts @@ -244,7 +244,11 @@ describe('readTranscripts — defensive parsing', () => { // and two look-alikes — a `.bak` sibling and a shell command that only // NAMES the diff — refused. const b = { agentId: 'a1', agentName: 'general-purpose', sessionId: 'S1' }; - const call = (name: string, args: object): object[] => [ + const call = ( + name: string, + args: object, + response: object = { output: 'ok' }, + ): object[] => [ { ...b, type: 'assistant', @@ -255,32 +259,53 @@ describe('readTranscripts — defensive parsing', () => { type: 'tool_result', message: { role: 'user', - parts: [{ functionResponse: { name, response: { output: 'ok' } } }], + parts: [{ functionResponse: { name, response } }], }, }, ]; file( 'agent-a1.jsonl', [ - JSON.stringify({ + // A plain object literal like its siblings — pre-stringifying it here + // would let the trailing `.map` encode it twice, so `parseTranscript` + // parses a bare string and silently drops the launch line. + { ...b, type: 'user', message: { role: 'user', parts: [{ text: 'chunk 1 of 1' }] }, - }), + }, ...call('read_file', { file_path: '/d.txt', offset: 0, limit: 40 }), ...call('read_file', { file_path: '/d.txt.bak' }), ...call('run_shell_command', { command: 'rm /d.txt' }), + // A FAILED read of the diff: names it, but the response is an error. + // Hoisting the `diffToolCalls++` / `diffReads.push` out of the + // `!isErrorPart` branch would count this as a diff read. + ...call( + 'read_file', + { file_path: '/d.txt', offset: 40, limit: 40 }, + { error: 'denied' }, + ), ] .map((r) => JSON.stringify(r)) .join('\n') + '\n', ); const [rec] = readTranscripts(undefined, ENV, '/d.txt'); + // The launch line survived — proof the fixture is single-encoded. + expect(rec.launchPrompt).toBe('chunk 1 of 1'); + // Only the ONE successful, exact-path read counts: not the `.bak` + // sibling, not the shell mention, not the denied read. expect(rec.diffToolCalls).toBe(1); // The RANGE too, not only the count: `range` is wired through the same // `namedTheDiff` decision, so dropping that wiring leaves the count // right and every chunk-coverage ruling — which reads the lines, not - // the tally — with nothing to rule on. + // the tally — with nothing to rule on. The denied read's [41, 80] is + // absent, pinning the success gate on `diffReads` as well. expect(rec.diffReads).toEqual([[1, 40]]); + // The same gate guards the evidence lists the certification atoms read + // (`openedBrief`, `readBrief`, `readFindingsPointer`): the denied read + // must stay out of them too, not only out of the diff fields. + expect(rec.successfulCallArgs).toHaveLength(3); + expect(rec.successfulReadFileArgs).toHaveLength(2); // And with no diffPath the field stays 0, whatever was read. expect(readTranscripts(undefined, ENV)[0].diffToolCalls).toBe(0); }); diff --git a/packages/cli/src/commands/review/pr-context.test.ts b/packages/cli/src/commands/review/pr-context.test.ts index 682e854bb9..096ffadfb9 100644 --- a/packages/cli/src/commands/review/pr-context.test.ts +++ b/packages/cli/src/commands/review/pr-context.test.ts @@ -2034,7 +2034,7 @@ describe('renderLedgerSection', () => { // The CONDITION, not just the instruction. Dropping the clause leaves the // tail telling the orchestrator, unconditionally and in imperative tone, // to re-run with a sha that may already have been deterministically - // refused — `not-an-ancestor`, `hunks-outside-pr-diff`, `partition-failed` + // refused — `not-an-ancestor`, `nothing-to-narrow`, `partition-failed` // — which the recovered-anchor flow says must NOT be retried. expect(anchored).toContain( "when Step 1's recovered-anchor check rules a re-run admissible", diff --git a/packages/cli/src/commands/review/run.ts b/packages/cli/src/commands/review/run.ts index f27560d707..08735b482a 100644 --- a/packages/cli/src/commands/review/run.ts +++ b/packages/cli/src/commands/review/run.ts @@ -34,11 +34,7 @@ import { writeStdoutLine, writeStderrLineSafe, } from '../../utils/stdioHelpers.js'; -import { - REVIEW_TMP_DIR, - REVIEWS_DIR, - repoRelativeOf, -} from './lib/paths.js'; +import { REVIEW_TMP_DIR, REVIEWS_DIR, repoRelativeOf } from './lib/paths.js'; import { safeTarget } from '../../utils/paths.js'; import { gitOpt } from './lib/git.js'; import { EFFORT_LEVELS, parseReviewArgs } from './parse-args.js'; diff --git a/packages/cli/src/serve/routes/goals.test.ts b/packages/cli/src/serve/routes/goals.test.ts index fcff6888d1..0360cacee1 100644 --- a/packages/cli/src/serve/routes/goals.test.ts +++ b/packages/cli/src/serve/routes/goals.test.ts @@ -64,6 +64,20 @@ const activeGoal = ( }; }; +const goalWithStatus = ( + condition: string, + status: 'paused' | 'blocked' | 'usage_limited' | 'complete', +): BridgeSessionGoal => { + const base = activeGoal(condition); + return { + ...base, + snapshot: { + ...base.snapshot, + goal: { ...base.snapshot.goal!, status }, + }, + }; +}; + const noGoal: BridgeSessionGoal = { snapshot: { v: 2, activity: 'idle', goal: null }, active: null, @@ -165,6 +179,7 @@ describe('GET /goals', () => { iterations: 0, setAt: 2000, hasActivePrompt: true, + snapshot: goals['s2'].snapshot, }, { sessionId: 's1', @@ -174,10 +189,54 @@ describe('GET /goals', () => { setAt: 1000, lastReason: 'two tests still fail', hasActivePrompt: false, + snapshot: goals['s1'].snapshot, }, ]); }); + it.each(['paused', 'blocked', 'usage_limited'] as const)( + 'lists a %s goal so its controls stay reachable', + async (status) => { + // A stopped goal is exactly the one the user needs to find in order to + // resume it; listing only active goals hides it from the Goals page. + const goals: Record = { + s1: goalWithStatus('resume me', status), + }; + const app = makeApp({ + listWorkspaceSessions: () => [summary('s1')], + getSessionGoal: async (id) => goals[id], + }); + + const res = await request(app).get('/goals'); + + expect(res.status).toBe(200); + expect(res.body.goals).toHaveLength(1); + expect(res.body.goals[0]).toMatchObject({ + sessionId: 's1', + condition: 'resume me', + }); + }, + ); + + it('filters out a completed goal', async () => { + // Without the exclusion a finished goal is listed forever. + const goals: Record = { + s1: goalWithStatus('already done', 'complete'), + s2: activeGoal('still running'), + }; + const app = makeApp({ + listWorkspaceSessions: () => [summary('s1'), summary('s2')], + getSessionGoal: async (id) => goals[id], + }); + + const res = await request(app).get('/goals'); + + expect(res.status).toBe(200); + expect( + res.body.goals.map((goal: { sessionId: string }) => goal.sessionId), + ).toEqual(['s2']); + }); + it('drops a session whose probe rejects rather than failing the whole list', async () => { vi.mocked(writeStderrLine).mockClear(); const app = makeApp({ @@ -199,6 +258,7 @@ describe('GET /goals', () => { iterations: 0, setAt: 1000, hasActivePrompt: false, + snapshot: activeGoal('keep going').snapshot, }, ]); diff --git a/packages/cli/src/serve/routes/goals.ts b/packages/cli/src/serve/routes/goals.ts index e33aa52d5a..9b248ddd2a 100644 --- a/packages/cli/src/serve/routes/goals.ts +++ b/packages/cli/src/serve/routes/goals.ts @@ -18,9 +18,8 @@ * (up to `PROBE_CONCURRENCY`), so a wedged child costs one timeout rather than * one per session. * - * Read-only: clearing a goal stays on `POST /session/:id/goal/clear`, and - * setting one stays a prompt (`/goal ` updates the owning runtime, - * which schedules the first Goal turn). + * Controls use the canonical `POST /session/:id/goal` route. This listing stays + * read-only and only projects each live runtime's current snapshot. */ import type { Application } from 'express'; @@ -86,7 +85,7 @@ async function allSettledWithLimit( return results; } -/** One row of the Goals page. */ +/** One non-terminal Goal shown on the Goals page. */ interface GoalView { sessionId: string; /** The session's label, when it has one — otherwise the client shows the id. */ @@ -102,6 +101,7 @@ interface GoalView { * that the goal specifically is running. */ hasActivePrompt: boolean; + snapshot: BridgeSessionGoal['snapshot']; } export function registerGoalsRoutes( @@ -145,17 +145,19 @@ export function registerGoalsRoutes( continue; } const { session, goal } = outcome.value; - if (!goal.active) continue; + const record = goal.snapshot.goal; + if (!record || record.status === 'complete') continue; goals.push({ sessionId: session.sessionId, displayName: session.displayName ?? null, - condition: goal.active.condition, - iterations: goal.active.iterations, - setAt: goal.active.setAt, - ...(goal.active.lastReason !== undefined - ? { lastReason: goal.active.lastReason } + condition: record.objective, + iterations: record.turnCount, + setAt: record.createdAt, + ...(record.lastReason !== undefined + ? { lastReason: record.lastReason } : {}), hasActivePrompt: session.hasActivePrompt, + snapshot: goal.snapshot, }); } if (dropped.length > 0) { diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index f841dfcc56..010253e727 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -30,6 +30,7 @@ import { type SessionGroupColor, type SessionGroupPresetColor, type SessionArchiveState, + parseGoalControlRequest, } from '@qwen-code/qwen-code-core'; import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts'; import { @@ -4357,6 +4358,46 @@ export function registerSessionRoutes( ), ); + app.post( + '/session/:id/goal', + mutate({ strict: true }), + withOwnerMutableSession( + 'POST /session/:id/goal', + async (req, res, sessionId, runtime) => { + const request = parseGoalControlRequest(safeBody(req)); + if (!request) { + res.status(400).json({ + error: 'Invalid Goal control request', + code: 'invalid_goal_control_request', + }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + res + .status(200) + .json( + await runtime.bridge.controlSessionGoal( + sessionId, + request, + clientId === undefined ? undefined : { clientId }, + ), + ); + }, + ), + ); + + app.get( + '/session/:id/goal', + withOwnerReadSession( + 'GET /session/:id/goal', + async (_req, res, sessionId, runtime) => { + const goal = await runtime.bridge.getSessionGoal(sessionId); + res.status(200).json({ snapshot: goal.snapshot }); + }, + ), + ); + app.post( '/session/:id/goal/clear', mutate({ strict: true }), @@ -6250,7 +6291,10 @@ export function registerSessionRoutes( trimmed, clientId !== undefined ? { clientId } : undefined, typeof messageId === 'string' ? messageId : undefined, - mediaBlocks ? { content: mediaBlocks } : undefined, + { + rejectIfIdle: true, + ...(mediaBlocks ? { content: mediaBlocks } : {}), + }, ); res.status(200).json(result); }, diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index eeddb2c6f8..0aa05c9261 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -89,6 +89,8 @@ import { type PrepareExtensionInstallOptions, type PreparedExtensionMutation, type SessionListItem, + type GoalControlRequest, + type GoalSnapshotV2, } from '@qwen-code/qwen-code-core'; import * as qwenCore from '@qwen-code/qwen-code-core'; import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; @@ -797,6 +799,7 @@ interface FakeBridgeOpts { message: string, context?: BridgeClientRequestContext, messageId?: string, + options?: Parameters[4], ) => { accepted: boolean; messageId?: string }; removeMidTurnImpl?: ( sessionId: string, @@ -915,6 +918,12 @@ interface FakeBridgeOpts { clearSessionGoalImpl?: ( sessionId: string, ) => Promise<{ cleared: boolean; condition?: string }>; + controlSessionGoalImpl?: ( + sessionId: string, + request: GoalControlRequest, + context?: BridgeClientRequestContext, + ) => Promise<{ snapshot: GoalSnapshotV2 }>; + getSessionGoalImpl?: AcpSessionBridge['getSessionGoal']; continueSessionImpl?: (sessionId: string) => Promise<{ accepted: boolean; interruption: 'none' | 'interrupted_prompt' | 'interrupted_turn'; @@ -1116,6 +1125,7 @@ interface FakeBridge extends AcpSessionBridge { message: string; context?: BridgeClientRequestContext; messageId?: string; + options?: Parameters[4]; }>; removeMidTurnCalls: Array<{ sessionId: string; @@ -1202,6 +1212,11 @@ interface FakeBridge extends AcpSessionBridge { taskKind: 'agent' | 'shell' | 'monitor'; }>; clearSessionGoalCalls: string[]; + controlSessionGoalCalls: Array<{ + sessionId: string; + request: GoalControlRequest; + context?: BridgeClientRequestContext; + }>; continueSessionCalls: string[]; continueSessionContexts: Array; sessionHooksCalls: string[]; @@ -1381,6 +1396,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const sessionTranscriptCalls: FakeBridge['sessionTranscriptCalls'] = []; const cancelSessionTaskCalls: FakeBridge['cancelSessionTaskCalls'] = []; const clearSessionGoalCalls: string[] = []; + const controlSessionGoalCalls: FakeBridge['controlSessionGoalCalls'] = []; const continueSessionCalls: string[] = []; const continueSessionContexts: Array = []; @@ -1722,6 +1738,34 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { opts.cancelSessionTaskImpl ?? (async () => ({ cancelled: true })); const clearSessionGoalImpl = opts.clearSessionGoalImpl ?? (async () => ({ cleared: true })); + const controlSessionGoalImpl = + opts.controlSessionGoalImpl ?? + (async (_sessionId, request) => ({ + snapshot: { + v: 2 as const, + activity: 'idle' as const, + goal: + request.action === 'create' + ? null + : { + goalId: request.expectedGoalId, + revision: request.expectedRevision, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: null }, + turnCount: 0, + activeTimeMs: 0, + createdAt: 1, + updatedAt: 1, + }, + }, + })); + const getSessionGoalImpl = + opts.getSessionGoalImpl ?? + (async () => ({ + snapshot: { v: 2 as const, activity: 'idle' as const, goal: null }, + active: null, + })); const continueSessionImpl = opts.continueSessionImpl ?? (async () => ({ accepted: false, interruption: 'none' as const })); @@ -1963,6 +2007,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionTranscriptCalls, cancelSessionTaskCalls, clearSessionGoalCalls, + controlSessionGoalCalls, continueSessionCalls, continueSessionContexts, sessionHooksCalls, @@ -2255,6 +2300,17 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { clearSessionGoalCalls.push(sessionId); return clearSessionGoalImpl(sessionId); }, + async controlSessionGoal(sessionId, request, context) { + controlSessionGoalCalls.push({ + sessionId, + request, + ...(context ? { context } : {}), + }); + return controlSessionGoalImpl(sessionId, request, context); + }, + async getSessionGoal(sessionId) { + return getSessionGoalImpl(sessionId); + }, async continueSession(sessionId, context) { continueSessionCalls.push(sessionId); continueSessionContexts.push(context); @@ -2374,7 +2430,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { ...(messageId ? { messageId } : {}), ...(options ? { options } : {}), }); - return enqueueMidTurnImpl(sessionId, message, context, messageId); + return enqueueMidTurnImpl( + sessionId, + message, + context, + messageId, + options, + ); }, removeMidTurnMessage(sessionId, messageId, context) { removeMidTurnCalls.push({ @@ -9390,6 +9452,86 @@ describe('createServeApp', () => { expect(bridge.clearSessionGoalCalls).toEqual(['s-1']); }); + it('reads and controls the canonical session Goal', async () => { + const snapshot: GoalSnapshotV2 = { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision: 3, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: null }, + turnCount: 1, + activeTimeMs: 1000, + createdAt: 1, + updatedAt: 2, + }, + }; + const bridge = fakeBridge({ + getSessionGoalImpl: async () => ({ snapshot, active: null }), + controlSessionGoalImpl: async () => ({ snapshot }), + knownClientIds: ['client-1'], + }); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const read = await request(app) + .get('/session/s-1/goal') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret'); + const controlled = await request(app) + .post('/session/s-1/goal') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .set('X-Qwen-Client-Id', 'client-1') + .send({ + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 3, + }); + + expect(read.status).toBe(200); + expect(read.body).toEqual({ snapshot }); + expect(controlled.status).toBe(200); + expect(controlled.body).toEqual({ snapshot }); + expect(bridge.controlSessionGoalCalls).toEqual([ + { + sessionId: 's-1', + request: { + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 3, + }, + context: { clientId: 'client-1' }, + }, + ]); + }); + + it('rejects an invalid Goal control before bridge dispatch', async () => { + const bridge = fakeBridge(); + const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp( + { ...tokenOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .post('/session/s-1/goal') + .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Authorization', 'Bearer secret') + .send({ action: 'pause', expectedGoalId: 'goal-1' }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_goal_control_request'); + expect(bridge.controlSessionGoalCalls).toEqual([]); + }); + it('maps goal clear bridge errors', async () => { const bridge = fakeBridge({ clearSessionGoalImpl: async (sessionId) => { @@ -9901,6 +10043,7 @@ describe('createServeApp', () => { message: 'hello', context: { clientId: 'client-9' }, messageId: 'client-mid-1', + options: { rejectIfIdle: true }, }, ]); }); @@ -9917,10 +10060,38 @@ describe('createServeApp', () => { ); expect(res.status).toBe(200); expect(bridge.enqueueMidTurnCalls).toEqual([ - { sessionId: 's-1', message: 'hi', context: { clientId: 'client-9' } }, + { + sessionId: 's-1', + message: 'hi', + context: { clientId: 'client-9' }, + options: { rejectIfIdle: true }, + }, ]); }); + it('rejects an in-flight enqueue that reaches an idle session', async () => { + const bridge = fakeBridge({ + enqueueMidTurnImpl: ( + _sessionId, + _message, + _context, + _messageId, + options, + ) => (options?.rejectIfIdle ? { accepted: false } : { accepted: true }), + }); + + const res = await midTurnPost(midTurnApp(bridge), 's-1', { + message: 'late steering', + messageId: 'late-steering-1', + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ accepted: false }); + expect(bridge.enqueueMidTurnCalls[0]?.options).toEqual({ + rejectIfIdle: true, + }); + }); + it.each([[''], [123], ['x'.repeat(129)]])( '400 when `messageId` is invalid: %j', async (messageId) => { @@ -9973,6 +10144,7 @@ describe('createServeApp', () => { sessionId: 's-1', message: 'see this', options: { + rejectIfIdle: true, content: [{ type: 'image', data: 'aW1n', mimeType: 'image/png' }], }, }, @@ -9999,7 +10171,7 @@ describe('createServeApp', () => { { sessionId: 's-1', message: 'read this', - options: { content: [resource] }, + options: { rejectIfIdle: true, content: [resource] }, }, ]); }); @@ -10101,6 +10273,7 @@ describe('createServeApp', () => { sessionId: 's-1', message: 'see this', options: { + rejectIfIdle: true, content: [ { type: 'image', diff --git a/packages/cli/src/serve/server/error-response.test.ts b/packages/cli/src/serve/server/error-response.test.ts index e3fe841d72..05b00ce960 100644 --- a/packages/cli/src/serve/server/error-response.test.ts +++ b/packages/cli/src/serve/server/error-response.test.ts @@ -138,6 +138,61 @@ describe('sendBridgeError session writer errors', () => { }); }); + it('maps an untrusted workspace bridge error to 403', () => { + const { response, status, json } = responseMock(); + const error = Object.assign(new Error('Workspace is not trusted'), { + data: { errorKind: 'untrusted_workspace', httpStatus: 403 }, + }); + + sendBridgeError(response, error); + + expect(status).toHaveBeenCalledWith(403); + expect(json).toHaveBeenCalledWith({ + error: 'Workspace is not trusted', + code: 'untrusted_workspace', + }); + }); + + it.each([ + ['goal_conflict', 409], + ['goal_invalid_transition', 409], + ['goal_persist_failed', 500], + ] as const)('maps %s to %i', (kind, expectedStatus) => { + // A persistence failure is not retryable; surfacing it as a 409 sends the + // client back to re-sync `current` and retry a write that cannot succeed, + // and the inverse turns an ordinary conflict into a 500. + const { response, status, json } = responseMock(); + const error = Object.assign(new Error('goal control failed'), { + data: { errorKind: kind }, + }); + + sendBridgeError(response, error); + + expect(status).toHaveBeenCalledWith(expectedStatus); + expect(json).toHaveBeenCalledWith({ + error: 'goal control failed', + code: kind, + }); + }); + + it('forwards the current Goal snapshot on a conflict', () => { + // The client re-syncs from `current` before retrying; dropping it leaves it + // retrying against the revision the daemon just rejected. + const { response, json } = responseMock(); + const current = { v: 2, activity: 'idle', goal: null }; + const error = Object.assign(new Error('goal revision changed'), { + data: { errorKind: 'goal_conflict', current }, + }); + + sendBridgeError(response, error); + + expect(json).toHaveBeenCalledWith({ + error: 'goal revision changed', + code: 'goal_conflict', + current, + }); + }); + it.each([ ['invalid_session_attachment_reference', 400], ['session_attachment_gone', 410], diff --git a/packages/cli/src/serve/server/error-response.ts b/packages/cli/src/serve/server/error-response.ts index 89b4b9d93c..523faf7f20 100644 --- a/packages/cli/src/serve/server/error-response.ts +++ b/packages/cli/src/serve/server/error-response.ts @@ -662,6 +662,26 @@ export function sendBridgeError( }); return; } + if (kind === 'untrusted_workspace') { + res.status(403).json({ + error: errorMessage(err), + code: kind, + }); + return; + } + if ( + kind === 'goal_conflict' || + kind === 'goal_invalid_transition' || + kind === 'goal_persist_failed' + ) { + const d = data as { current?: unknown }; + res.status(kind === 'goal_persist_failed' ? 500 : 409).json({ + error: errorMessage(err), + code: kind, + ...(d.current !== undefined ? { current: d.current } : {}), + }); + return; + } if (kind === 'branch_point_invalid') { res.status(409).json({ error: errorMessage(err), diff --git a/packages/cli/src/serve/server/telemetry-catalog.test.ts b/packages/cli/src/serve/server/telemetry-catalog.test.ts index 4c8c7a21f4..f04e17b2e9 100644 --- a/packages/cli/src/serve/server/telemetry-catalog.test.ts +++ b/packages/cli/src/serve/server/telemetry-catalog.test.ts @@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => { .map(({ method, path }) => `${method} ${path}`) .sort(); - expect(registered).toHaveLength(59); + expect(registered).toHaveLength(61); expect(registered).toEqual(catalog); }); }); diff --git a/packages/cli/src/serve/server/telemetry.test.ts b/packages/cli/src/serve/server/telemetry.test.ts index ea675d074e..3c351646d9 100644 --- a/packages/cli/src/serve/server/telemetry.test.ts +++ b/packages/cli/src/serve/server/telemetry.test.ts @@ -1062,17 +1062,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => { }); describe('legacy session telemetry route catalog', () => { - it('contains 59 unique routes with the audited 57/2 attribution split', () => { + it('contains 61 unique routes with the audited 59/2 attribution split', () => { const keys = legacySessionTelemetryRoutes.map( ({ method, path }) => `${method} ${path}`, ); - expect(keys).toHaveLength(59); - expect(new Set(keys).size).toBe(59); + expect(keys).toHaveLength(61); + expect(new Set(keys).size).toBe(61); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'handler_resolved', ), - ).toHaveLength(57); + ).toHaveLength(59); expect( legacySessionTelemetryRoutes.filter( ({ attribution }) => attribution === 'pre_resolved', diff --git a/packages/cli/src/serve/server/telemetry.ts b/packages/cli/src/serve/server/telemetry.ts index 3702373f21..a8765e1ee7 100644 --- a/packages/cli/src/serve/server/telemetry.ts +++ b/packages/cli/src/serve/server/telemetry.ts @@ -181,6 +181,18 @@ export const legacySessionTelemetryRoutes = [ attribution: 'handler_resolved', route: 'POST /session/:id/tasks/:taskId/cancel', }, + { + method: 'POST', + path: '/session/:id/goal', + attribution: 'handler_resolved', + route: 'POST /session/:id/goal', + }, + { + method: 'GET', + path: '/session/:id/goal', + attribution: 'handler_resolved', + route: 'GET /session/:id/goal', + }, { method: 'POST', path: '/session/:id/goal/clear', diff --git a/packages/core/src/goals/goal-protocol.ts b/packages/core/src/goals/goal-protocol.ts index ed5d2bcf6a..1fddb0eb17 100644 --- a/packages/core/src/goals/goal-protocol.ts +++ b/packages/core/src/goals/goal-protocol.ts @@ -120,6 +120,13 @@ export interface GoalSnapshotV2 { v: typeof GOAL_STATE_VERSION; goal: GoalRecord | null; activity: GoalActivity; + clearedGoal?: GoalOrder; +} + +export interface GoalOrder { + goalId: string; + revision: number; + updatedAt: number; } /** diff --git a/packages/core/src/goals/goal-reducer.test.ts b/packages/core/src/goals/goal-reducer.test.ts index 8e602b8e6f..7c5a1f3bff 100644 --- a/packages/core/src/goals/goal-reducer.test.ts +++ b/packages/core/src/goals/goal-reducer.test.ts @@ -625,6 +625,23 @@ describe('goal reducer', () => { }, ); + it('parses clear snapshots with their cleared goal order', () => { + const value = { + v: 2, + goal: null, + activity: 'idle', + clearedGoal: { goalId: 'g-1', revision: 3, updatedAt: 42 }, + } as const; + + expect(parseGoalSnapshotV2(value)).toEqual(value); + expect( + parseGoalSnapshotV2({ + ...value, + clearedGoal: { ...value.clearedGoal, revision: 0 }, + }), + ).toBeUndefined(); + }); + it.each(['evidence_catalog', 'checkpoint_request'] as const)( 'round-trips a %s limitKind through a persisted snapshot', (limitKind) => { diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts index 270d374a4f..c11c7b0750 100644 --- a/packages/core/src/goals/goal-reducer.ts +++ b/packages/core/src/goals/goal-reducer.ts @@ -268,25 +268,48 @@ export function parseGoalSnapshotV2( ): GoalSnapshotV2 | undefined { if ( !isRecord(value) || - !hasOnlyKeys(value, ['v', 'goal', 'activity']) || + !hasOnlyKeys(value, ['v', 'goal', 'activity', 'clearedGoal']) || value['v'] !== GOAL_STATE_VERSION || !isGoalActivity(value['activity']) ) { return undefined; } if (value['goal'] === null) { + const clearedGoal = parseGoalOrder(value['clearedGoal']); + if (value['clearedGoal'] !== undefined && !clearedGoal) return undefined; return { v: GOAL_STATE_VERSION, goal: null, activity: value['activity'], + ...(clearedGoal ? { clearedGoal } : {}), }; } + if (value['clearedGoal'] !== undefined) return undefined; const goal = parseGoalRecord(value['goal']); return goal ? { v: GOAL_STATE_VERSION, goal, activity: value['activity'] } : undefined; } +function parseGoalOrder(value: unknown): GoalSnapshotV2['clearedGoal'] { + if ( + !isRecord(value) || + !hasOnlyKeys(value, ['goalId', 'revision', 'updatedAt']) || + typeof value['goalId'] !== 'string' || + !value['goalId'] || + !isNonNegativeInteger(value['revision']) || + value['revision'] === 0 || + !isFiniteNumber(value['updatedAt']) + ) { + return undefined; + } + return { + goalId: value['goalId'], + revision: value['revision'], + updatedAt: value['updatedAt'], + }; +} + export function parseGoalStateCause( value: unknown, ): GoalStateCause | undefined { diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts index 50db7af790..488d6c304f 100644 --- a/packages/core/src/goals/goal-runtime.test.ts +++ b/packages/core/src/goals/goal-runtime.test.ts @@ -3817,6 +3817,11 @@ describe('goal runtime', () => { expect(host.preemptGoalTurn).toHaveBeenCalledOnce(); expect(host.started).toHaveLength(2); expect(runtime.getSnapshot().goal).toBeNull(); + expect(runtime.getSnapshot().clearedGoal).toEqual({ + goalId: replaced.snapshot.goal!.goalId, + revision: 1, + updatedAt: replaced.snapshot.goal!.updatedAt, + }); }); it('defensively copies response, subscriber, and getter snapshots', async () => { diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts index 9e460af861..de3f15915c 100644 --- a/packages/core/src/goals/goal-runtime.ts +++ b/packages/core/src/goals/goal-runtime.ts @@ -1419,6 +1419,15 @@ export function createGoalRuntime( v: GOAL_STATE_VERSION, goal: nextGoal, activity: 'idle', + ...(request.action === 'clear' && snapshot.goal + ? { + clearedGoal: { + goalId: snapshot.goal.goalId, + revision: snapshot.goal.revision, + updatedAt: snapshot.goal.updatedAt, + }, + } + : {}), }; try { await options.journal.recordGoalState(recordUuid, { diff --git a/packages/core/src/skills/bundled/review/DESIGN.md b/packages/core/src/skills/bundled/review/DESIGN.md index 7dd7189b03..b0903ea45b 100644 --- a/packages/core/src/skills/bundled/review/DESIGN.md +++ b/packages/core/src/skills/bundled/review/DESIGN.md @@ -548,19 +548,16 @@ So `--resume` is three small mechanisms over existing state. `fetch-pr --resume` The refusal directions all point the same way: anything that no longer matches or cannot be read (missing report, worktree moved or dirty, diff-hash mismatch, head moved, unreadable ledger) reads as "start fresh" — never as a silent continuation of stale work. The resume cap reads two counters — the resume marker's count, cross-capped by the session ledger's resume count — so deleting `resume.json` alone does not reset it. An in-run drift restart is recorded when its Step 1 re-entry refuses with `head-moved` and falls through to the fresh fetch the restart wants. And budget round stamps are deliberately dropped on resume because a span across the death gap would price a round at hours — the gate falls back to its conservative constant, whose failure direction is an early stop with a disclosure; a round-cap stop, being the CLI's own record of an exhausted cap rather than a stale time-budget stop, is kept. -## Why incremental scoping is a command, and why it widens by one import hop +## Why the incremental scope widens by one import hop -Incremental review shipped as prose: Step 1 told the orchestrator to "compute `git diff ..HEAD` and use it as the review scope", and left the mechanics to improvisation. The only improvisable route — capture the interdiff by hand and re-run `plan-diff` over it — silently degrades the plan: a `plan-diff` plan carries no `worktreePath`, no PR identity unless re-supplied, and no per-file line counts, so no `heavy` classification — which drops Agent 0, the modeled-system lens and every invariant agent from the roster, with nothing anywhere saying so. `fetch-pr --since` closes that hole the way `check-coverage` closed the receipt hole: the scope decision moves from prose into the command that already builds the plan, which validates its own inputs (the anchor is re-checked against the history — `rev-parse`, `--is-ancestor` — because a mis-scoped range reviews the wrong code, the one failure this feature must never have) and emits the plan with the same builders a full round uses, identity fields riding through and post-image line counts intact. Its failure direction is pinned: every refusal falls back to the full-range plan, so the fallback is always more review, never a skip. +The narrowing publishes the PR's own sections for the files a round touched, and `narrow-diff.ts` carries the argument for building the scope that way rather than checking a separately captured one. This is the other half, and it goes the opposite direction: the narrowing is sound only for files the delta can see. -The scope is a **slice of the PR's own diff**, never a re-capture of `since..head`, and two properties ride on that. Every hunk is byte-identical to a hunk GitHub renders, so an inline comment cannot 422 and take the whole Create Review call with it — a re-capture carries hunks the PR's diff does not contain whenever the fix round reverted lines back to base content, which is an ordinary thing for a fix round to do, and the pre-slice design had to refuse the anchor outright on it. And a file with **no hunks in the delta at all** can still be in scope, which is what makes the one-hop widening possible: an importer of a changed file is unchanged by definition, so a delta capture cannot show it, yet round 1 cleared it against the callee's old shape and (importer@head × callee@head) is a pairing no round has seen. +The widening exists because "clean" is a verdict about the code as it stood. The previous round cleared a caller against the callee it imported THEN; the fix under review moves the callee, and a scope that holds only the interdiff never re-opens the caller — the breakage retires silently, permanently, because the next clean round re-anchors past it. So every still-clean source file one import hop from a changed file re-enters the scope with its full-range hunks, and the plan records why (`incremental.interaction[]`), so the chunk brief can direct its agent at the seam — "do your uses of what changed still hold" — instead of a from-scratch re-review that re-reports what round 1 already ruled on. One hop, dependents only, source files only: the callee-side risk lives in the changed file's own chunk (its agent reads callees from the worktree), test dependents are `build-test`'s job, and a barrel re-export between caller and callee hides the edge — a documented miss that leaves exactly the floor incremental review had before widening existed. The specifier scan is a regex heuristic on purpose, and its error directions are chosen: a false positive reviews a file once more than needed, a false negative never drops below the unwidened floor. A round that only REVERTS needs none of this: the undone file is gone from the PR's own diff, so the narrowing refuses to narrow at all and the round reviews the full range — over-review, which is the direction every refusal here leans. -Scoped files carry their FULL-RANGE hunks; the interdiff only chooses which files are in scope. The first cut gave delta files their since-anchor hunks — tighter, and wrong: a fix round that RESTORES lines the previous round changed produces interdiff hunks that exist in no hunk of the PR's own `mergeBase..head` diff, and an inline comment anchored on such a line 422s the whole Create Review call, all-or-nothing — the review's entire inline output lost to one anchor. Full-range hunks are a subset of the PR diff by construction, so every anchor stays anchorable; the savings that matter were always file-level (the files skipped), not hunk-level. +It cannot be folded into the narrowing, because the file it adds is one the delta capture provably does not contain — an importer of a changed file is unchanged by definition. So it runs on the narrowing's own selection, after its guards have passed, and it only ever adds: with no edge to follow the widened round publishes exactly what the unwidened one would, which makes the narrowing the floor rather than a second path that could disagree with it. -The widening exists because "clean" is a verdict about the code as it stood. The previous round cleared a caller against the callee it imported THEN; the fix under review moves the callee, and a scope that holds only the interdiff never re-opens the caller — the breakage retires silently, permanently, because the next clean round re-anchors past it. So every still-clean source file one import hop from a changed file re-enters the scope with its full-range hunks, and the plan records why (`incremental.interaction[]`), so the chunk brief can direct its agent at the seam — "do your uses of what changed still hold" — instead of a from-scratch re-review that re-reports what round 1 already ruled on. One hop, dependents only, source files only: the callee-side risk lives in the changed file's own chunk (its agent reads callees from the worktree), test dependents are `build-test`'s job, and a barrel re-export between caller and callee hides the edge — a documented miss that leaves exactly the floor incremental review had before widening existed. The specifier scan is a regex heuristic on purpose, and its error directions are chosen: a false positive reviews a file once more than needed, a false negative never drops below the unwidened floor. The hop runs in BOTH directions, because a file the fix round reverted plays both parts: as a change it still pulls its importers in, and as an importer its own base-era calls now face whatever the PR still moves. That second direction is the one a revert makes load-bearing — round 1 changes a callee and its caller together and clears both; the fix round reverts only the caller, so the delta holds one restored file and nothing else, and the callee it strands was changed BEFORE the anchor and is unchanged since. Scoped by the delta alone the round stops `upToDate` without advancing the anchor, and (caller@base × callee@head) is a pairing no round ever sees. So the second pass resolves a restored file's imports against every file the PR still changes, not just the ones the delta moved, and the seam's moving side — the half that carries the hunks — is scoped in with it. The local flow anchors on content, not on a commit, because it has no commit to anchor on and is forbidden from making one: the reviewed state is a dirty working tree, and `local-diff.ts`'s standing constraint — nothing on the capture path writes to the index, the worktree, or any ref — rules out snapshot commits and stashes. `git hash-object` without `-w` computes the blob id of the current bytes and writes nothing, so the anchor is the hashed per-file state of exactly what the plan covered, plus the HEAD the diff was measured against. The identity is `:`, not the blob alone — an exec-bit flip or a file↔symlink typechange is its own diff lines, so identical bytes under a different mode are not an identical change; symlinks hash their link text at 120000, exactly what `git diff` renders, never the resolved target's bytes. Whatever cannot be captured faithfully — a submodule gitlink (the pinned diff flags deliberately keep those visible), a FIFO, a path git C-quoted out of an invalid-UTF-8 filename — is marked `unhashable`, which never compares equal, not even to itself: "could not capture it twice" is not "unchanged", and each of those shapes was measured comparing stable under the naive scheme, silently leaving incremental scope forever. The capture also re-snapshots the diff after hashing and withholds the candidate unless the two captures are byte-identical — the one race where the anchor could certify bytes no round reviewed, closed by refusing to anchor rather than by pretending the window is empty. HEAD is hashed into the state id AND checked as a separate hard gate — the redundancy is for legibility's sake — "HEAD moved since the last local round" is a reason a user can act on, where a mere state-id mismatch is not — and it is load-bearing: the captured diff is HEAD-vs-worktree, so under a moved HEAD identical worktree bytes describe a different change under review (a reset exposes commits no round ever read). The candidate/cache split mirrors the PR flow's marker rules: the capture writes this round's anchor deterministically on every run, and only Step 8's clean-high-effort gate promotes it to `.qwen/review-cache/`, so a fail-closed round can never anchor the next round's skip past scope nobody reviewed. -The widening exists because "clean" is a verdict about the code as it stood. The previous round cleared a caller against the callee it imported THEN; the fix under review moves the callee, and a scope that holds only the interdiff never re-opens the caller — the breakage retires silently, permanently, because the next clean round re-anchors past it. So every still-clean source file one import hop from a changed file re-enters the scope with its full-range hunks, and the plan records why (`incremental.interaction[]`), so the chunk brief can direct its agent at the seam — "do your uses of what changed still hold" — instead of a from-scratch re-review that re-reports what round 1 already ruled on. One hop, dependents only, source files only: the callee-side risk lives in the changed file's own chunk (its agent reads callees from the worktree), test dependents are `build-test`'s job, and a barrel re-export between caller and callee hides the edge — a documented miss that leaves exactly the floor incremental review had before widening existed. The specifier scan is a regex heuristic on purpose, and its error directions are chosen: a false positive reviews a file once more than needed, a false negative never drops below the unwidened floor. - ## Why three more mutation operators, and why each is shaped the way it is Statement deletion with a safety-verb filter was the first operator because it has the cleanest survivor semantics. But a live maintainer re-verification produced a survivor list the deletion operator cannot express — and every entry mapped to one of three shapes, each with equally crisp semantics: diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index 3a0fa42be6..7656755fb3 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -167,13 +167,13 @@ Based on the parsed `target.type`: Worktree isolation: all subsequent steps (agents, build/test) operate inside `worktreePath`, not the user's working tree. Cache and reports (Step 8) are written to the **main project directory**, not the worktree. - **Incremental review check** (high effort only — neither low nor medium consults or updates the cache): read `.qwen/review-cache/pr-.json` **before** `fetch-pr` (it is a local file; nothing about it needs the fetch) and, when it holds a `lastCommitSha`, pass BOTH fields to the fetch verbatim: `--since --since-model ` (omit `--since-model` when the cache has no `lastModelId`; do not substitute anything for it). **Copy them; do not compare them to anything.** The same-model gate is ruled inside `fetch-pr`, over the identity the runtime published — "clean up to `lastCommitSha`" is the recorded identity's verdict, and the command validates an anchor against the HISTORY, never against who certified it, so an anchor from another identity is ancestrally perfect and would scope this round past code it never reviewed. A hand-applied version of that gate was wrong every time it was written, because `{{model}}` interpolates the BARE model id while every identity the CLI records is provider-qualified: two provider configurations exposing one model name compared equal and passed each other's gate. When the gate refuses, the report says `cross-model-anchor` and the round reviews the full diff. Read the cache's `findings` ledger either way (Step 6 owes each entry a ruling; the work list carries across models, only the anchor does not). **You never run `git` against an anchor yourself** — no `git diff ..HEAD`, no `cat-file`, no `merge-base --is-ancestor`: the command validates the anchor against the fetched history and computes the scoped diff and chunk plan in one pass, because a hand-run check is one a run can skip, and the hand-computed delta was exactly the shape this skill forbids everywhere else (the diff is a file the CLI writes, never a command you run). The report's `incremental` field is the decision; act on it with `lastModelId` from the cache and the current model ID (`{{model}}`): - - `effective: true` (no `upToDate`) → the report's diff and plan ARE the incremental scope; continue with them exactly as with a full plan. The scope is **a slice of the PR's own diff**, not a re-capture of `since..head`: the delta decides WHICH files are in scope and every scoped file carries its **full-range hunks**, so an inline comment can only ever anchor on a line GitHub's PR diff actually renders. The file set is **widened by one import hop** — a still-clean source file that imports a changed one re-enters, because round 1 cleared it against the callee's old shape — and `incremental.scope` names each file's class (`deltaFiles`, `interaction` with the edges that pulled each one in, `contextFileCount`, `restoredFileCount`); a chunk brief built for an interaction file points its agent at that seam instead of a from-scratch re-review. The superseded full range stays on disk at `incremental.fullDiffPath` for any later step that needs the whole PR. **Also read the cache's `findings` ledger** (older caches have none — then there is nothing to track): these are the previous round's findings with their ids, and Step 6 owes each of them a ruling this round. (Reachable only under a matching identity: the gate inside the command is what keeps a cross-model anchor from scoping anything.) + - `effective: true` (no `upToDate`) → the report's diff and plan ARE the incremental scope (`since..head`); continue with them exactly as with a full plan. The file set is **widened by one import hop**: a still-clean source file that imports a changed one re-enters the scope with its own full-range hunks, because the round before cleared it against the callee's OLD shape. `incremental.scope` names each file's class — `deltaFiles` (touched since the anchor), `interaction[]` (widened back in, each with the edges that did it), `contextFileCount` (weighed and passed over) — and a chunk brief built for an interaction file points its agent at that seam instead of a from-scratch re-review. **Also read the cache's `findings` ledger** (older caches have none — then there is nothing to track): these are the previous round's findings with their ids, and Step 6 owes each of them a ruling this round. (Reachable only under a matching identity: the gate inside the command is what keeps a cross-model anchor from scoping anything.) - `upToDate: true` **and** `comment.effective` is false (no `--comment` flag, and `review.comment` not enabled in settings) → inform the user "No new changes since last review" (this branch consumes no plan, so it holds even when `diffPath` is null), run `"${QWEN_CODE_CLI:-qwen}" review cleanup pr-` to remove the worktree just created, and stop. **This branch does not apply on a resumed run** (`resumed: true` from the resume branch below): a continuation's `incremental` field is the interrupted attempt's history, not this run's decision, and taking the stop/cleanup here would destroy the very state `--resume` reused. - `upToDate: true` **but** `comment.effective` is true (the `--comment` flag or the `review.comment` setting) → run the full review anyway — the report already holds the full-range diff and plan for exactly this flow, unless `diffPath` is null, which is the ordinary degraded state (partial coverage, disclosed) rather than a scoping fact. Inform the user: "No new code changes. Running review to post inline comments." - `reason: cross-model-anchor` → the cached anchor was certified by another identity, so it was not used. Continue on the full-range plan (or, when `diffPath` is null, on the degraded state its siblings name). The command already said which identity certified it and which is running; repeat that to the user rather than restating it from the cache. - - `effective: false` → the anchor was refused and the report says why. **Every reason names a CAUSE** — `not-an-ancestor` (a rebase or force-push); `unknown-commit`; `behind-merge-base` (the base moved past the anchor, e.g. a partial merge landed, and scoping to it would review base history the PR does not contain); `containment-unverified` (the round has nothing to slice FROM and a re-run would not change that: either the delta could not be READ — a capture that returned something the diff parser cannot name, so the file list it decides scope from is the parser's emptiness rather than the tree's — or `git merge-base` succeeded and found no common ancestor at all, which is a cross-fork PR with unrelated history. A base FETCH that failed is the other shape and reports `base-untrusted` instead, because a re-run repeats the component that failed); `lineage-unfollowable` (a file changed since the anchor carries no section of the PR's own diff under that name — a rename before the anchor whose hunks now sit under the pre-rename name, which the slice cannot follow); `base-untrusted` (the base could not be fetched, or a probe of it could not answer — an exit above 1, or a kill — so the merge-base clamp, the check that keeps the anchor from scoping into base history the PR does not contain, could not be ruled. Two probes answer under this reason: the merge-base probe itself, and the restoration probe that compares a delta file's tree entry against the base — either failing is the surface, not a verdict about the anchor); `capture-failed` (a capture threw); `partition-failed` (the diff would not tile). **Whether a PLAN exists is a separate field: `diffPath`.** Non-null → the diff and plan are the full range; continue as a full review. Null → no diff exists at all: that is the `diffPath: null` degraded state (partial coverage, disclosed), whatever the reason says. Do not read one field for both facts — a reason that meant "planless" as well as "why" is what put deterministic refusals into the retry class below. The previous round's ledger is still owed its rulings in every refusal. + - `effective: false` → the anchor was refused and the report says why. **Every reason names a CAUSE** — `not-an-ancestor` (a rebase or force-push); `unknown-commit`; `behind-merge-base` (the base moved past the anchor, e.g. a partial merge landed, and scoping to it would review base history the PR does not contain); `nothing-to-narrow` (the narrowing found nothing it could publish — all deterministic and all safe, because the round keeps the full range: an ordinary "undo per feedback" revert that puts lines back the way the base had them, so the PR's own diff no longer displays the undone FILE at all (a file the PR still displays does not refuse — the join fails closed and publishes its section whole instead); a capture on either side whose bytes do not survive a UTF-8 round trip; a delta the parser cannot read; and a fail-closed refusal where the two captures key the same change differently — a path or a rename git resolves differently across the two ranges — so narrowing would drop a change the PR's diff displays); `base-untrusted` (the base could not be fetched, so the clamp that keeps an anchor from scoping wider than the PR's diff could not be ruled); `capture-failed` (a capture threw, or the base fetch or merge-base resolution failed); `partition-failed` (the diff would not tile). **Whether a PLAN exists is a separate field: `diffPath`.** Non-null → the diff and plan are the full range; continue as a full review. Null → no diff exists at all: that is the `diffPath: null` degraded state (partial coverage, disclosed), whatever the reason says. Do not read one field for both facts — a reason that meant "planless" as well as "why" is what put deterministic refusals into the retry class below. The previous round's ledger is still owed its rulings in every refusal. - - **When the cache has no anchor, the PR itself carries one** (high effort only, same as the cache). The file being absent is the NORMAL state everywhere except the machine that ran the last review — CI, another clone, a colleague's checkout — and it used to mean the incremental range silently degraded to the full diff every time, which is precisely the cost incremental review exists to avoid. The anchor now rides the posted review: the machine ledger's marker carries `sha`, the head the last clean round reviewed, and `pr-context` writes it into the side file `qwen-review-pr--prev-ledger.json` with the rest of the ledger. So when the cache had no anchor to pass — including the case where it HELD one that the cache-path gate withheld, because `lastModelId` was another model's: the marker may carry an anchor THIS model certified, and a round that stops at the cache would never look — **or the anchor it passed was refused** (`incremental.effective: false` — a rebase or force-push retires a cached anchor exactly when another environment may have posted a newer round whose marker still holds a valid one): proceed with the setup batch as usual, and when the side file lands with a `sha` — **different from the one already refused, OR the same sha when the refusal was infrastructure** (`base-untrusted`, `capture-failed`: the anchor was never ruled invalid, and the component that failed — a base fetch, a merge-base probe, a capture — is re-run by the re-run. Every other reason is deterministic for the same sha and must NOT be retried: a validity refusal re-refuses, `partition-failed` re-fails the partitioner on identical bytes, and the containment reasons re-rule identically) —, **re-run the `fetch-pr` command from above with `--since --since-model `, taking `model` from beside the `sha` in the side file — REPLACING any `--since` and any `--since-model` the command already carries, never appending a second of either** (a repeated flag is one flag with two values; the CLI takes the last, but a command that reads as two anchors is a command nobody can check; and `--since` without its `--since-model` is refused as `cross-model-anchor` — a missing certifier is a mismatch, not a pass — so the pair travels together) — the PR ref is already fetched so the re-run is cheap, and it rebuilds the worktree, diff and chunk plan scoped to the delta, with the validation the old flow asked you to hand-run (`cat-file`, `merge-base --is-ancestor`) inside the command where it cannot be skipped. Then act on the new report's `incremental` field exactly as the cache path above does (**the same-model gate on this path is RULED FOR YOU, not left to you to apply**: the marker carries `model` beside its `sha` — the identity that certified the range — and `pr-context`'s ledger section states the verdict outright, either "the same-model contract HOLDS" or "**Do NOT pass the reviewed-at sha as `--since`**". Obey that sentence and do not compare the two identities yourself: the marker's `model` is a PROVIDER-QUALIFIED identity (`@`) while `{{model}}` above is the bare model id, so they are not the same kind of string — comparing them by hand either never matches, which throws away this whole recovery path, or matches loosely, which accepts another provider's same-named model and scopes past code it never reviewed. A ledger section that states no verdict — because the side file survived from an earlier round the recovery could not re-vouch — is a mismatch: review the full range. The ledger's round is used only for precedence, and an `upToDate` anchor from the side file stops only when `comment.effective` is false). The decision lands AFTER the setup batch but BEFORE any agent launches, which is where the money is (a same-SHA stop still runs `cleanup`; it just fires three cheap commands later than the cache's fast path would have). An anchor that fails validation falls back to the full diff with the reason in the report, exactly as a rebased cache sha does. Two edges, both decided for you: if the side file's `round` is **higher** than the cache's, prefer the side file's sha — the cache is stale by a round some other environment posted; and a side file with no `sha` field means the last posted round was fail-closed (`compose-review` withholds the anchor then — Step 8 names the conditions), had its ledger truncated by the marker's size caps (a partial work list must not certify a range — the dropped entries would fall outside the next round's scope and retire silently), or predates the field — in every case there is no anchor to recover, and the review is full-range. (The side file may also carry `commitId` — the previous review's own `commit_id`. That is Step 6's **age reference** for the convergence posture, present even on fail-closed rounds; it is never an anchor, and scoping the diff to it would skip exactly the range a fail-closed round could not certify.) + - **When the cache has no anchor, the PR itself carries one** (high effort only, same as the cache). The file being absent is the NORMAL state everywhere except the machine that ran the last review — CI, another clone, a colleague's checkout — and it used to mean the incremental range silently degraded to the full diff every time, which is precisely the cost incremental review exists to avoid. The anchor now rides the posted review: the machine ledger's marker carries `sha`, the head the last clean round reviewed, and `pr-context` writes it into the side file `qwen-review-pr--prev-ledger.json` with the rest of the ledger. So when the cache had no anchor to pass — including the case where it HELD one that the cache-path gate withheld, because `lastModelId` was another model's: the marker may carry an anchor THIS model certified, and a round that stops at the cache would never look — **or the anchor it passed was refused** (`incremental.effective: false` — a rebase or force-push retires a cached anchor exactly when another environment may have posted a newer round whose marker still holds a valid one): proceed with the setup batch as usual, and when the side file lands with a `sha` — **different from the one already refused, OR the same sha when the refusal was infrastructure** (`base-untrusted`, `capture-failed`: the anchor was never ruled invalid, and the component that failed — a base fetch, a merge-base resolution, a capture — is re-run by the re-run. One shape of `capture-failed` retries ONCE, not forever: a base-less refusal (a null `mergeBaseSha`) means the base fetch failed (`baseFetchFailed: true`) and no local base ref remained, or `git merge-base` itself failed on a non-answer exit. The failed component IS re-run by the re-run, but the exit status cannot split the members — git exits 128 identically for a transient fetch fault and for a deterministic refusal (the base branch deleted on the remote — the refspec fetch fails every time), and the merge-base probe folds its surface failures the same way — so a second refusal of the same shape on the same sha is the deterministic member. Retry that one, once. Every other reason is deterministic for the same sha and must NOT be retried: a validity refusal re-refuses; a planless `partition-failed` always carries a `mergeBaseSha` — with no base nothing is captured and an empty diff cannot fail to tile — so both ranges were in hand and both refused to tile, which the re-run reproduces exactly, do not retry it; `nothing-to-narrow` re-narrows identically: the same two captures select the same hunks, and a capture that failed a UTF-8 round trip fails it again — and its base-less shape (a null `mergeBaseSha` with `baseFetchFailed: false`) is NOT retryable: the fetch succeeded and `git merge-base` found no common ancestor at all (a cross-fork PR with unrelated history), which a re-run reproduces exactly) —, **re-run the `fetch-pr` command from above with `--since ` — REPLACING any `--since` it already carries, never appending a second one** (a repeated flag is one flag with two values; the CLI takes the last, but a command that reads as two anchors is a command nobody can check) — the PR ref is already fetched so the re-run is cheap, and it rebuilds the worktree, diff and chunk plan scoped to the delta, with the validation the old flow asked you to hand-run (`cat-file`, `merge-base --is-ancestor`) inside the command where it cannot be skipped. Then act on the new report's `incremental` field exactly as the cache path above does (**the same-model gate on this path is RULED FOR YOU, not left to you to apply**: the marker carries `model` beside its `sha` — the identity that certified the range — and `pr-context`'s ledger section states the verdict outright, either "the same-model contract HOLDS" or "**Do NOT pass the reviewed-at sha as `--since`**". Obey that sentence and do not compare the two identities yourself: the marker's `model` is a PROVIDER-QUALIFIED identity (`@`) while `{{model}}` above is the bare model id, so they are not the same kind of string — comparing them by hand either never matches, which throws away this whole recovery path, or matches loosely, which accepts another provider's same-named model and scopes past code it never reviewed. A ledger section that states no verdict — because the side file survived from an earlier round the recovery could not re-vouch — is a mismatch: review the full range. The ledger's round is used only for precedence, and an `upToDate` anchor from the side file stops only when `comment.effective` is false). The decision lands AFTER the setup batch but BEFORE any agent launches, which is where the money is (a same-SHA stop still runs `cleanup`; it just fires three cheap commands later than the cache's fast path would have). An anchor that fails validation falls back to the full diff with the reason in the report, exactly as a rebased cache sha does. Two edges, both decided for you: if the side file's `round` is **higher** than the cache's, prefer the side file's sha — the cache is stale by a round some other environment posted; and a side file with no `sha` field means the last posted round was fail-closed (`compose-review` withholds the anchor then — Step 8 names the conditions), had its ledger truncated by the marker's size caps (a partial work list must not certify a range — the dropped entries would fall outside the next round's scope and retire silently), or predates the field — in every case there is no anchor to recover, and the review is full-range. (The side file may also carry `commitId` — the previous review's own `commit_id`. That is Step 6's **age reference** for the convergence posture, present even on fail-closed rounds; it is never an anchor, and scoping the diff to it would skip exactly the range a fail-closed round could not certify.) - **Resuming an interrupted run (`--resume`)**: when `parse-args` reported `resume.effective: true`, append `--resume` to the `fetch-pr` command above, and decide `--effort` off `effortSource`, not off whether the word `--effort` was typed. Pass the resolved level whenever `effortSource` is `explicit` **or `forced-by-comment`** (the `--comment` flag or the `review.comment` setting forces high — parse-args announces "running at high effort"); omit it ONLY when `effortSource` is `default`. `fetch-pr` cannot tell a passed-through default from a chosen level: the interrupted run may have recorded a different one, and handing it the resolved default refuses the resume (`effort-mismatch`) whose fresh fall-through discards the very state `--resume` exists to save — blaming an effort nobody asked for. Omitted, the continuation pins to the recorded level. A level this invocation actually requires — a user's explicit `--effort`, or the high that `--comment` forces — that differs from the recorded one is NOT a passed-through default: pass it, so a recorded lower level refuses (`effort-mismatch`) and runs fresh at the level this invocation needs. That is right — different effort is different work, and posting authority raising the required depth is different work too, never a silent pin. Omitting a `forced-by-comment` high is the trap: `fetch-pr` has no `--comment` input and reads `requestedEffort` only from `--effort`, so the null would pin the continuation at the recorded sub-high level while `--comment` stays effective — the "effective comment at medium effort" state the medium-tier rules call impossible, posting nothing (medium skips posting) or posting from a pipeline missing the high-only passes the forcing exists to guarantee. `fetch-pr` rules on the interrupted attempt's on-disk state itself (worktree still at `fetchedSha` and clean, diff bytes unchanged, PR head unmoved, resume cap unspent — every probe is a fact it gathers, none is yours to assert) and prints one JSON line on stdout. Branch on it: - **`{"resumed": true, ...}`** — this run continues the interrupted one. The report at the `--out` path is the PREVIOUS attempt's, deliberately left untouched (its mtime is the run epoch every downstream fence keys on); read it for the worktree, plan and diff, which are all reused. The report's `incremental` field is now HISTORY, not a decision to re-take: a resumed run proceeds on the reused plan and does NOT re-enter the incremental check above — in particular it never takes the `upToDate: true` stop/cleanup branch, which runs `cleanup pr-` and would destroy the exact worktree and lease `--resume` just saved (the interrupted attempt was a `--comment` full review of an up-to-date PR; resuming it without `--comment` effective in THIS invocation would otherwise route it straight into "No new changes since last review" and abandon it). Then rebuild your working state from disk before launching anything: @@ -821,7 +821,7 @@ The ledger has two sources, in priority order: **the PR itself** — `pr-context **Bounded family → enumerate; unbounded family → collapse to one class-level finding.** This rule governs **both** sibling-entrance paths — the ledger `fixed` ruling above and the open-blocker re-check below — so the two cannot disagree. **Boundedness is a property of the SURFACE, not of the round count**: a family is unbounded when its entrances cannot be enumerated and closed one by one — hand-rolled parsing of untrusted input, matching of a rendered format, a re-implemented grammar. (Recurrence across rounds is a _signal_ that prompts the question, never the definition — a finite family can recur twice; an infinite one is unbounded on round one.) For a **bounded** family, enumerate: a still-open sibling is a fresh finding, exactly as the two paths already say. For an **unbounded** one, do not file sibling N — **collapse the whole family into one class-level finding under a single stable id**: `the surface is unbounded; close it structurally — a real parser / the tool's authoritative output / a fail-closed decision — not entrance by entrance`. **The class finding carries one demonstrated entrance as its witness** — the concrete input and the line(s) producing the wrong outcome — so it clears Step 4's high-confidence bar and posts (a shape with no concrete corner confirms only low, and low-confidence findings are terminal-only — they never post and never reach the ledger this backstop reads); the entrance is the class's evidence, not a separate finding. That one finding **supersedes** the family's prior sibling ids: rule each `superseded by ` (the disposition above), fold it in as evidence, and do not re-report it under its own id — the class id is the only one that carries forward, so the next round's **ledger marker** recovers one entry, not N, and a prior sibling that resurfaces on the PR as its own thread is ruled `superseded`, not re-posted. **A brand-new sibling found in the current round** — by a Step 3 finder or Step 5 auditor over the incremental diff, while the class finding is already on the ledger and open — folds the same way: into the class finding's re-report as evidence under the class id at Step 6 rendering, never filed under its own id. **Its severity is the demonstrated risk of the shape** (Agent 3b's rule), Critical when the surface can be fooled into a wrong result, its own severity otherwise — an infinite surface is not automatically a blocker. **Supersession preserves the strongest evidence**: collapse a family only when the class finding is filed at **at least the highest severity AND confidence any absorbed sibling demonstrated** — a proven high-confidence Critical entrance must not be retired behind a low-confidence or non-Critical class finding (which never posts, so nothing carries the block and the defect stays live at a zero-Critical verdict). If the class finding cannot carry that strength, keep the prior Critical open until an equally-strong verified class finding replaces it. Rule the class finding `fixed` only when the structural change lands, never when the latest entrance is patched. (Agent 3b's enumeration-trap check files this same finding _prospectively_ in round 1, before the siblings accumulate; this rule is its cross-round backstop for a family already being enumerated.) -Render the rulings as a short table at the top of the Findings section — id, one-line title, this round's status — so the report reads as a continuation, the way a human reviewer's round-2 comment opens with "M1 is fixed". The incremental scope rule does not conflict with this: the _files_ reviewed are those changed in `lastCommitSha..HEAD` (plus the one-hop interaction files `fetch-pr --since` widened the scope with), but a ledger ruling reads the code at HEAD, which every agent already has. +Render the rulings as a short table at the top of the Findings section — id, one-line title, this round's status — so the report reads as a continuation, the way a human reviewer's round-2 comment opens with "M1 is fixed". The incremental scope rule does not conflict with this: the _diff_ reviewed is `lastCommitSha..HEAD`, but a ledger ruling reads the code at HEAD, which every agent already has. ### The convergence posture (round-aware posting, PR re-reviews only) @@ -1291,7 +1291,7 @@ Create the `.qwen/reviews/` directory if it doesn't exist. **For PR worktree mod Report content should include: - Review timestamp and target description -- **Provenance — the commits and the toolchain.** The head SHA reviewed (`fetchedSha` from the fetch report) and the base it was diffed against — **the range the round actually used**: `mergeBaseSha` in every case — a delta-scoped round publishes sections of `merge-base..head`, so the merge base is the published range's left side, and new reports carry no `incremental.diffBase`; honour the field when an older report still carries it, since that CLI published a `diffBase..head` capture and recording the merge base there hands the later reader a scope the run never had — plus the platform and the Node/npm versions the gates ran on, and one line per gate with its result (`build`, `test`, `script-lint`, `test-efficacy`, `test-plan` — ran / clean / failed / skipped, and why). A saved report is read by someone who cannot re-derive what it was about: without the SHA pair a "Verdict: Approve" names no commit, so it can be neither checked against the PR nor distinguished from an approval of a different head; and without the gate line a reader cannot tell a gate that passed from one that never ran. Both facts are already in reports this run has open — copy them, do not re-measure. +- **Provenance — the commits and the toolchain.** The head SHA reviewed (`fetchedSha` from the fetch report) and the base it was diffed against — **the range the round actually used**: `incremental.diffBase` on a delta-scoped round (`incremental.effective` and no `upToDate`), `mergeBaseSha` on every other, since recording the merge base for a round that reviewed `diffBase..head` hands the later reader a scope the run never had — plus the platform and the Node/npm versions the gates ran on, and one line per gate with its result (`build`, `test`, `script-lint`, `test-efficacy`, `test-plan` — ran / clean / failed / skipped, and why). A saved report is read by someone who cannot re-derive what it was about: without the SHA pair a "Verdict: Approve" names no commit, so it can be neither checked against the PR nor distinguished from an approval of a different head; and without the gate line a reader cannot tell a gate that passed from one that never ran. Both facts are already in reports this run has open — copy them, do not re-measure. - Effort level the review ran at (low / medium / high; **low** findings are marked unverified — medium and high verify them in Step 4) - Diff statistics (files changed, lines added/removed) — omit if reviewing a file with no diff - Build & test results (Agent 7 output summary) — high and medium effort diff --git a/packages/core/src/skills/bundled/review/SKILL.test.ts b/packages/core/src/skills/bundled/review/SKILL.test.ts index f6032e16f4..edaf6ff337 100644 --- a/packages/core/src/skills/bundled/review/SKILL.test.ts +++ b/packages/core/src/skills/bundled/review/SKILL.test.ts @@ -126,92 +126,64 @@ describe('bundled review skill', () => { expect(body).toContain( '**Whether a PLAN exists is a separate field: `diffPath`.**', ); - // …and the re-run instruction, carrying BOTH flags: a re-run with only - // `--since` can never pass the command's same-model gate — a missing - // certifier is a mismatch, not a pass (the gate refuses it as - // `cross-model-anchor`) — so the recovery this paragraph exists for is - // dead on every flow without the model beside the sha. Plus the - // flag-replacement rule that keeps a second `--since` from reading as - // two anchors. + // …and the re-run instruction, including the flag-replacement rule that + // keeps a second `--since` from reading as two anchors. expect(body).toContain( - 're-run the `fetch-pr` command from above with `--since --since-model `', - ); - expect(body).toContain( - 'REPLACING any `--since` and any `--since-model` the command already carries', + 'REPLACING any `--since` it already carries, never appending a second one', ); }); it('pins which refusal reasons the recovery flow may retry', () => { - // The orchestrator's recovery loop acts on this prose alone. The - // retryable class is the infrastructure reasons — a base fetch, a - // merge-base probe, a capture — whose components a re-run repeats; - // widening it re-refuses a dead anchor every round forever. + // The orchestrator's recovery loop acts on this prose alone, and the + // producer deliberately manufactures both planless shapes. Deleting the + // retry exception strands the one shape a re-run fixes; widening the + // retryable set re-refuses a dead anchor every round forever. const body = skillBody(); expect(body).toContain( 'Every other reason is deterministic for the same sha and must NOT be retried', ); + expect(body).toContain('Retry that one, once.'); + // The once-cap's re-keyed shape: a base-less `capture-failed` is the + // retryable class, but git's exit status cannot split its transient + // member from its deterministic one (a deleted remote base exits 128 + // identically), so the retry is bounded to one. expect(body).toContain( - 'the component that failed — a base fetch, a merge-base probe, a capture — is re-run by the re-run', + 'One shape of `capture-failed` retries ONCE, not forever', ); - // The retryable set's MEMBERSHIP, not just the clause's existence: - // widening the parenthetical (say, with `partition-failed`) makes an - // orchestrator retry a deterministic refusal every round forever — the - // exact loop this test's own comment warns about. + expect(body).toContain('`baseFetchFailed: true`'); + // The re-key's premise: a planless partition failure cannot be + // base-less, so the cap no longer keys on `partition-failed` at all. expect(body).toContain( - '(`base-untrusted`, `capture-failed`: the anchor was never ruled invalid', + 'a planless `partition-failed` always carries a `mergeBaseSha`', ); - // The rules-load exception the deleted `baseFetchFailed: true` pin used - // to cover — still live: on a failed base fetch the unresolvable ref - // makes load-rules report "no rules found", indistinguishable from a - // repo with none, silently enforcing none. + // The narrowing reason is deterministic for the same sha like every other + // non-infrastructure one: the same two captures select the same hunks. A + // future edit moving it into the retryable set would re-narrow to nothing + // every round, forever. + expect(body).toContain('`nothing-to-narrow` re-narrows identically'); + expect(body).toContain('found no common ancestor at all'); + // The narrowing reason's definition in the enumeration and the retryable + // set's membership, pinned outright: the recovery loop reads both, and a + // rename of the one or a widening of the other ships green without them. expect(body).toContain( - 'except when the fetch report recorded `baseFetchFailed: true`', - ); - // The ONE-exception paragraph this test used to pin named a shape the - // CLI can no longer produce — `partition-failed` implies a base - // resolved, because every publish site needs one — and the transient - // shape it carved out now arrives as `base-untrusted`, already - // retryable under the infrastructure clause above. - expect(body).not.toContain('Retry that one, once.'); - }); - - it('pins the per-reason descriptions the retry split rests on', () => { - // The FETCH-vs-containment distinction is load-bearing on retry: a - // flappy base fetch must stay retryable (`base-untrusted`) and a - // base-free cross-fork history deterministic (`containment-unverified`) - // — swapping the two sentences reclassifies one into the other, and the - // orchestrator stops retrying what it should retry. And - // `lineage-unfollowable` is a reason the CLI emits (the fetch-pr refuse - // tree), so it owes a recovery bullet like every other reason. - const body = skillBody(); - expect(body).toContain('`lineage-unfollowable`'); - expect(body).toContain( - 'A base FETCH that failed is the other shape and reports `base-untrusted` instead', + '`nothing-to-narrow` (the narrowing found nothing it could publish', ); + expect(body).toContain('(`base-untrusted`, `capture-failed`:'); }); it('records the range the round actually reviewed in provenance', () => { - // A saved report is read by someone who cannot re-derive its scope. - // Slicing made every delta-scoped round publish sections of - // `merge-base..head`, so the merge base IS the range the round used; - // the field that named a delta range's left side left new reports, and - // the instruction that pointed the writer at it named a field that is - // never there — inviting an improvisation that records the anchor, a - // scope the run never had. + // A saved report is read by someone who cannot re-derive its scope, so + // recording the merge base for a round that reviewed `diffBase..head` + // hands that reader a range the run never had. + // The whole rule, not its opening clause. The discriminating CONDITION + // and the fallback half were each pinned by nothing: deleting the + // condition, flipping it to `and upToDate`, or swapping the fallback for + // `fetchedSha` all shipped this file green, and each one records a scope + // the run never had. expect(skillBody()).toContain( - '`mergeBaseSha` in every case — a delta-scoped round publishes sections of `merge-base..head`', - ); - // …and the legacy carve-out: an older report's `diffBase` still names - // the range that CLI published, so it stays authoritative there. - expect(skillBody()).toContain( - 'honour the field when an older report still carries it', - ); - // …and the discriminator that tells a writer the field is GONE on new - // reports — without it a provenance step improvises an anchor-scoped - // range the run never had. - expect(skillBody()).toContain( - 'new reports carry no `incremental.diffBase`', + '`incremental.diffBase` on a delta-scoped round (`incremental.effective` and no `upToDate`)', ); + expect(skillBody()).toContain('`mergeBaseSha` on every other'); }); it('never asks the orchestrator to derive the file-review target', () => { diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index 501b53eec2..fc8a351cc9 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -121,6 +121,8 @@ import type { DaemonWorkspaceRemovalResult, DaemonWorkspaceUpdate, HeartbeatResult, + GoalControlRequest, + GoalStateResponse, PermissionResponse, PromptContentBlock, PromptResult, @@ -3016,23 +3018,37 @@ export class DaemonClient { ); } - async sessionGoalClear( + sessionGoalClear( sessionId: string, clientId?: string, ): Promise<{ cleared: boolean; condition?: string }> { - return await this.fetchWithTimeout( - `${this.baseUrl}/session/${urlEncode(sessionId)}/goal/clear`, - { - method: 'POST', - headers: this.headers({ 'Content-Type': 'application/json' }, clientId), - body: JSON.stringify({}), - }, - async (res) => { - if (!res.ok) { - throw await this.failOnError(res, 'POST /session/:id/goal/clear'); - } - return (await res.json()) as { cleared: boolean; condition?: string }; - }, + return this.jsonRequest<{ cleared: boolean; condition?: string }>( + `/session/${urlEncode(sessionId)}/goal/clear`, + 'POST /session/:id/goal/clear', + { method: 'POST', body: {}, clientId }, + ); + } + + sessionGoal( + sessionId: string, + clientId?: string, + ): Promise { + return this.jsonRequest( + `/session/${urlEncode(sessionId)}/goal`, + 'GET /session/:id/goal', + { clientId }, + ); + } + + sessionGoalControl( + sessionId: string, + request: GoalControlRequest, + clientId?: string, + ): Promise { + return this.jsonRequest( + `/session/${urlEncode(sessionId)}/goal`, + 'POST /session/:id/goal', + { method: 'POST', body: request, clientId }, ); } diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index f86635d3d3..86443702df 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -50,6 +50,8 @@ import type { DaemonSessionTaskStatus, DaemonSessionTasksStatus, HeartbeatResult, + GoalControlRequest, + GoalStateResponse, PermissionResponse, PromptContentBlock, PromptResult, @@ -614,50 +616,43 @@ export class DaemonSessionClient { * policy. Forwards the bound `clientId` so identified clients update * their per-client timestamp instead of just the session-wide one. */ - async heartbeat(): Promise { - return await this.client.heartbeat(this.sessionId, this.clientId); + heartbeat(): Promise { + return this.client.heartbeat(this.sessionId, this.clientId); } - async artifacts(): Promise { - return await this.client.listSessionArtifacts( - this.sessionId, - this.clientId, - ); + artifacts(): Promise { + return this.client.listSessionArtifacts(this.sessionId, this.clientId); } - async addArtifact( + addArtifact( artifact: DaemonSessionArtifactInput, ): Promise { - return await this.client.addSessionArtifact( + return this.client.addSessionArtifact( this.sessionId, artifact, this.clientId, ); } - async removeArtifact( + removeArtifact( artifactId: string, ): Promise { - return await this.client.removeSessionArtifact( + return this.client.removeSessionArtifact( this.sessionId, artifactId, this.clientId, ); } - async setModel(modelId: string): Promise { - return await this.client.setSessionModel( - this.sessionId, - modelId, - this.clientId, - ); + setModel(modelId: string): Promise { + return this.client.setSessionModel(this.sessionId, modelId, this.clientId); } - async setConfigOption( + setConfigOption( configId: 'reasoning_effort', value: string, ): Promise { - return await this.client.setSessionConfigOption( + return this.client.setSessionConfigOption( this.sessionId, configId, value, @@ -665,17 +660,17 @@ export class DaemonSessionClient { ); } - async getRewindSnapshots(): Promise<{ + getRewindSnapshots(): Promise<{ snapshots: DaemonRewindSnapshotInfo[]; }> { - return await this.client.getRewindSnapshots(this.sessionId); + return this.client.getRewindSnapshots(this.sessionId); } - async rewind( + rewind( promptId: string, opts?: { rewindFiles?: boolean }, ): Promise { - return await this.client.rewindSession(this.sessionId, promptId, { + return this.client.rewindSession(this.sessionId, promptId, { clientId: this.clientId, ...(opts?.rewindFiles !== undefined ? { rewindFiles: opts.rewindFiles } @@ -683,8 +678,8 @@ export class DaemonSessionClient { }); } - async fork(directive: string): Promise { - return await this.client.forkSession( + fork(directive: string): Promise { + return this.client.forkSession( this.sessionId, { directive }, this.clientId, @@ -699,10 +694,8 @@ export class DaemonSessionClient { * child both run to completion regardless (no cross-process abort * plumbing in v1). */ - async recap(opts?: { - signal?: AbortSignal; - }): Promise { - return await this.client.recapSession(this.sessionId, { + recap(opts?: { signal?: AbortSignal }): Promise { + return this.client.recapSession(this.sessionId, { ...(opts?.signal ? { signal: opts.signal } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), }); @@ -718,11 +711,11 @@ export class DaemonSessionClient { }); } - async btw( + btw( question: string, opts?: { signal?: AbortSignal }, ): Promise { - return await this.client.btwSession(this.sessionId, question, { + return this.client.btwSession(this.sessionId, question, { ...(opts?.signal ? { signal: opts.signal } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), }); @@ -734,7 +727,7 @@ export class DaemonSessionClient { * create/attach. Accepted requests become daemon-owned even when the active * turn settles while the request is in flight. */ - async enqueueMidTurnMessage( + enqueueMidTurnMessage( message: string, opts?: { signal?: AbortSignal; @@ -742,7 +735,7 @@ export class DaemonSessionClient { content?: PromptContentBlock[]; }, ): Promise { - return await this.client.enqueueMidTurnMessage(this.sessionId, message, { + return this.client.enqueueMidTurnMessage(this.sessionId, message, { ...(opts?.signal ? { signal: opts.signal } : {}), ...(opts?.messageId ? { messageId: opts.messageId } : {}), ...(opts?.content && opts.content.length > 0 @@ -752,10 +745,10 @@ export class DaemonSessionClient { }); } - async removeMidTurnMessage( + removeMidTurnMessage( messageId: string, ): Promise { - return await this.client.removeMidTurnMessage(this.sessionId, messageId, { + return this.client.removeMidTurnMessage(this.sessionId, messageId, { ...(this.clientId ? { clientId: this.clientId } : {}), }); } @@ -818,10 +811,10 @@ export class DaemonSessionClient { }; } - async removePendingPrompt( + removePendingPrompt( promptId: string, ): Promise { - return await this.client.removePendingPrompt(this.sessionId, promptId, { + return this.client.removePendingPrompt(this.sessionId, promptId, { ...(this.clientId ? { clientId: this.clientId } : {}), }); } @@ -832,54 +825,47 @@ export class DaemonSessionClient { * automatically forwards the client id bound when the session was created * or attached. */ - async shellCommand( + shellCommand( command: string, signal?: AbortSignal, ): Promise { - return await this.client.shellCommand(this.sessionId, command, { + return this.client.shellCommand(this.sessionId, command, { ...(signal ? { signal } : {}), ...(this.clientId ? { clientId: this.clientId } : {}), }); } - async context(): Promise { - return await this.client.sessionContext(this.sessionId, this.clientId); + context(): Promise { + return this.client.sessionContext(this.sessionId, this.clientId); } - async status(): Promise { - return await this.client.sessionStatus(this.sessionId, this.clientId); + status(): Promise { + return this.client.sessionStatus(this.sessionId, this.clientId); } - async contextUsage( + contextUsage( opts: { detail?: boolean } = {}, ): Promise { - return await this.client.sessionContextUsage( - this.sessionId, - opts, - this.clientId, - ); + return this.client.sessionContextUsage(this.sessionId, opts, this.clientId); } - async supportedCommands(): Promise { - return await this.client.sessionSupportedCommands( - this.sessionId, - this.clientId, - ); + supportedCommands(): Promise { + return this.client.sessionSupportedCommands(this.sessionId, this.clientId); } - async tasks(): Promise { - return await this.client.sessionTasks(this.sessionId, this.clientId); + tasks(): Promise { + return this.client.sessionTasks(this.sessionId, this.clientId); } - async lspStatus(): Promise { - return await this.client.sessionLspStatus(this.sessionId, this.clientId); + lspStatus(): Promise { + return this.client.sessionLspStatus(this.sessionId, this.clientId); } - async cancelTask( + cancelTask( taskId: string, kind: DaemonSessionTaskStatus['kind'], ): Promise<{ cancelled: boolean }> { - return await this.client.sessionTaskCancel( + return this.client.sessionTaskCancel( this.sessionId, taskId, kind, @@ -887,12 +873,24 @@ export class DaemonSessionClient { ); } - async clearGoal(): Promise<{ cleared: boolean; condition?: string }> { - return await this.client.sessionGoalClear(this.sessionId, this.clientId); + clearGoal(): Promise<{ cleared: boolean; condition?: string }> { + return this.client.sessionGoalClear(this.sessionId, this.clientId); } - async stats(): Promise { - return await this.client.sessionStats(this.sessionId, this.clientId); + goal(): Promise { + return this.client.sessionGoal(this.sessionId, this.clientId); + } + + controlGoal(request: GoalControlRequest): Promise { + return this.client.sessionGoalControl( + this.sessionId, + request, + this.clientId, + ); + } + + stats(): Promise { + return this.client.sessionStats(this.sessionId, this.clientId); } async respondToPermission( diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 56899b4a79..b32b7c0c91 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -344,6 +344,14 @@ export type { KnownDaemonEvent, } from './events.js'; export type { + GoalActivity, + GoalControlRequest, + GoalLimitKind, + GoalRecord, + GoalSnapshotV2, + GoalStateResponse, + GoalStatus, + TranscriptCursor, DaemonAgentLevel, DaemonAgentMutationResult, DaemonGeneratedAgentContent, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index a9520856ee..9b1d23b0e3 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -15,6 +15,70 @@ export type DaemonMode = 'http-bridge' | 'native'; +/** Goal v2 wire types, duplicated here to keep the SDK independent of Core. */ +export type GoalStatus = + | 'active' + | 'paused' + | 'blocked' + | 'usage_limited' + | 'complete'; + +export type GoalActivity = 'idle' | 'running' | 'verifying'; + +export interface TranscriptCursor { + recordId: string | null; +} + +/** + * Why the runtime stopped a Goal at one of its enumerated bounds. Set alongside + * `lastReason` — that stays the human-readable half, this is the half a client + * may key behavior off (an evidence-limited Goal cannot be resumed). + */ +export type GoalLimitKind = 'evidence_catalog' | 'checkpoint_request'; + +export interface GoalRecord { + goalId: string; + revision: number; + objective: string; + status: GoalStatus; + evidenceCursor: TranscriptCursor; + turnCount: number; + activeTimeMs: number; + createdAt: number; + updatedAt: number; + lastReason?: string; + limitKind?: GoalLimitKind; +} + +export interface GoalSnapshotV2 { + v: 2; + goal: GoalRecord | null; + activity: GoalActivity; + clearedGoal?: { + goalId: string; + revision: number; + updatedAt: number; + }; +} + +export type GoalControlRequest = + | { action: 'create'; objective: string } + | { + action: 'replace' | 'edit'; + objective: string; + expectedGoalId: string; + expectedRevision: number; + } + | { + action: 'pause' | 'resume' | 'clear'; + expectedGoalId: string; + expectedRevision: number; + }; + +export interface GoalStateResponse { + snapshot: GoalSnapshotV2; +} + export interface DaemonProtocolVersions { current: string; supported: string[]; diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index b0a9464552..d62ef63dfb 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -24,6 +24,9 @@ import { import type { BranchSessionRequest, DaemonCapabilities, + GoalControlRequest, + GoalSnapshotV2, + GoalStateResponse, DaemonSessionContextStatus, DaemonSessionLspStatus, DaemonSessionOrganizationResult, @@ -37,6 +40,22 @@ import type { DaemonWorkspaceSkillsStatus, } from '../../src/daemon/types.js'; +const GOAL_SNAPSHOT: GoalSnapshotV2 = { + v: 2, + activity: 'running', + goal: { + goalId: 'goal-1', + revision: 3, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: 'record-1' }, + turnCount: 2, + activeTimeMs: 4000, + createdAt: 1000, + updatedAt: 2000, + }, +}; + function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, @@ -121,6 +140,59 @@ function recordingFetch( } describe('DaemonClient', () => { + describe('session Goal lifecycle', () => { + it('reads and controls the authoritative snapshot with client identity', async () => { + const response: GoalStateResponse = { snapshot: GOAL_SNAPSHOT }; + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, response), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const request: GoalControlRequest = { + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 3, + }; + + await expect( + client.sessionGoal('session/1', 'client-1'), + ).resolves.toEqual(response); + await expect( + client.sessionGoalControl('session/1', request, 'client-1'), + ).resolves.toEqual(response); + + expect(calls.map(({ url, method }) => ({ url, method }))).toEqual([ + { url: 'http://daemon/session/session%2F1/goal', method: 'GET' }, + { url: 'http://daemon/session/session%2F1/goal', method: 'POST' }, + ]); + expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([ + 'client-1', + 'client-1', + ]); + expect(JSON.parse(calls[1]!.body!)).toEqual(request); + }); + + it('preserves a Goal conflict body through DaemonHttpError', async () => { + const conflict = { + error: 'Goal revision is stale', + code: 'goal_conflict', + current: GOAL_SNAPSHOT, + }; + const { fetch } = recordingFetch(() => jsonResponse(409, conflict)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const error = await client + .sessionGoalControl('s-1', { + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 2, + }) + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(DaemonHttpError); + expect(error).toMatchObject({ status: 409, body: conflict }); + }); + }); + describe('normalizePendingPromptLimit', () => { it('defaults undefined to 5', () => { expect(normalizePendingPromptLimit(undefined)).toBe(5); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index d25d2a54c6..34d7d7f7a4 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -13,6 +13,10 @@ import { DaemonSessionClient, type DaemonSessionSubscribeOptions, } from '../../src/daemon/DaemonSessionClient.js'; +import type { + GoalControlRequest, + GoalSnapshotV2, +} from '../../src/daemon/types.js'; import { AutoReconnectTransport } from '../../src/daemon/AutoReconnectTransport.js'; import { DaemonTransportClosedError, @@ -21,6 +25,22 @@ import { type DaemonTransportType, } from '../../src/daemon/DaemonTransport.js'; +const GOAL_SNAPSHOT: GoalSnapshotV2 = { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision: 4, + objective: 'ship it', + status: 'paused', + evidenceCursor: { recordId: null }, + turnCount: 1, + activeTimeMs: 2000, + createdAt: 1000, + updatedAt: 3000, + }, +}; + function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, @@ -139,6 +159,42 @@ function turnCompleteFrame(promptId: string): string { } describe('DaemonSessionClient', () => { + it('binds Goal reads and controls to the session and client identity', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { snapshot: GOAL_SNAPSHOT }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + }, + }); + const request: GoalControlRequest = { + action: 'resume', + expectedGoalId: 'goal-1', + expectedRevision: 4, + }; + + await expect(session.goal()).resolves.toEqual({ snapshot: GOAL_SNAPSHOT }); + await expect(session.controlGoal(request)).resolves.toEqual({ + snapshot: GOAL_SNAPSHOT, + }); + + expect(calls.map(({ url, method }) => ({ url, method }))).toEqual([ + { url: 'http://daemon/session/s-1/goal', method: 'GET' }, + { url: 'http://daemon/session/s-1/goal', method: 'POST' }, + ]); + expect(calls.map((call) => call.headers['x-qwen-client-id'])).toEqual([ + 'client-1', + 'client-1', + ]); + expect(JSON.parse(calls[1]!.body!)).toEqual(request); + }); + it('creates or attaches a daemon session and exposes session metadata', async () => { const { fetch, calls } = recordingFetch(() => jsonResponse(200, { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 9d5dfc4d7f..f670cf584c 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -12,6 +12,7 @@ import { type DaemonSessionStatsStatus, type DaemonSettingDescriptor, type DaemonWorkspaceGitStatus, + type GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; import type { Message } from './adapters/types'; @@ -48,8 +49,30 @@ type MockConnection = { gitStatus?: DaemonWorkspaceGitStatus; voiceTarget?: VoiceWorkspaceTarget; voiceStatusRevision?: VoiceStatusRevision; + goalState?: GoalSnapshotV2; }; +function activeGoalSnapshot( + objective = 'ship it', + revision = 1, +): GoalSnapshotV2 { + return { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision, + objective, + status: 'active', + evidenceCursor: { recordId: null }, + turnCount: 2, + activeTimeMs: 1_000, + createdAt: 123, + updatedAt: 456, + }, + }; +} + type ChatEditorTestProps = { onSubmit: ( text: string, @@ -244,6 +267,17 @@ const { }), submitPermission: vi.fn().mockResolvedValue(true), clearGoal: vi.fn().mockResolvedValue(undefined), + getGoal: vi.fn().mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }), + controlGoal: vi.fn().mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }), + applyGoalSnapshot: vi.fn((sessionId: string, snapshot: unknown) => { + if (mockConnection.sessionId === sessionId) { + mockConnection.goalState = snapshot as never; + } + }), forkSession: vi.fn().mockResolvedValue({ launched: false }), sendShellCommand: vi.fn().mockResolvedValue(undefined), cancel: vi.fn().mockResolvedValue(undefined), @@ -297,6 +331,9 @@ const { updateScheduledTask: vi.fn(), deleteScheduledTask: vi.fn(), deleteModel: vi.fn().mockResolvedValue(undefined), + controlGoal: vi.fn().mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }), }, mockMcp: { initialize: vi.fn().mockResolvedValue({ accepted: true }), @@ -333,6 +370,7 @@ const { streamingState: 'idle' as StreamingState, blocks: [] as unknown[], messages: [] as unknown[], + queuedPromptHoldHistory: [] as boolean[], chatEditorRenderCount: 0, latestChatEditorProps: null as ChatEditorTestProps | null, latestToastHostElevated: false, @@ -559,15 +597,20 @@ vi.mock('./hooks/useAnimationFrameValue', () => ({ })); vi.mock('./hooks/useQueuedPrompts', () => ({ - useQueuedPrompts: () => ({ - queuedPrompts: [], - queuedTexts, - enqueuePrompt: rawEnqueuePrompt, - removeQueuedPrompt: vi.fn(), - editQueuedPrompt: vi.fn(), - editLastQueuedPrompt, - clearQueuedPrompts, - }), + useQueuedPrompts: (args: { holdQueuedPromptsLocally?: boolean }) => { + testState.queuedPromptHoldHistory.push( + args.holdQueuedPromptsLocally === true, + ); + return { + queuedPrompts: [], + queuedTexts, + enqueuePrompt: rawEnqueuePrompt, + removeQueuedPrompt: vi.fn(), + editQueuedPrompt: vi.fn(), + editLastQueuedPrompt, + clearQueuedPrompts, + }; + }, })); vi.mock('./utils/systemInfo', () => ({ @@ -4672,6 +4715,9 @@ beforeEach(() => { }; mockConnection.gitBranch = undefined; mockConnection.gitStatus = undefined; + // A loaded session always carries a Goal snapshot; tests that exercise the + // hydration window (goalState still unknown) set it back to undefined. + mockConnection.goalState = { v: 2, activity: 'idle', goal: null }; testState.ownerVersion = 0; mockWorkspace.capabilities = { workspaces: [{ id: 'primary', cwd: '/workspace', primary: true }], @@ -4717,6 +4763,7 @@ beforeEach(() => { testState.streamingState = 'idle'; testState.blocks = []; testState.messages = []; + testState.queuedPromptHoldHistory = []; testState.chatEditorRenderCount = 0; testState.latestChatEditorProps = null; testState.latestToastHostElevated = false; @@ -4810,6 +4857,12 @@ beforeEach(() => { }); mockSessionActions.submitPermission.mockResolvedValue(undefined); mockSessionActions.clearGoal.mockResolvedValue(undefined); + mockSessionActions.getGoal.mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }); + mockSessionActions.controlGoal.mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }); mockSessionActions.forkSession.mockResolvedValue({ launched: false }); mockSessionActions.sendShellCommand.mockResolvedValue(undefined); mockSessionActions.cancel.mockResolvedValue(undefined); @@ -4844,6 +4897,9 @@ beforeEach(() => { modifiedMs: 0, }); mockWorkspaceActions.loadProviders.mockResolvedValue({ current: null }); + mockWorkspaceActions.controlGoal.mockResolvedValue({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }); mockWorkspaceActions.loadPreflight.mockResolvedValue(null); mockWorkspaceActions.loadEnv.mockResolvedValue(null); mockCollectSystemInfo.mockImplementation(() => ({ @@ -5407,6 +5463,21 @@ describe('App shell command queueing', () => { ); }); + it('runs an idle shell command immediately while a Goal is active', async () => { + mockConnection.goalState = activeGoalSnapshot('keep working'); + renderApp({}); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('!pwd'); + await vi.waitFor(() => { + expect(mockSessionActions.sendShellCommand).toHaveBeenCalledWith('pwd'); + }); + }); + + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); + }); + it('blocks duplicate ! submission while session creation is in flight', async () => { mockConnection.sessionId = undefined; let resolveCreate!: () => void; @@ -10741,29 +10812,23 @@ describe('App session callbacks', () => { expect(onSessionIdChange).not.toHaveBeenCalled(); }); - it('preserves active goal for the same session and clears it after session changes', async () => { + it('exposes canonical goal state to custom footers and clears it after session changes', async () => { + const snapshots: unknown[] = []; const activeGoals: unknown[] = []; + mockConnection.goalState = activeGoalSnapshot(); const { rerender } = renderApp({ renderFooter: (props) => { + snapshots.push(props.goalSnapshot); activeGoals.push(props.activeGoal); return null; }, }); await flush(); - await act(async () => { - window.dispatchEvent( - new CustomEvent('web-shell-goal-status-active', { - detail: { - active: true, - condition: 'ship it', - setAt: 123, - }, - }), - ); - await Promise.resolve(); + expect(snapshots.at(-1)).toMatchObject({ + v: 2, + goal: { goalId: 'goal-1', objective: 'ship it' }, }); - expect(activeGoals.at(-1)).toMatchObject({ condition: 'ship it', setAt: 123, @@ -10772,29 +10837,121 @@ describe('App session callbacks', () => { mockConnection.errorStatus = 404; rerender({ renderFooter: (props) => { + snapshots.push(props.goalSnapshot); activeGoals.push(props.activeGoal); return null; }, }); await flush(); + expect(snapshots.at(-1)).toMatchObject({ + goal: { goalId: 'goal-1' }, + }); expect(activeGoals.at(-1)).toMatchObject({ condition: 'ship it', setAt: 123, }); mockConnection.sessionId = 'session-2'; + mockConnection.goalState = undefined; rerender({ renderFooter: (props) => { + snapshots.push(props.goalSnapshot); activeGoals.push(props.activeGoal); return null; }, }); await flush(); + expect(snapshots.at(-1)).toBeNull(); expect(activeGoals.at(-1)).toBeNull(); }); + it('refuses /language ui while a Goal owns the session', async () => { + // The daemon sync is what makes the agent answer in the new language; if + // it is skipped the chrome switches alone and the agent keeps replying in + // the old one for the rest of the Goal run. + const onToast = vi.fn(); + mockConnection.goalState = activeGoalSnapshot('keep working'); + renderApp({ onToast }); + await flush(); + + let accepted: boolean | undefined; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit('/language ui zh'); + await flush(); + }); + + expect(accepted).toBe(false); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(onToast).toHaveBeenCalledWith( + 'error', + "Slash commands can't be queued while a turn is running.", + ); + }); + + it('holds a composer prompt while Goal state is still hydrating', async () => { + // The session load clears `loadingTranscript` before its `goal()` fetch + // resolves, so the composer is writable while the Goal state is unknown. + // The queue-hold gate already fails closed on that state; a direct submit + // must too, or a prompt typed in that window is sent straight into a Goal + // the client has not learned about yet. + mockConnection.goalState = undefined; + renderApp(); + await flush(); + + let accepted: boolean | undefined; + await act(async () => { + accepted = testState.latestChatEditorProps?.onSubmit( + 'hello during hydration', + ); + await flush(); + }); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(rawEnqueuePrompt).toHaveBeenCalledTimes(1); + expect(rawEnqueuePrompt.mock.calls[0]?.[0]).toBe('hello during hydration'); + expect(accepted).toBe(true); + }); + + it('holds queued prompts on the first render of an active Goal', async () => { + mockConnection.goalState = activeGoalSnapshot(); + + renderApp(); + await flush(); + + expect(testState.queuedPromptHoldHistory.length).toBeGreaterThan(0); + expect(testState.queuedPromptHoldHistory).not.toContain(false); + }); + + it('restores the Goal snapshot when the same session learns its workspace', async () => { + const snapshots: unknown[] = []; + mockConnection.workspaceCwd = undefined; + mockConnection.goalState = activeGoalSnapshot(); + const { rerender } = renderApp({ + renderFooter: (props) => { + snapshots.push(props.goalSnapshot); + return null; + }, + }); + await flush(); + + act(() => { + mockConnection.workspaceCwd = '/tmp/project'; + rerender({ + renderFooter: (props) => { + snapshots.push(props.goalSnapshot); + return null; + }, + }); + }); + await flush(); + + expect(snapshots.at(-1)).toMatchObject({ + goal: { goalId: 'goal-1', objective: 'ship it' }, + }); + }); + it('gates direct submissions and dispatches compatible submit events', async () => { const onSubmitBefore = vi.fn().mockResolvedValue(undefined); const onSessionChange = vi.fn(); @@ -10936,6 +11093,28 @@ describe('App session callbacks', () => { expect(testState.latestChatEditorProps?.isPreparing).toBe(false); }); + it('keeps a daemon-bound draft when onSubmitBefore rejects', async () => { + // The direct-submission path (streaming idle) must leave the composer + // untouched when the host refuses the prompt: clearing or committing the + // editor on rejection silently discards what the user typed. + const onSubmitBefore = vi.fn().mockRejectedValue(new Error('host says no')); + const { container } = renderApp({ onSubmitBefore }); + await flush(); + + await clickSubmit(container); + await flush(); + + expect(onSubmitBefore).toHaveBeenCalledWith({ + sessionId: 'session-1', + prompt: 'hello', + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(editorCommit).not.toHaveBeenCalled(); + expect(editorClear).not.toHaveBeenCalled(); + expect(testState.prompt).toBe('hello'); + expect(testState.latestChatEditorProps?.isPreparing).toBe(false); + }); + it('cancels an approved direct submission after a session transition', async () => { let approve: (() => void) | undefined; const onSubmitBefore = vi.fn( @@ -13862,7 +14041,7 @@ describe('App session callbacks', () => { expect(editorCommit).not.toHaveBeenCalled(); }); - it('keeps daemon-bound slash command drafts when onSubmitBefore rejects', async () => { + it('keeps goal controls on the control plane instead of prompt admission', async () => { const onSubmitBefore = vi.fn().mockRejectedValue(new Error('blocked')); const { container } = renderApp({ onSubmitBefore }); await flush(); @@ -13871,13 +14050,14 @@ describe('App session callbacks', () => { await clickSubmit(container); await flush(); - expect(onSubmitBefore).toHaveBeenCalledWith({ - sessionId: 'session-1', - prompt: '/goal ship it', + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'create', + objective: 'ship it', + }); }); + expect(onSubmitBefore).not.toHaveBeenCalled(); expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); - expect(editorCommit).not.toHaveBeenCalled(); - expect(editorClear).not.toHaveBeenCalled(); }); it('refreshes background tasks after /fork launches', async () => { @@ -19894,19 +20074,305 @@ describe('App /goal command', () => { expect(rawEnqueuePrompt).not.toHaveBeenCalled(); }); - it('still sends /goal as a prompt rather than opening the page', async () => { + it('creates a goal through the canonical control plane without sending a prompt', async () => { + const { container } = renderApp(); + await flush(); + mockSessionActions.getGoal.mockClear(); + mockSessionActions.controlGoal.mockResolvedValueOnce({ + snapshot: activeGoalSnapshot('ship it'), + }); + + testState.prompt = '/goal ship it'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'create', + objective: 'ship it', + }); + }); + + expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); + expect(mockSessionActions.getGoal).toHaveBeenCalledTimes(1); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledWith( + '/goal ship it', + ); + }); + + it('refuses a composer control while another goal control is in flight', async () => { + // The strip disables its buttons while a control runs; the composer has no + // disabled state, so without this refusal both controls read the same + // snapshot, stamp the same expected revision, and the daemon rejects the + // loser with a 409 surfaced as "Failed to …the goal". + const pendingControl = deferred<{ + snapshot: ReturnType; + }>(); + mockSessionActions.controlGoal.mockReturnValueOnce(pendingControl.promise); const { container } = renderApp(); await flush(); testState.prompt = '/goal ship it'; await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledTimes(1); + }); + + testState.prompt = '/goal ship something else'; + await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); - expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + expect(mockSessionActions.controlGoal).toHaveBeenCalledTimes(1); + expect(mockStore.appendLocalUserMessage).toHaveBeenCalledTimes(1); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + await act(async () => { + pendingControl.resolve({ snapshot: activeGoalSnapshot('ship it') }); + await flush(); + }); }); - it('still routes /goal clear through the daemon clear path', async () => { + it('creates a goal as the first command while the new session is still committing', async () => { + mockConnection.sessionId = undefined; + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'session-created', + }); + mockWorkspaceActions.controlGoal.mockResolvedValueOnce({ + snapshot: activeGoalSnapshot('first objective'), + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal first objective'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockWorkspaceActions.controlGoal).toHaveBeenCalledWith( + 'session-created', + { action: 'create', objective: 'first objective' }, + ); + }); + + expect(mockSessionActions.createSession).toHaveBeenCalledOnce(); + expect(mockSessionActions.attachSession).toHaveBeenCalledOnce(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('re-syncs canonical Goal state after creating it in an allocated session', async () => { + const active = activeGoalSnapshot('first objective'); + mockConnection.sessionId = undefined; + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'session-created', + }); + mockSessionActions.attachSession.mockImplementationOnce(async () => { + mockConnection.sessionId = 'session-created'; + mockConnection.goalState = { v: 2, activity: 'idle', goal: null }; + }); + mockWorkspaceActions.controlGoal.mockResolvedValueOnce({ + snapshot: active, + }); + mockSessionActions.getGoal.mockImplementationOnce(async () => { + mockConnection.goalState = active; + return { snapshot: active }; + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal first objective'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.getGoal).toHaveBeenCalledOnce(); + }); + + expect( + mockWorkspaceActions.controlGoal.mock.invocationCallOrder[0], + ).toBeLessThan(mockSessionActions.getGoal.mock.invocationCallOrder[0]!); + expect(mockConnection.goalState).toBe(active); + }); + + it('installs the allocated-session Goal before its re-sync resolves', async () => { + // `workspaceActions.controlGoal` does not write `connection.goalState`, so + // without installing the create response the state stays goal-less for a + // whole round trip (up to the action timeout if the GET stalls): the hold + // gate reads false and a prompt typed in that window bypasses the Goal + // queue entirely. + const active = activeGoalSnapshot('first objective'); + mockConnection.sessionId = undefined; + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'session-created', + }); + mockSessionActions.attachSession.mockImplementationOnce(async () => { + mockConnection.sessionId = 'session-created'; + mockConnection.goalState = { v: 2, activity: 'idle', goal: null }; + }); + mockWorkspaceActions.controlGoal.mockResolvedValueOnce({ + snapshot: active, + }); + // The re-sync never resolves: the create response has to stand on its own. + mockSessionActions.getGoal.mockImplementationOnce( + () => new Promise(() => {}), + ); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal first objective'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.applyGoalSnapshot).toHaveBeenCalledWith( + 'session-created', + active, + ); + }); + await flush(); + + expect(mockConnection.goalState).toBe(active); + expect(testState.queuedPromptHoldHistory.at(-1)).toBe(true); + // The App's own snapshot drives the strip; asserting only the connection + // state would re-read what this test's mock wrote. + expect( + container.querySelector('[data-testid="goal-status-strip"]'), + ).not.toBeNull(); + expect(container.textContent).toContain('first objective'); + + rawEnqueuePrompt.mockClear(); + mockSessionActions.sendPrompt.mockClear(); + testState.prompt = 'bypass me'; + await act(async () => { + testState.latestChatEditorProps?.onSubmit('bypass me'); + await flush(); + }); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(rawEnqueuePrompt.mock.calls[0]?.[0]).toBe('bypass me'); + }); + + it('keeps a session-less /goal control in the composer', async () => { + // Returning true wipes the composer, so a control that cannot run without + // a session has to be refused before that happens — the async path would + // otherwise clear the text and leave only a toast. + mockConnection.sessionId = undefined; + renderApp(); + await flush(); + + let accepted: boolean | undefined; + act(() => { + accepted = testState.latestChatEditorProps?.onSubmit( + '/goal clear', + undefined, + undefined, + editorCommit, + ); + }); + await flush(); + + expect(accepted).toBe(false); + expect(editorCommit).not.toHaveBeenCalled(); + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); + expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + }); + + it('reports an objective-less /goal set through the i18n layer', async () => { + // `formatError` prefers `error.message`, so a hardcoded English string in + // the parser would reach the toast untranslated. Localized copy comes from + // the dictionaries, which the zh-CN mount below exercises. + const onToast = vi.fn(); + const { rerender } = renderApp({ onToast }); + await flush(); + + let accepted: boolean | undefined; + act(() => { + accepted = testState.latestChatEditorProps?.onSubmit( + '/goal set', + undefined, + undefined, + editorCommit, + ); + }); + await flush(); + + expect(accepted).toBe(false); + expect(editorCommit).not.toHaveBeenCalled(); + expect(onToast).toHaveBeenCalledWith( + 'error', + '/goal set requires an objective.', + ); + + act(() => rerender({ onToast, language: 'zh-CN' })); + await flush(); + act(() => { + testState.latestChatEditorProps?.onSubmit( + '/goal edit', + undefined, + undefined, + editorCommit, + ); + }); + await flush(); + + expect(onToast).toHaveBeenLastCalledWith( + 'error', + '/goal edit 需要提供目标内容。', + ); + }); + + it('keeps /goal attachments in the composer instead of discarding them', async () => { + renderApp(); + await flush(); + testState.prompt = '/goal inspect this screenshot'; + const images = [{ data: 'abc', media_type: 'image/png' }]; + + let accepted: boolean | undefined; + act(() => { + accepted = testState.latestChatEditorProps?.onSubmit( + testState.prompt, + images, + undefined, + editorCommit, + ); + }); + await flush(); + + expect(accepted).toBe(false); + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); + expect(mockWorkspaceActions.controlGoal).not.toHaveBeenCalled(); + expect(editorCommit).not.toHaveBeenCalled(); + expect(testState.prompt).toBe('/goal inspect this screenshot'); + }); + + it('drops a lazy /goal create when another session wins allocation', async () => { + mockConnection.sessionId = undefined; + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'session-created', + }); + const attach = deferred(); + mockSessionActions.attachSession.mockReturnValueOnce(attach.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/goal first objective'; + void clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.attachSession).toHaveBeenCalledOnce(); + }); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'other-session'; + rerender({}); + }); + await act(async () => { + attach.resolve(); + await attach.promise; + }); + await flush(); + + expect(mockWorkspaceActions.controlGoal).not.toHaveBeenCalled(); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalledWith( + '/goal first objective', + ); + }); + + it('refuses non-set Goal controls without allocating a session', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockConnection.sessionId = undefined; const { container } = renderApp(); await flush(); @@ -19914,11 +20380,403 @@ describe('App /goal command', () => { await clickSubmit(container); await flush(); - expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); - expect(mockSessionActions.clearGoal).toHaveBeenCalled(); + expect(mockSessionActions.createSession).not.toHaveBeenCalled(); + expect(mockSessionActions.attachSession).not.toHaveBeenCalled(); + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); + expect(mockStore.appendLocalUserMessage).not.toHaveBeenCalledWith( + '/goal clear', + ); }); - it('starts a goal in a fresh session from the Goals page', async () => { + it('replaces an existing goal with compare-and-swap identity', async () => { + const current = activeGoalSnapshot('old objective', 7); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: current }); + mockSessionActions.controlGoal.mockResolvedValueOnce({ + snapshot: activeGoalSnapshot('new objective', 8), + }); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal new objective'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'replace', + objective: 'new objective', + expectedGoalId: 'goal-1', + expectedRevision: 7, + }); + }); + + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('clears a goal directly without a confirmation or legacy clear call', async () => { + const current = activeGoalSnapshot('ship it', 4); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: current }); + const { container } = renderApp(); + await flush(); + mockSessionActions.controlGoal.mockResolvedValueOnce({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }); + + testState.prompt = '/goal clear'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'clear', + expectedGoalId: 'goal-1', + expectedRevision: 4, + }); + }); + + expect(mockSessionActions.clearGoal).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('applies explicit controls immediately while a turn is running', async () => { + const current = activeGoalSnapshot('ship it', 5); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: current }); + const { container, rerender } = renderApp(); + await flush(); + act(() => { + testState.streamingState = 'responding'; + rerender({}); + }); + + testState.prompt = '/goal pause'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 5, + }); + }); + + expect(rawEnqueuePrompt).not.toHaveBeenCalled(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('starts a canonical goal in a fresh session from the Goals page', async () => { + const onSessionIdChange = vi.fn(); + const { container } = renderApp({ onSessionIdChange }); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.clearSession.mockClear(); + mockSessionActions.getGoal.mockClear(); + mockSessionActions.controlGoal.mockResolvedValueOnce({ + snapshot: activeGoalSnapshot('all tests pass'), + }); + + await act(async () => { + await onCreateGoal('all tests pass'); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(mockSessionActions.controlGoal).toHaveBeenCalledWith({ + action: 'create', + objective: 'all tests pass', + }); + // Order is the invariant the flow exists for: dispatching the create + // before the allocation completes would start the Goal inside the + // conversation the user is leaving. + expect( + mockSessionActions.clearSession.mock.invocationCallOrder[0], + ).toBeLessThan(mockSessionActions.controlGoal.mock.invocationCallOrder[0]!); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(onSessionIdChange).not.toHaveBeenCalledWith(undefined); + expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); + }); + + it.each(['resolve', 'reject'] as const)( + 'ignores a stale Goal edit %s after the session changes', + async (outcome) => { + const goalA = activeGoalSnapshot('session A objective', 5); + const goalB = { + ...activeGoalSnapshot('session B objective', 1), + goal: { + ...activeGoalSnapshot('session B objective', 1).goal!, + goalId: 'goal-b', + }, + }; + const pending = deferred<{ snapshot: typeof goalA }>(); + mockConnection.goalState = goalA; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: goalA }); + mockSessionActions.controlGoal.mockReturnValueOnce(pending.promise); + const { container, rerender } = renderApp(); + await flush(); + + const editA = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!editA) throw new Error('session A edit control was not rendered'); + act(() => editA.click()); + const saveA = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!saveA) throw new Error('session A save control was not rendered'); + act(() => saveA.click()); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledTimes(1); + }); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-b'; + mockConnection.goalState = goalB; + rerender({}); + }); + // Session A's dialog must be gone before B's is opened: left open, it + // re-syncs its textarea from B's objective and the user edits B believing + // it is still A. (The same-session replacement case, which only the + // goalId-keyed reset effect covers, is pinned below.) + expect(document.querySelector('textarea')).toBeNull(); + const editB = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!editB) throw new Error('session B edit control was not rendered'); + act(() => editB.click()); + expect(document.querySelector('textarea')).not.toBeNull(); + + await act(async () => { + if (outcome === 'resolve') pending.resolve({ snapshot: goalA }); + else pending.reject(new Error('session A edit failed')); + await Promise.resolve(); + }); + + expect(document.querySelector('textarea')).not.toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + // The stale resolution must not install session A's goal over B's: the + // strip and the dialog would then describe the wrong session's goal. + expect(container.textContent).toContain('session B objective'); + expect(container.textContent).not.toContain('session A objective'); + expect( + document.querySelector('textarea')?.value, + ).toBe('session B objective'); + }, + ); + + it('keeps a Goal control busy latch owned by its own session', async () => { + // Session A's control settles after the user moved to B. Releasing the + // latch unconditionally re-enables B's strip mid-flight, and a second click + // dispatches a duplicate control that dies in the daemon's CAS. + const goalA = activeGoalSnapshot('goal A', 5); + const goalB = { + ...activeGoalSnapshot('goal B', 1), + goal: { + ...activeGoalSnapshot('goal B', 1).goal!, + goalId: 'goal-b', + }, + }; + const pendingA = deferred<{ snapshot: typeof goalA }>(); + const pendingB = deferred<{ snapshot: typeof goalB }>(); + mockConnection.goalState = goalA; + mockSessionActions.getGoal + .mockReturnValueOnce(pendingA.promise) + .mockReturnValueOnce(pendingB.promise); + const { container, rerender } = renderApp(); + await flush(); + + const pauseA = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pauseA) throw new Error('session A pause control was not rendered'); + act(() => pauseA.click()); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-b'; + mockConnection.goalState = goalB; + rerender({}); + }); + const pauseB = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pauseB) throw new Error('session B pause control was not rendered'); + act(() => pauseB.click()); + expect( + container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(true); + + await act(async () => { + pendingA.resolve({ snapshot: goalA }); + await flush(); + }); + + expect( + container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(true); + + await act(async () => { + pendingB.resolve({ snapshot: goalB }); + await flush(); + }); + }); + + it('keeps the busy latch when an allocated-session create settles late', async () => { + // `createGoalForAllocatedSession` shares the latch with `controlCurrentGoal` + // but used to release it unconditionally, so a create that settles after + // the user moved on re-enabled the strip under the new session's control. + const created = activeGoalSnapshot('first objective'); + const goalB = activeGoalSnapshot('goal B', 1); + mockConnection.sessionId = undefined; + mockSessionActions.createSession.mockResolvedValueOnce({ + sessionId: 'session-created', + }); + const pendingCreate = deferred<{ snapshot: typeof created }>(); + const pendingPause = deferred<{ snapshot: typeof goalB }>(); + mockWorkspaceActions.controlGoal.mockReturnValueOnce(pendingCreate.promise); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/goal first objective'; + await clickSubmit(container); + await vi.waitFor(() => { + expect(mockWorkspaceActions.controlGoal).toHaveBeenCalledTimes(1); + }); + + // The user leaves for a session that already has a Goal and pauses it. + mockSessionActions.getGoal.mockReturnValueOnce(pendingPause.promise); + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-b'; + mockConnection.goalState = goalB; + rerender({}); + }); + const pause = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('session B pause control was not rendered'); + act(() => pause.click()); + expect( + container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(true); + + await act(async () => { + pendingCreate.resolve({ snapshot: created }); + await flush(); + }); + + expect( + container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(true); + + await act(async () => { + pendingPause.resolve({ snapshot: goalB }); + await flush(); + }); + }); + + it('closes the Goal edit dialog when the same session replaces its goal', async () => { + // Only the goalId-keyed reset effect can close it here — the session key is + // unchanged — and an open dialog would re-sync its textarea to the new + // goal's objective while the user believes they are editing the old one. + const goalA = activeGoalSnapshot('goal A', 5); + const goalB = { + ...activeGoalSnapshot('goal B', 1), + goal: { + ...activeGoalSnapshot('goal B', 1).goal!, + goalId: 'goal-b', + }, + }; + mockConnection.goalState = goalA; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: goalA }); + const { container, rerender } = renderApp(); + await flush(); + + const edit = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!edit) throw new Error('edit control was not rendered'); + act(() => edit.click()); + expect(document.querySelector('textarea')).not.toBeNull(); + + act(() => { + mockConnection.goalState = goalB; + rerender({}); + }); + + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('rejects a Goal edit when the same session replaces the goal', async () => { + const goalA = activeGoalSnapshot('goal A', 5); + const goalB = { + ...activeGoalSnapshot('goal B', 1), + goal: { + ...activeGoalSnapshot('goal B', 1).goal!, + goalId: 'goal-b', + }, + }; + const pendingGoal = deferred<{ snapshot: typeof goalB }>(); + mockConnection.goalState = goalA; + mockSessionActions.getGoal.mockReturnValueOnce(pendingGoal.promise); + const { container, rerender } = renderApp(); + await flush(); + + const edit = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!edit) throw new Error('edit control was not rendered'); + act(() => edit.click()); + const save = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!save) throw new Error('save control was not rendered'); + act(() => save.click()); + act(() => { + mockConnection.goalState = goalB; + rerender({}); + }); + await act(async () => pendingGoal.resolve({ snapshot: goalB })); + + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); + }); + + it('keeps the Goals page open when canonical creation is rejected', async () => { + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.controlGoal.mockRejectedValueOnce( + new Error('daemon says no'), + ); + + await act(async () => { + await expect(onCreateGoal('all tests pass')).rejects.toThrow( + 'daemon says no', + ); + }); + + expect( + container.querySelector('[data-testid="goals-page"]'), + ).not.toBeNull(); + }); + + it('reuses the empty session left by a rejected canonical creation', async () => { const { container } = renderApp(); await flush(); @@ -19929,99 +20787,329 @@ describe('App /goal command', () => { const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); mockSessionActions.clearSession.mockClear(); - mockSessionActions.sendPrompt.mockClear(); + mockSessionActions.controlGoal.mockRejectedValueOnce( + new Error('daemon says no'), + ); await act(async () => { - await onCreateGoal('all tests pass'); + await expect(onCreateGoal('all tests pass')).rejects.toThrow( + 'daemon says no', + ); }); - - // A goal takes over its session's turns, so it starts in a NEW one - // (clearSession is how createNewSession starts one) rather than hijacking - // the conversation the user was already having. expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( - '/goal all tests pass', - expect.anything(), - ); - }); - - it('keeps the Goals page mounted across createNewSession, not just after it', async () => { - // `createNewSession` switches to the chat view itself, before any await. That - // silently defeated the deferred switch below: by the time `sendPrompt` - // rejected, the Goals page — and the form that renders the error — was already - // gone, dumping the user in an empty chat with no explanation. The handler - // passes `keepView` so the page survives until the prompt is admitted. - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); - mockSessionActions.sendPrompt.mockRejectedValueOnce( - new Error('daemon says no'), - ); - - await act(async () => { - await expect(onCreateGoal('all tests pass')).rejects.toThrow( - 'daemon says no', - ); - }); - - // createNewSession ran (a fresh session was started) … - expect(mockSessionActions.clearSession).toHaveBeenCalled(); - // … and the Goals page is STILL up, so the rejection has somewhere to land. - expect( - container.querySelector('[data-testid="goals-page"]'), - ).not.toBeNull(); - }); - - it('keeps the Goals page open when the goal prompt is rejected', async () => { - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); - mockSessionActions.sendPrompt.mockRejectedValueOnce( - new Error('daemon says no'), - ); - - await act(async () => { - await expect(onCreateGoal('all tests pass')).rejects.toThrow( - 'daemon says no', - ); - }); - - // Switching to the chat first would unmount the page, leaving the rejection - // with nowhere to render: the user would land in an empty session with no - // explanation. - expect( - container.querySelector('[data-testid="goals-page"]'), - ).not.toBeNull(); - }); - - it('switches to the chat view only after the goal prompt is admitted', async () => { - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); await act(async () => { await onCreateGoal('all tests pass'); }); + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + expect(mockSessionActions.controlGoal).toHaveBeenLastCalledWith({ + action: 'create', + objective: 'all tests pass', + }); + }); + + it('forgets a rejected goal session after leaving the Goals page', async () => { + const pendingCreate = deferred<{ + snapshot: ReturnType; + }>(); + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.clearSession.mockClear(); + mockSessionActions.controlGoal.mockReturnValueOnce(pendingCreate.promise); + + let firstCreate!: Promise; + act(() => { + firstCreate = onCreateGoal('all tests pass'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledOnce(); + }); + act(() => { + container + .querySelector('[data-testid="goals-page"] button') + ?.click(); + }); + await flush(); expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); + pendingCreate.reject(new Error('daemon says no')); + await act(async () => { + await expect(firstCreate).rejects.toThrow('daemon says no'); + }); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const retryCreate = testState.latestGoalsProps?.onCreateGoal; + if (!retryCreate) throw new Error('onCreateGoal was not recaptured'); + await act(async () => { + await retryCreate('all tests pass'); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + + it('forgets a stranded session recorded before the user leaves the page', async () => { + // Reject FIRST, leave the page second: the `[mainView]` cleanup effect is + // the only thing that forgets the stranded session in that order, and + // without it a later create reuses a session the user has since turned into + // a real conversation. + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.clearSession.mockClear(); + mockSessionActions.controlGoal.mockRejectedValueOnce( + new Error('daemon says no'), + ); + + await act(async () => { + await expect(onCreateGoal('all tests pass')).rejects.toThrow( + 'daemon says no', + ); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + + act(() => { + container + .querySelector('[data-testid="goals-page"] button') + ?.click(); + }); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const retryCreate = testState.latestGoalsProps?.onCreateGoal; + if (!retryCreate) throw new Error('onCreateGoal was not recaptured'); + await act(async () => { + await retryCreate('all tests pass'); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + + it('starts a fresh session for a goal created after a successful one', async () => { + // A failed create records its session so the retry can reuse it. The + // success path has to forget it again — otherwise the NEXT create reuses + // the session the running Goal now owns and degrades into a CAS replace + // against it. + const { container } = renderApp(); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.clearSession.mockClear(); + mockSessionActions.controlGoal.mockRejectedValueOnce( + new Error('daemon says no'), + ); + + await act(async () => { + await expect(onCreateGoal('all tests pass')).rejects.toThrow( + 'daemon says no', + ); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + + // The retry reuses the stranded session rather than piling up a blank one. + mockSessionActions.controlGoal.mockResolvedValue({ + snapshot: activeGoalSnapshot('all tests pass'), + }); + await act(async () => { + await onCreateGoal('all tests pass'); + }); + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + const nextCreate = testState.latestGoalsProps?.onCreateGoal; + if (!nextCreate) throw new Error('onCreateGoal was not recaptured'); + await act(async () => { + await nextCreate('and lint is clean'); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + + it('does not reuse a session selected while goal creation is in flight', async () => { + const pendingCreate = deferred<{ + snapshot: ReturnType; + }>(); + const { container, rerender } = renderApp(); + await flush(); + + testState.prompt = '/goal'; + await clickSubmit(container); + await flush(); + + const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; + if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); + mockSessionActions.clearSession.mockClear(); + mockSessionActions.controlGoal.mockReturnValueOnce(pendingCreate.promise); + + let firstCreate!: Promise; + act(() => { + firstCreate = onCreateGoal('all tests pass'); + }); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledTimes(1); + }); + + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'conversation-session'; + rerender({}); + }); + pendingCreate.reject(new Error('daemon says no')); + await act(async () => { + await expect(firstCreate).rejects.toThrow('daemon says no'); + }); + + const retryCreate = testState.latestGoalsProps?.onCreateGoal; + if (!retryCreate) throw new Error('onCreateGoal was not recaptured'); + mockSessionActions.controlGoal.mockResolvedValueOnce({ + snapshot: activeGoalSnapshot('all tests pass'), + }); + await act(async () => { + await retryCreate('all tests pass'); + }); + + expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); + }); + + it('locks goal controls while the current snapshot refresh is in flight', async () => { + const current = activeGoalSnapshot('ship it', 5); + const pendingGoal = deferred<{ snapshot: typeof current }>(); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockReturnValueOnce(pendingGoal.promise); + mockSessionActions.controlGoal.mockResolvedValueOnce({ snapshot: current }); + const { container } = renderApp(); + await flush(); + + const pause = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + + expect(pause.disabled).toBe(true); + act(() => pause.click()); + expect(mockSessionActions.getGoal).toHaveBeenCalledTimes(1); + + await act(async () => pendingGoal.resolve({ snapshot: current })); + await vi.waitFor(() => { + expect(mockSessionActions.controlGoal).toHaveBeenCalledTimes(1); + }); + }); + + it('does not dispatch a Goal control after the session changes during refresh', async () => { + const current = activeGoalSnapshot('ship it', 5); + const pendingGoal = deferred<{ snapshot: typeof current }>(); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockReturnValueOnce(pendingGoal.promise); + const { container, rerender } = renderApp(); + await flush(); + + const pause = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + act(() => { + testState.ownerVersion += 1; + mockConnection.sessionId = 'session-b'; + rerender({}); + }); + await act(async () => pendingGoal.resolve({ snapshot: current })); + + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); + }); + + it('releases Goal control busy state after a same-session reattach', async () => { + const current = activeGoalSnapshot('ship it', 5); + const pendingControl = deferred<{ snapshot: typeof current }>(); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockResolvedValue({ snapshot: current }); + mockSessionActions.controlGoal.mockReturnValueOnce(pendingControl.promise); + const { container, rerender } = renderApp(); + await flush(); + + const pause = container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + await vi.waitFor(() => + expect(mockSessionActions.controlGoal).toHaveBeenCalledOnce(), + ); + act(() => { + testState.ownerVersion += 1; + rerender({}); + }); + await act(async () => pendingControl.resolve({ snapshot: current })); + + expect( + container.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(false); + }); + + it('reports an edit failure after the edited Goal disappears', async () => { + const current = activeGoalSnapshot('ship it', 5); + const pendingGoal = deferred<{ + snapshot: { v: 2; activity: 'idle'; goal: null }; + }>(); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockConnection.goalState = current; + mockSessionActions.getGoal.mockReturnValueOnce(pendingGoal.promise); + const { container, rerender } = renderApp(); + await flush(); + + act(() => { + container + .querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ) + ?.click(); + }); + const save = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!save) throw new Error('save control was not rendered'); + act(() => save.click()); + act(() => { + mockConnection.goalState = { v: 2, activity: 'idle', goal: null }; + rerender({}); + }); + await act(async () => + pendingGoal.resolve({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }), + ); + + expect(consoleError).toHaveBeenCalledWith( + '[web-shell]', + expect.stringContaining('goal'), + expect.any(Error), + ); + consoleError.mockRestore(); }); it("opens a goal's session in the chat view", async () => { @@ -20089,140 +21177,6 @@ describe('App /goal command', () => { consoleError.mockRestore(); }); - it('reuses the empty session a failed goal attempt left behind', async () => { - // `sendPrompt` creates the daemon session lazily, so a prompt that fails - // after admission leaves a created-but-empty session. The form keeps the - // condition and invites a retry; if that retry started ANOTHER new session, - // every failed attempt would strand a blank chat in the sidebar. - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); - - mockSessionActions.clearSession.mockClear(); - mockSessionActions.sendPrompt.mockRejectedValueOnce( - new Error('daemon says no'), - ); - - await act(async () => { - await expect(onCreateGoal('all tests pass')).rejects.toThrow( - 'daemon says no', - ); - }); - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - - // Retry: the session from the failed attempt is still current and empty, so - // it is reused rather than abandoned. No second clearSession. - await act(async () => { - await onCreateGoal('all tests pass'); - }); - - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - expect(mockSessionActions.sendPrompt).toHaveBeenLastCalledWith( - '/goal all tests pass', - expect.anything(), - ); - }); - - it('forgets the stranded session once the user leaves the Goals page', async () => { - // The stranded session is only a scratch session while the Goals page is - // up. Leave, and the composer can talk to it — reusing it for a later goal - // would drop the goal loop on top of a real conversation, which is the very - // thing starting a fresh session exists to prevent. - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); - - mockSessionActions.clearSession.mockClear(); - mockSessionActions.sendPrompt.mockRejectedValueOnce( - new Error('daemon says no'), - ); - await act(async () => { - await expect(onCreateGoal('all tests pass')).rejects.toThrow( - 'daemon says no', - ); - }); - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - - // Leave the Goals page via its Back button, then use the session from the - // composer — it is now a real conversation, not a scratch session. - const back = container.querySelector( - '[data-testid="goals-page"] button[aria-label="back"]', - ); - if (!back) throw new Error('Back button not found'); - await act(async () => { - back.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - await flush(); - expect(container.querySelector('[data-testid="goals-page"]')).toBeNull(); - - testState.prompt = 'hello from the composer'; - await clickSubmit(container); - await flush(); - - // Re-open Goals and set a goal: it must NOT reuse the session the user has - // since been talking to. - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoalAgain = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoalAgain) throw new Error('onCreateGoal was not captured'); - mockSessionActions.clearSession.mockClear(); - - await act(async () => { - await onCreateGoalAgain('all tests pass'); - }); - - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - }); - - it('starts a fresh session again once a goal has actually been sent', async () => { - // The reuse above is only for a session stranded by a failure. Once a goal - // lands, that session belongs to it, and the next goal must not be dropped - // on top of the running one. - const { container } = renderApp(); - await flush(); - - testState.prompt = '/goal'; - await clickSubmit(container); - await flush(); - - const onCreateGoal = testState.latestGoalsProps?.onCreateGoal; - if (!onCreateGoal) throw new Error('onCreateGoal was not captured'); - - mockSessionActions.clearSession.mockClear(); - mockSessionActions.sendPrompt.mockRejectedValueOnce( - new Error('daemon says no'), - ); - await act(async () => { - await expect(onCreateGoal('first goal')).rejects.toThrow( - 'daemon says no', - ); - }); - await act(async () => { - await onCreateGoal('first goal'); - }); - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(1); - - // A brand-new goal after a successful send: fresh session again. - await act(async () => { - await onCreateGoal('second goal'); - }); - expect(mockSessionActions.clearSession).toHaveBeenCalledTimes(2); - }); - it('does not drop the goal into the current session when the new session fails', async () => { const { container } = renderApp(); await flush(); @@ -20236,13 +21190,13 @@ describe('App /goal command', () => { mockSessionActions.clearSession.mockRejectedValueOnce( new Error('daemon unreachable'), ); - mockSessionActions.sendPrompt.mockClear(); + mockSessionActions.controlGoal.mockClear(); await act(async () => { await onCreateGoal('all tests pass'); }); - expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + expect(mockSessionActions.controlGoal).not.toHaveBeenCalled(); }); }); @@ -20253,6 +21207,11 @@ describe('App manual-run orchestration (scheduled tasks)', () => { async function openRunHandler( container: HTMLElement, ): Promise<(prompt: string, sessionId: string | null) => Promise> { + mockConnection.goalState ??= { + v: 2, + activity: 'idle', + goal: null, + }; testState.prompt = '/schedule'; await clickSubmit(container); await flush(); @@ -20304,6 +21263,81 @@ describe('App manual-run orchestration (scheduled tasks)', () => { }); }); + it('rejects an unbound run before admission while Goal is active', async () => { + mockConnection.goalState = activeGoalSnapshot('keep working'); + const { container } = renderApp(); + await flush(); + const run = await openRunHandler(container); + + await act(async () => { + await expect(run('do the thing', null)).rejects.toThrow(/Goal is active/); + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('rejects an unbound run while Goal state is hydrating', async () => { + mockConnection.goalState = undefined; + const { container } = renderApp(); + await flush(); + const run = await openRunHandler(container); + mockConnection.goalState = undefined; + + await act(async () => { + await expect(run('do the thing', null)).rejects.toThrow(/Goal is active/); + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + + it('starts an unbound manual run when no session is attached yet', async () => { + // Session-less means no Goal can exist and `sendPrompt` allocates a session + // itself, so gating the run on an unknown Goal state here would make every + // Run now on a fresh workspace fail. + admitOnSend(); + const { container, rerender } = renderApp(); + await flush(); + const run = await openRunHandler(container); + act(() => { + mockConnection.sessionId = undefined; + mockConnection.goalState = undefined; + rerender({}); + }); + + await act(async () => { + await expect(run('do the thing', null)).resolves.toBeUndefined(); + }); + expect(mockSessionActions.sendPrompt).toHaveBeenCalled(); + }); + + it('waits for bound-session Goal hydration before admitting a run', async () => { + const { container, rerender } = renderApp(); + await flush(); + const run = await openRunHandler(container); + act(() => { + mockConnection.goalState = undefined; + rerender({}); + }); + + let runError: unknown; + act(() => { + void run('do the thing', 'session-1').catch((error) => { + runError = error; + }); + }); + await flush(); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + + act(() => { + mockConnection.goalState = activeGoalSnapshot('keep working'); + rerender({}); + }); + await vi.waitFor(() => { + expect((runError as Error | undefined)?.message).toMatch( + /Goal is active/, + ); + }); + expect(mockSessionActions.sendPrompt).not.toHaveBeenCalled(); + }); + it('fires a bound run immediately when its session is already active', async () => { admitOnSend(); const { container } = renderApp(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index c78c83d57c..e02f8708d1 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -48,8 +48,10 @@ import type { DaemonSessionArtifact, DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, + GoalSnapshotV2, } from '@qwen-code/sdk/daemon'; +import { isGoalGateBlocked as isGoalGateBlockedFor } from './utils/goalGate'; import { type SessionGitIntent } from './components/GitModePopover'; import { SESSION_LIST_PAGE_SIZE, @@ -90,6 +92,9 @@ import type { import type { PromptFile, PromptImage } from './adapters/promptTypes'; import type { AttachmentPreviewRequest } from './adapters/messageTypes'; import { StatusBar, type StatusBarHandle } from './components/StatusBar'; +import { GoalStatusStrip } from './components/GoalStatusStrip'; +import composerStatusStyles from './components/ComposerStatusStack.module.css'; +import { GoalEditDialog } from './components/dialogs/GoalEditDialog'; import { StreamingStatus } from './components/StreamingStatus'; import { ToastHost, @@ -160,11 +165,8 @@ import { } from './utils/splitUrl'; import { ScheduledTasksDialog } from './components/dialogs/ScheduledTasksDialog'; import { GoalsDialog } from './components/dialogs/GoalsDialog'; -import { - goalArgOf, - isGoalClearCommand, - isGoalClearKeyword, -} from './utils/goalCondition'; +import { parseWebShellGoalCommand } from './utils/goalCondition'; +import { buildGoalControlRequest } from './utils/goalControlRequest'; import { ExtensionsManagerPage } from './components/extensions/ExtensionsManagerPage'; import { PluginManagerPage } from './components/plugins/PluginManagerPage'; import { ChannelsManagerPage } from './components/channels/ChannelsManagerPage'; @@ -255,11 +257,6 @@ import { } from './components/messages/StatusMessage'; import type { SerializedMcpStatusMessage } from './components/messages/McpStatusMessage'; import { McpManagerPage } from './components/mcp/McpManagerPage'; -import { - GOAL_STATUS_ACTIVE_EVENT, - parseGoalStatusMessage, - serializeGoalStatusMessage, -} from './components/messages/GoalStatusMessage'; import { BtwMessage } from './components/messages/BtwMessage'; import { createAndAttachSessionForPrompt, @@ -489,11 +486,6 @@ function normalizeHiddenCommand(command: string): string { return command.trim().replace(/^\/+/, '').toLowerCase(); } -interface ActiveGoalStatus { - condition: string; - setAt: number; -} - interface SendPromptOptionsWithRetry { optimisticUserMessage?: boolean; images?: PromptImage[]; @@ -751,40 +743,6 @@ function retryTranscriptIdentityMatches( ); } -type GoalStatusTranscriptBlock = DaemonTranscriptBlock & { - text: string; - source?: string; - data?: unknown; -}; - -function parseGoalStatusFromBlock(block: DaemonTranscriptBlock) { - const statusBlock = block as GoalStatusTranscriptBlock; - if (statusBlock.source !== 'goal') return null; - return ( - parseGoalStatusMessage(statusBlock.data) ?? - parseGoalStatusMessage(statusBlock.text) - ); -} - -function getLatestActiveGoalFromBlocks( - blocks: readonly DaemonTranscriptBlock[], -): ActiveGoalStatus | null { - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i]; - if (block.kind !== 'status') continue; - const status = parseGoalStatusFromBlock(block); - if (!status) continue; - if (status.kind === 'set' || status.kind === 'checking') { - return { - condition: status.condition, - setAt: status.setAt ?? block.serverTimestamp ?? block.createdAt, - }; - } - return null; - } - return null; -} - interface LocalAnchoredMessage { anchorAfterId?: string; anchorIndex: number; @@ -4193,13 +4151,30 @@ export function App({ useEffect(() => { assignComposerRef(composerRef, editorRef.current ?? emptyComposerApi); }, [composerRef]); - const [activeGoal, setActiveGoal] = useState(null); - useLayoutEffect(() => setActiveGoal(null), [logicalSessionKey]); + const [goalSnapshot, setGoalSnapshot] = useState(null); + const goalSnapshotRef = useRef(null); + goalSnapshotRef.current = goalSnapshot; + const [goalControlBusy, setGoalControlBusy] = useState(false); + // Which control operation owns the busy latch, mirroring ChatPane's twin. A + // finishing operation must not release the latch under a newer one that is + // still in flight, or the strip re-enables mid-control and a second dispatch + // races the first against the same expected revision. + const goalControlOpSeqRef = useRef(0); + const goalControlOwnerRef = useRef< + { opId: number; sessionId: string | undefined } | undefined + >(undefined); + const [goalEditOpen, setGoalEditOpen] = useState(false); + const [goalEditError, setGoalEditError] = useState(null); + useLayoutEffect(() => { + setGoalSnapshot(null); + goalControlOwnerRef.current = undefined; + setGoalControlBusy(false); + setGoalEditOpen(false); + setGoalEditError(null); + }, [logicalSessionKey]); const [isCreatingMissingSession, setIsCreatingMissingSession] = useState(false); const creatingMissingSessionRef = useRef(false); - const activeGoalRef = useRef(null); - activeGoalRef.current = activeGoal; const { followupState, onAcceptFollowup, @@ -5375,6 +5350,16 @@ export function App({ }, []); const connectionRef = useRef(connection); connectionRef.current = connection; + /** + * Whether a local action must be held back because a Goal owns the session. + * Reads the latest connection through the ref so callers get the gate as of + * call time; the fail-closed hydration convention lives in the shared + * predicate, which every Goal gate in the client shares. + */ + const isGoalGateBlocked = useCallback( + () => isGoalGateBlockedFor(connectionRef.current), + [], + ); const refreshActiveSessionDisplayName = useCallback(async () => { const activeConnection = connectionRef.current; if (!activeConnection.sessionId || !activeConnection.workspaceCwd) return; @@ -5523,28 +5508,25 @@ export function App({ const onSessionCreatedRef = useRef(onSessionCreated); onSessionCreatedRef.current = onSessionCreated; /** - * The session a failed `/goal` submit left behind. + * The session a failed Goal creation left behind. * - * Setting a goal starts a fresh session and then sends `/goal ` - * into it, but the daemon session is not created by the "new session" step — - * `ensureSessionForPrompt` creates it lazily *inside* `sendPrompt`. So a - * prompt that fails leaves a session that exists but never got its goal. + * Creating a Goal from the Goals page allocates a fresh session and then + * installs the Goal in it; the daemon session is created lazily, so an + * attempt that fails leaves a session that exists but never got its Goal. * - * The Goals form keeps the condition and lets the user retry. Without this - * ref every retry would abandon that session and create another, piling up - * blank chats in the sidebar. Remembering it lets the retry reuse it — no - * session is ever deleted. + * The form keeps the condition and lets the user retry. Without this ref + * every retry would abandon that session and create another, piling up blank + * chats in the sidebar. Remembering it lets the retry reuse it — no session + * is ever deleted. * * Only valid while the Goals page stays mounted. The moment the user leaves, * that session is reachable from the composer and may stop being a scratch - * session, so the effect below forgets it: a later goal then starts a fresh + * session, so the effect below forgets it: a later Goal then starts a fresh * session rather than landing on top of a conversation. */ const strandedGoalSessionRef = useRef(undefined); useEffect(() => { - if (mainView !== 'goals') { - strandedGoalSessionRef.current = undefined; - } + if (mainView !== 'goals') strandedGoalSessionRef.current = undefined; }, [mainView]); const ensureSessionForPrompt = useCallback(() => { const currentSessionId = connectionRef.current.sessionId; @@ -6125,7 +6107,11 @@ export function App({ [pushToast], ); const handleFailedPromptRetry = useCallback(() => { - if (sessionWriteBlockedRef.current || promptPreparationOwnerRef.current) { + if ( + sessionWriteBlockedRef.current || + promptPreparationOwnerRef.current || + isGoalGateBlocked() + ) { return; } let failed = failedPromptRef.current; @@ -6261,6 +6247,7 @@ export function App({ t, updateFailedPrompt, updateUnknownPromptAdmission, + isGoalGateBlocked, ]); const canMutateMidTurn = connection.capabilities?.features.includes( @@ -6277,6 +6264,7 @@ export function App({ queuedTexts, enqueuePrompt: rawEnqueuePrompt, removeQueuedPrompt, + insertQueuedPrompt, editQueuedPrompt, editLastQueuedPrompt, clearQueuedPrompts, @@ -6291,6 +6279,10 @@ export function App({ canInjectMidTurnMedia, workspaceFileActions: artifactWorkspaceActions, streamingState, + holdQueuedPromptsLocally: + connection.sessionId !== undefined && + (connection.goalState === undefined || + connection.goalState.goal?.status === 'active'), sessionActions, store, editorRef, @@ -7072,7 +7064,7 @@ export function App({ reloadWorkspaceSettings(), ]); }; - if (streamingStateRef.current !== 'idle') { + if (streamingStateRef.current !== 'idle' || isGoalGateBlocked()) { handleLanguageChange(previousLanguage); blockLocalCommandDuringTurn(); return; @@ -7095,6 +7087,7 @@ export function App({ selectedLanguage, sessionActions, sessionOwnerGuard, + isGoalGateBlocked, ], ); @@ -7565,43 +7558,29 @@ export function App({ ]); useEffect(() => { - const nextGoal = getLatestActiveGoalFromBlocks(blocks); - setActiveGoal((current) => { - if (!nextGoal) return current ? null : current; - if ( - current?.condition === nextGoal.condition && - current.setAt === nextGoal.setAt - ) { - return current; - } - return nextGoal; - }); - }, [blocks]); + setGoalSnapshot(connection.goalState ?? null); + }, [connection.goalState, connection.sessionId, logicalSessionKey]); + const connectionGoalComplete = + connection.goalState?.goal?.status === 'complete'; useEffect(() => { - const onGoalStatusActive = (event: Event) => { - const detail = ( - event as CustomEvent<{ - active?: boolean; - condition?: string; - setAt?: number; - }> - ).detail; - if (!detail?.active) { - setActiveGoal(null); - return; - } - if (!detail.condition) return; - setActiveGoal({ - condition: detail.condition, - setAt: detail.setAt ?? Date.now(), - }); - }; + setGoalEditOpen(false); + setGoalEditError(null); + }, [ + connection.goalState?.goal?.goalId, + connection.sessionId, + connectionGoalComplete, + ]); - window.addEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive); - return () => - window.removeEventListener(GOAL_STATUS_ACTIVE_EVENT, onGoalStatusActive); - }, []); + const activeGoal = + goalSnapshot?.goal && goalSnapshot.goal.status !== 'complete' + ? { + condition: goalSnapshot.goal.objective, + setAt: goalSnapshot.goal.createdAt, + } + : null; + const liveGoalSnapshot = + goalSnapshot?.goal?.status === 'complete' ? null : goalSnapshot; // Auto-recap: fire when the user returns after being away ≥ 3 minutes const hiddenAtRef = useRef(null); @@ -8466,6 +8445,13 @@ export function App({ const enqueueManualRun = useCallback( (prompt: string): Promise => new Promise((resolve, reject) => { + // Session-less means no Goal can exist (and `sendPrompt` allocates a + // session itself), so gate on the shared predicate rather than on a + // bare `goalState === undefined`, which also fires with no session. + if (isGoalGateBlocked()) { + reject(new Error(t('scheduledTasks.error.goalActive'))); + return; + } let admitted = false; const admit = () => { if (admitted) return; @@ -8483,7 +8469,7 @@ export function App({ }, ); }), - [sendPrompt], + [isGoalGateBlocked, sendPrompt, t], ); // Enqueue the pending bound run once its session is the current, fully-loaded // one — driven both by the effect below (when the session switch changes a @@ -8497,7 +8483,8 @@ export function App({ if ( !pending || conn.sessionId !== pending.sessionId || - conn.loadingTranscript + conn.loadingTranscript || + conn.goalState === undefined ) { return; } @@ -8580,6 +8567,7 @@ export function App({ connection.sessionId, connection.loadingTranscript, connection.catchingUp, + connection.goalState, tryFireBoundRun, ]); @@ -8626,61 +8614,103 @@ export function App({ [handleOpenMonitorDetails, handleOpenShellDetails, openTasksPanel], ); - const dispatchGoalSet = useCallback( - (condition: string, setAt: number) => { - setActiveGoal({ condition, setAt }); - store.dispatch([ - { - type: 'status', - text: serializeGoalStatusMessage({ - kind: 'set', - condition, - setAt, - }), - }, - ]); + const refreshGoal = useCallback(async () => { + const owner = sessionOwnerGuard.capture(); + const response = await sessionActions.getGoal(); + if (owner.isCurrent()) setGoalSnapshot(response.snapshot); + return response.snapshot; + }, [sessionActions, sessionOwnerGuard]); + + const controlCurrentGoal = useCallback( + async ( + action: 'create' | 'replace' | 'edit' | 'pause' | 'resume' | 'clear', + objective?: string, + ) => { + const busyOwner = sessionOwnerGuard.capture(); + const busySessionId = connectionRef.current.sessionId; + const expectedGoalId = goalSnapshotRef.current?.goal?.goalId; + const opId = ++goalControlOpSeqRef.current; + goalControlOwnerRef.current = { opId, sessionId: busySessionId }; + setGoalControlBusy(true); + try { + const snapshot = await refreshGoal(); + const goal = snapshot.goal; + if ( + (action === 'replace' || action === 'edit') && + goal?.goalId !== expectedGoalId + ) { + throw new Error(t('goals.error.goalUnavailable')); + } + const request = buildGoalControlRequest(action, goal, objective, { + emptyObjective: t('goals.error.emptyCondition'), + goalUnavailable: t('goals.error.goalUnavailable'), + }); + + if (!busyOwner.isCurrent()) { + throw new Error(t('goals.error.goalUnavailable')); + } + const owner = sessionOwnerGuard.capture(); + try { + const response = await sessionActions.controlGoal(request); + if (owner.isCurrent()) setGoalSnapshot(response.snapshot); + return response.snapshot; + } catch (error) { + if (owner.isCurrent()) await refreshGoal().catch(() => undefined); + throw error; + } + } finally { + // A newer operation (or a session change) owns the latch now; leave it + // to whoever owns it rather than releasing it under them. + if (goalControlOwnerRef.current?.opId === opId) { + goalControlOwnerRef.current = undefined; + if (connectionRef.current.sessionId === busySessionId) { + setGoalControlBusy(false); + } + } + } }, - [store], + [refreshGoal, sessionActions, sessionOwnerGuard, t], ); - const dispatchGoalCleared = useCallback( - (goal: ActiveGoalStatus | null) => { - if (!goal) return; - store.dispatch([ - { - type: 'status', - text: serializeGoalStatusMessage({ - kind: 'cleared', - condition: goal.condition, - durationMs: Date.now() - goal.setAt, - }), - }, - ]); - setActiveGoal(null); + const createGoalForAllocatedSession = useCallback( + async (sessionId: string, objective: string) => { + const opId = ++goalControlOpSeqRef.current; + goalControlOwnerRef.current = { opId, sessionId }; + setGoalControlBusy(true); + try { + const response = await workspaceActions.controlGoal(sessionId, { + action: 'create', + objective, + }); + // The workspace-scoped control does not write `connection.goalState` + // the way `sessionActions.controlGoal` does, so install the create + // response directly. Until it lands, `holdQueuedPromptsLocally` reads + // false and the sync effect re-derives the local snapshot to null — a + // prompt typed in that window would go straight to the daemon instead + // of the Goal queue, and no Goal strip would render. + sessionActions.applyGoalSnapshot(sessionId, response.snapshot); + if ( + !connectionRef.current.sessionId || + connectionRef.current.sessionId === sessionId + ) { + setGoalSnapshot(response.snapshot); + } + if (connectionRef.current.sessionId === sessionId) { + await refreshGoal(); + } + return response.snapshot; + } finally { + // Same ownership rule as `controlCurrentGoal`: a create that settles + // after the user switched sessions must not release a latch a newer + // control now holds, or the strip re-enables mid-control and a second + // dispatch loses the daemon's CAS with a 409. + if (goalControlOwnerRef.current?.opId === opId) { + goalControlOwnerRef.current = undefined; + setGoalControlBusy(false); + } + } }, - [store], - ); - - const handleBusyGoalClear = useCallback( - (text: string) => { - if (sessionWriteBlocked) return false; - if (!requireActiveSessionForLocalCommand()) return false; - const owner = sessionOwnerGuard.capture(); - store.appendLocalUserMessage(text); - sessionActions.clearGoal().catch((error: unknown) => { - if (!owner.isCurrent()) return; - reportError(error, 'Failed to clear /goal'); - }); - return true; - }, - [ - reportError, - requireActiveSessionForLocalCommand, - sessionWriteBlocked, - sessionActions, - sessionOwnerGuard, - store, - ], + [refreshGoal, sessionActions, workspaceActions], ); const loadRewindSnapshots = useCallback( @@ -8706,72 +8736,127 @@ export function App({ ); const handleGoalSlashCommand = useCallback( - ( - text: string, - images?: PromptImage[], - files?: PromptFile[], - opts?: { - sendToDaemon?: boolean; - commitComposerAccepted?: ComposerSubmitCommit; - }, - ) => { - const goalArg = goalArgOf(text); - const sendToDaemon = opts?.sendToDaemon ?? true; - const sendGoalPrompt = () => { - const owner = { current: sessionOwnerGuard.capture() }; - const deferComposerCommit = - Boolean(onSubmitBeforeRef.current) || - createSessionPromiseRef.current !== null; - const clearComposerOnPromptStart = - !connectionRef.current.sessionId || deferComposerCommit; - sendPrompt(text, images, files, { - ownerRef: owner, - clearComposerOnPromptStart, - commitComposerAccepted: clearComposerOnPromptStart - ? opts?.commitComposerAccepted - : undefined, - }).catch((error: unknown) => { - if (!owner.current.isCurrent()) return; - reportError(error, 'Failed to send /goal command'); - }); - return clearComposerOnPromptStart ? false : true; - }; - - if (goalArg && isGoalClearKeyword(goalArg)) { - if (!sendToDaemon) { - store.appendLocalUserMessage(text); - dispatchGoalCleared(activeGoalRef.current); - return true; - } - return handleBusyGoalClear(text); - } else if (goalArg) { - if (!sendToDaemon) { - store.appendLocalUserMessage(text); - dispatchGoalSet(goalArg, Date.now()); - return true; - } - return sendGoalPrompt(); + (text: string, hasAttachments: boolean) => { + if (hasAttachments) { + pushToast('error', t('goals.error.attachmentsUnsupported')); + return false; + } + const operation = parseWebShellGoalCommand(text); + if (operation.kind === 'status') { + openGoals(); + return true; + } + if (operation.kind === 'error') { + pushToast( + 'error', + t('goals.error.requiresObjective', { keyword: operation.keyword }), + ); + return false; + } + // Returning true wipes the composer, so the preconditions that can be + // checked here must be checked before that happens — a control typed + // without a session would otherwise lose its text to a toast. + if (!connectionRef.current.sessionId && operation.kind !== 'set') { + pushToast('error', t('localCommand.noSession')); + return false; + } + // The strip disables its buttons while a control is in flight; the + // composer has no disabled state, so it has to refuse here. Two controls + // read the same snapshot and stamp the same `expectedGoalId`/ + // `expectedRevision`, and the daemon rejects the loser with a 409. + if (goalControlOwnerRef.current) { + pushToast('error', t('goals.error.controlBusy')); + return false; } - // Bare `/goal` opens the Goals page instead of asking the daemon to print - // its status as text — the same move `/schedule` makes. Nothing is sent, - // so the composer is cleared by returning true. - openGoals(); + void (async () => { + const sourceOwner = sessionOwnerGuard.capture(); + const sourceSessionId = connectionRef.current.sessionId; + let allocatedSessionId: string | undefined; + if (!connectionRef.current.sessionId) { + if (operation.kind !== 'set') { + throw new Error(t('localCommand.noSession')); + } + allocatedSessionId = await ensureSessionForPrompt(); + } + const currentSessionId = connectionRef.current.sessionId; + const ownAllocationSucceeded = + sourceSessionId === undefined && + allocatedSessionId !== undefined && + (currentSessionId === undefined || + currentSessionId === allocatedSessionId); + if ( + (!sourceOwner.isCurrent() && !ownAllocationSucceeded) || + (sourceSessionId !== undefined + ? currentSessionId !== sourceSessionId + : currentSessionId !== undefined && + currentSessionId !== allocatedSessionId) + ) { + return; + } + if (!connectionRef.current.sessionId && !allocatedSessionId) { + throw new Error(t('localCommand.noSession')); + } + store.appendLocalUserMessage(text); + const action = operation.kind === 'set' ? 'replace' : operation.kind; + const objective = + operation.kind === 'set' || operation.kind === 'edit' + ? operation.objective + : undefined; + if (allocatedSessionId && operation.kind === 'set') { + await createGoalForAllocatedSession( + allocatedSessionId, + operation.objective, + ); + } else { + await controlCurrentGoal(action, objective); + } + })().catch((error: unknown) => { + reportError(error, `Failed to ${operation.kind} /goal`); + }); return true; }, [ - dispatchGoalCleared, - dispatchGoalSet, - handleBusyGoalClear, + controlCurrentGoal, + createGoalForAllocatedSession, + ensureSessionForPrompt, openGoals, + pushToast, reportError, - sendPrompt, sessionOwnerGuard, store, - connectionRef, + t, ], ); + const runGoalControl = useCallback( + (action: 'pause' | 'resume' | 'clear') => { + void controlCurrentGoal(action).catch((error: unknown) => { + reportError(error, t(`goals.error.${action}Failed`)); + }); + }, + [controlCurrentGoal, reportError, t], + ); + + const handleGoalEditSave = useCallback( + (objective: string) => { + const owner = sessionOwnerGuard.capture(); + setGoalEditError(null); + void controlCurrentGoal('edit', objective) + .then(() => { + if (owner.isCurrent()) setGoalEditOpen(false); + }) + .catch((error: unknown) => { + if (!owner.isCurrent()) return; + setGoalEditError( + error instanceof Error ? error.message : String(error), + ); + reportError(error, t('goals.error.editFailed')); + }); + }, + [controlCurrentGoal, reportError, sessionOwnerGuard, t], + ); + const hiddenCommands = useMemo( () => new Set( @@ -8815,7 +8900,8 @@ export function App({ pushToast('warning', t('editor.connectionDisconnected')); return false; } - const promptBlocked = streamingStateRef.current !== 'idle'; + const promptBlocked = + streamingStateRef.current !== 'idle' || isGoalGateBlocked(); const submitPromptFromEditor = ( promptText: string, promptImages: PromptImage[] | undefined, @@ -8998,21 +9084,12 @@ export function App({ return true; } if (cmd === 'goal') { - // A bare `/goal` just opens the Goals page; it neither sends a - // prompt nor touches the session, so it works mid-turn too. - if (!goalArgOf(text)) { - openGoals(); - return true; - } - if (promptBlocked) { - if (isGoalClearCommand(text)) { - return handleBusyGoalClear(text); - } - return blockLocalCommandDuringTurn(); - } - return handleGoalSlashCommand(text, images, files, { - commitComposerAccepted, - }); + return handleGoalSlashCommand( + text, + (images?.length ?? 0) > 0 || + (files?.length ?? 0) > 0 || + (metadata?.inputAnnotations?.length ?? 0) > 0, + ); } if (cmd === 'theme') { const themeArg = text.slice(match[0].length).trim().toLowerCase(); @@ -9073,8 +9150,14 @@ export function App({ } const nextLanguage = normalizeLanguage(languageArg); const owner = { current: sessionOwnerGuard.capture() }; + // The daemon sync is what keeps the agent answering in the + // language the chrome just switched to, so when it cannot run + // (turn in flight, or a Goal owning the session) refuse the + // command instead of switching the UI alone — the language + // picker treats the identical condition the same way. + if (promptBlocked) return blockLocalCommandDuringTurn(); handleLanguageChange(nextLanguage); - if (!promptBlocked) { + { const deferComposerCommit = Boolean(onSubmitBeforeRef.current) || createSessionPromiseRef.current !== null; @@ -9824,7 +9907,7 @@ export function App({ } else if (text.startsWith('!')) { const cmd = text.slice(1).trim(); if (!cmd) return false; - if (promptBlocked) { + if (streamingStateRef.current !== 'idle') { queuedShellCommandsRef.current.push(cmd); pushToast('info', t('queue.shellQueued')); return true; @@ -9919,7 +10002,6 @@ export function App({ closeMobileDrawer, openPanel, openScheduledTasks, - openGoals, createNewSession, ensureSessionForPrompt, finishPromptPreparation, @@ -9928,7 +10010,6 @@ export function App({ gitDiffWorkspaceCwd, sessionWorktree, gitHubPrsSupported, - handleBusyGoalClear, handleGoalSlashCommand, handleThemeChange, handleSetMode, @@ -9957,6 +10038,7 @@ export function App({ workspaceActions, updateFailedPrompt, updateUnknownPromptAdmission, + isGoalGateBlocked, ], ); @@ -10083,7 +10165,11 @@ export function App({ ); const handleRetry = useCallback(() => { - if (sessionWriteBlockedRef.current || promptPreparationOwnerRef.current) { + if ( + sessionWriteBlockedRef.current || + promptPreparationOwnerRef.current || + isGoalGateBlocked() + ) { return; } if ( @@ -10279,6 +10365,7 @@ export function App({ store, t, updateUnknownPromptAdmission, + isGoalGateBlocked, ]); useEffect(() => { @@ -10668,7 +10755,7 @@ export function App({ const handleFastModelSelect = useCallback( (modelId: string) => { - if (streamingState !== 'idle') { + if (streamingState !== 'idle' || isGoalGateBlocked()) { blockLocalCommandDuringTurn(); return; } @@ -10724,6 +10811,7 @@ export function App({ reloadWorkspaceSettings, modelSettingScope, sessionOwnerGuard, + isGoalGateBlocked, ], ); @@ -11329,6 +11417,19 @@ export function App({ /> )} + {goalEditOpen && goalSnapshot?.goal && ( + { + if (goalControlBusy) return; + setGoalEditOpen(false); + setGoalEditError(null); + }} + /> + )} {showAuthDialog && ( { - // Setting a goal registers the Stop hook AND kicks off - // the first turn, so it has to travel the prompt path. - // Start a FRESH session so the goal loop doesn't take - // over the conversation the user was already having. - // - // Unless a previous attempt in this same visit to the - // page already made one and then failed to send: that - // session never got its goal and is still current, so - // reuse it. Creating another would strand it, and a user - // retrying a few times would end up with a column of - // blank chats in the sidebar. - // - // Leaving the page forgets it (see the effect on - // `strandedGoalSessionRef`), so this can never reuse a - // session the user has since talked to. const stranded = strandedGoalSessionRef.current; const canReuseStranded = stranded !== undefined && connectionRef.current.sessionId === stranded; if (!canReuseStranded) { - // `keepView`: createNewSession switches to the chat by - // default, which would unmount this form before the - // prompt is even sent and leave a later rejection with - // nowhere to render — the exact failure the deferred - // switch below exists to prevent. + strandedGoalSessionRef.current = undefined; const created = await createNewSession(undefined, { keepView: true, }); - // createNewSession already surfaced the failure; don't - // drop the goal into the wrong (still-current) session. - // `false` keeps the form open with the typed condition - // still in it — returning normally would read as - // "created" and reset it. if (!created) return false; - onSessionIdChange?.(undefined); } - // Switch to the chat only once the prompt is admitted. - // Switching first unmounts the Goals page, and a later - // rejection would then have nowhere to render: the user - // would land in an empty session with no explanation. - // Letting this reject keeps the error in the form the - // user is looking at. - const owner = { - current: sessionOwnerGuard.capture(), - }; + const allocationOwner = sessionOwnerGuard.capture(); + const sourceSessionId = + connectionRef.current.sessionId; + const allocatedSessionId = + await ensureSessionForPrompt(); + const currentSessionId = + connectionRef.current.sessionId; + const ownAllocationSucceeded = + sourceSessionId === undefined && + allocatedSessionId !== undefined && + (currentSessionId === undefined || + currentSessionId === allocatedSessionId); + if ( + (!allocationOwner.isCurrent() && + !ownAllocationSucceeded) || + (sourceSessionId !== undefined + ? currentSessionId !== sourceSessionId + : currentSessionId !== undefined && + currentSessionId !== allocatedSessionId) + ) { + return false; + } + if ( + !connectionRef.current.sessionId && + !allocatedSessionId + ) { + return false; + } + const owner = sessionOwnerGuard.capture(); try { - await sendPrompt( - `/goal ${condition}`, - undefined, - undefined, - { - clearComposerOnPromptStart: true, - ownerRef: owner, - }, - ); - if (!owner.current.isCurrent()) return false; + if (allocatedSessionId) { + await createGoalForAllocatedSession( + allocatedSessionId, + condition, + ); + } else { + await controlCurrentGoal('create', condition); + } } catch (error) { - // `sendPrompt` creates the session lazily, so by now - // one may exist even though the prompt never landed. - // Remember it so the retry reuses it rather than - // stranding it. - if (owner.current.isCurrent()) { + if ( + owner.isCurrent() && + mainViewRef.current === 'goals' + ) { strandedGoalSessionRef.current = + allocatedSessionId ?? connectionRef.current.sessionId; } throw error; } + if (!owner.isCurrent()) return false; strandedGoalSessionRef.current = undefined; setMainView('chat'); }} @@ -12179,6 +12274,7 @@ export function App({ onError={reportError} onImageIngestionNotice={pushToast} onSlashCommand={onSlashCommand} + onOpenGoals={openGoals} onRightPanelOpen={handleTurnOutputOpen} onOpenMonitor={openMonitorPanel} onPaneArtifactsChange={handlePaneArtifactsChange} @@ -12626,15 +12722,38 @@ export function App({ )} )} - + {(queuedPrompts.length > 0 || + liveGoalSnapshot?.goal) && ( +
+ + {liveGoalSnapshot?.goal && ( + { + setGoalEditError(null); + setGoalEditOpen(true); + }} + onPause={() => runGoalControl('pause')} + onResume={() => runGoalControl('resume')} + onClear={() => runGoalControl('clear')} + /> + )} +
+ )} {CustomComposerHeader && (
diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index 72612bf94c..35835bfb1d 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -35,7 +35,8 @@ let latestOnSubmit: | (( text: string, images?: unknown, - commit?: () => void, + files?: unknown, + commitAccepted?: () => void, metadata?: unknown, ) => boolean) | undefined; @@ -53,6 +54,7 @@ let sendPromptAdmit: (() => void) | undefined; const clearFollowup = vi.fn(); const insertText = vi.fn(); const transcriptDispatch = vi.fn(); +const appendLocalUserMessage = vi.fn(); const sendPrompt = vi.fn(async () => ({}) as any); const submitPermission = vi.fn(async () => true); const cancel = vi.fn(async () => {}); @@ -60,6 +62,8 @@ const setApprovalMode = vi.fn(async (mode: string) => ({ mode })); const setModel = vi.fn(async () => ({}) as any); const loadArtifacts = vi.fn(async () => ({ artifacts: [] })); const getTasks = vi.fn(); +const getGoal = vi.fn(); +const controlGoal = vi.fn(); const readAttachment = vi.fn(); const daemonActions = { sendPrompt, @@ -69,6 +73,8 @@ const daemonActions = { setModel, loadArtifacts, getTasks, + getGoal, + controlGoal, readAttachment, }; const enqueuePrompt = vi.fn(() => true); @@ -78,6 +84,7 @@ const editLastQueuedPrompt = vi.fn(() => false); const clearQueuedPrompts = vi.fn(() => false); let queuedPromptsMock: any[] = []; let queuedTextsMock: string[] = []; +let ownerVersion = 0; const latestComposerCoreOptions = vi.hoisted(() => ({ current: null as Record | null, @@ -108,6 +115,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ }), useTranscriptStore: () => ({ dispatch: transcriptDispatch, + appendLocalUserMessage, }), usePromptStatus: () => 'idle', useOptionalWorkspace: () => undefined, @@ -119,7 +127,10 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ }), useWorkspaceEventSignals: () => ({ artifactsVersion: 0 }), useDaemonSessionOwnerGuard: () => ({ - capture: () => ({ isCurrent: () => true }), + capture: () => { + const captured = ownerVersion; + return { isCurrent: () => ownerVersion === captured }; + }, }), })); @@ -330,6 +341,7 @@ vi.mock('./QueuedPromptDisplay', () => ({
{String(props.prompts.length)}
@@ -378,6 +390,11 @@ beforeEach(() => { workspaceCwd: '/w', loadingTranscript: false, catchingUp: false, + // A loaded session always carries a Goal snapshot (the load falls back to + // an idle one when the fetch fails), and the Goal gates fail CLOSED on an + // absent one — leaving it out here would model a session that is still + // hydrating, not a Goal-less one. + goalState: { v: 2, activity: 'idle', goal: null }, }; streamingStateValue = 'idle'; pendingPermission = null; @@ -391,10 +408,13 @@ beforeEach(() => { sendPromptAdmit = undefined; queuedPromptsMock = []; queuedTextsMock = []; + ownerVersion = 0; sendPrompt.mockReset(); loadArtifacts.mockReset(); loadArtifacts.mockResolvedValue({ artifacts: [] }); getTasks.mockReset(); + getGoal.mockReset(); + controlGoal.mockReset(); readAttachment.mockReset(); readAttachment.mockResolvedValue({ data: 'eyJoaSI6IuS9oOWlvSJ9', @@ -417,6 +437,7 @@ beforeEach(() => { editLastQueuedPrompt.mockClear(); clearQueuedPrompts.mockClear(); transcriptDispatch.mockClear(); + appendLocalUserMessage.mockClear(); catalogController.invalidateWorkspace.mockClear(); catalogController.promptAdmitted.mockClear(); catalogController.promptAdmissionUncertain.mockClear(); @@ -467,7 +488,651 @@ function testid(id: string): HTMLElement | null { return container!.querySelector(`[data-testid="${id}"]`); } +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, reject, resolve }; +} + describe('ChatPane', () => { + it.each([ + [ + 'images', + [{ data: 'image-data', media_type: 'image/png' }], + undefined, + undefined, + ], + ['files', undefined, [{ name: 'notes.txt' }], undefined], + [ + 'input annotations', + undefined, + undefined, + { + inputAnnotations: [ + { + start: 15, + end: 22, + text: '@notes', + type: 'file', + data: { path: 'notes.txt' }, + }, + ], + }, + ], + ])( + 'rejects /goal with %s and preserves the draft', + (_kind, images, files, metadata) => { + const onError = vi.fn(); + render({ onError }); + let returned: boolean | undefined; + + act(() => { + returned = latestOnSubmit!( + '/goal set inspect the attachment', + images, + files, + undefined, + metadata, + ); + }); + + expect(returned).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'Remove attachments before using /goal.', + ); + expect(controlGoal).not.toHaveBeenCalled(); + expect(transcriptDispatch).not.toHaveBeenCalled(); + }, + ); + + it('lets the host slash handler intercept /goal before the control plane', () => { + // The prop contract says the host handler runs before Web Shell handles a + // slash command; the main composer honours that for /goal, so the pane has + // to as well or an override silently applies on one surface only. + const onSlashCommand = vi.fn(() => true); + render({ onSlashCommand, onOpenGoals: vi.fn() }); + let returned: boolean | undefined; + + act(() => { + returned = latestOnSubmit!('/goal pause'); + }); + + expect(returned).toBe(true); + expect(onSlashCommand).toHaveBeenCalled(); + expect(getGoal).not.toHaveBeenCalled(); + expect(controlGoal).not.toHaveBeenCalled(); + }); + + it('does not swallow a bare /goal when the pane has no goals view', () => { + // The side-task pane passes no `onOpenGoals`; consuming the text there + // opens nothing and shows nothing. + const onError = vi.fn(); + render({ onError }); + let returned: boolean | undefined; + + act(() => { + returned = latestOnSubmit!('/goal'); + }); + + expect(returned).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'The goals view is not available on this surface.', + ); + }); + + it('reports an objective-less /goal set without consuming it', () => { + const onError = vi.fn(); + render({ onError, onOpenGoals: vi.fn() }); + let returned: boolean | undefined; + + act(() => { + returned = latestOnSubmit!('/goal set'); + }); + + expect(returned).toBe(false); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + '/goal set requires an objective.', + ); + expect(controlGoal).not.toHaveBeenCalled(); + }); + + it('offers Insert only while a turn is running', () => { + // Between two Goal turns streaming is idle while the hold keeps queued + // prompts visible. `insertQueuedPrompt` no-ops at idle, so the affordance + // has to disappear with it rather than render a button that does nothing. + queuedPromptsMock = [{ id: 1, text: 'held while the Goal runs' } as never]; + connectionState.goalState = { + v: 2, + activity: 'idle', + goal: { + goalId: 'goal-1', + revision: 1, + objective: 'ship it', + status: 'active', + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + streamingStateValue = 'idle'; + render(); + + expect(testid('pane-queue')?.dataset['canInsertMidTurn']).toBe('false'); + + act(() => { + streamingStateValue = 'responding'; + rerender(); + }); + + expect(testid('pane-queue')?.dataset['canInsertMidTurn']).toBe('true'); + }); + + it('preserves a /goal command the pane connection cannot deliver', () => { + // App.tsx applies the broken-connection guard before any slash handling and + // keeps the text in the composer. Without the same ordering here the branch + // consumes the text, writes a transcript entry, and only then fails inside + // `requireSessionForAction` — the typed control is gone. + const onError = vi.fn(); + connectionState = { ...connectionState, status: 'error' }; + render({ onError }); + let returned: boolean | undefined; + + act(() => { + returned = latestOnSubmit!('/goal pause'); + }); + + expect(returned).toBe(false); + expect(controlGoal).not.toHaveBeenCalled(); + expect(getGoal).not.toHaveBeenCalled(); + expect(appendLocalUserMessage).not.toHaveBeenCalled(); + }); + + it('keeps goal controls locked when the goal is replaced mid-control', async () => { + const goalA = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-a', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const goalB = { + ...goalA, + goal: { + ...goalA.goal, + goalId: 'goal-b', + revision: 1, + objective: 'replaced by another client', + updatedAt: 2, + }, + }; + const pendingControl = deferred<{ snapshot: typeof goalA }>(); + connectionState.goalState = goalA; + getGoal.mockResolvedValue({ snapshot: goalA }); + controlGoal.mockReturnValueOnce(pendingControl.promise); + render(); + + const pause = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledOnce()); + + // Another client replaces the goal while the pause is still in flight. + act(() => { + connectionState = { ...connectionState, goalState: goalB }; + rerender(); + }); + const pauseAfterReplace = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + expect(pauseAfterReplace?.disabled).toBe(true); + act(() => pauseAfterReplace?.click()); + expect(controlGoal).toHaveBeenCalledOnce(); + + await act(async () => pendingControl.resolve({ snapshot: goalB })); + expect( + container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(false); + }); + + it('locks goal controls while the current snapshot refresh is in flight', async () => { + const current = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-1', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + connectionState.goalState = current; + let resolveGoal: + | ((value: { snapshot: typeof current }) => void) + | undefined; + getGoal.mockReturnValue( + new Promise((resolve) => { + resolveGoal = resolve; + }), + ); + controlGoal.mockResolvedValue({ snapshot: current }); + render(); + + const pause = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + + expect(pause.disabled).toBe(true); + act(() => pause.click()); + expect(getGoal).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveGoal?.({ snapshot: current }); + }); + expect(controlGoal).toHaveBeenCalledTimes(1); + }); + + it('builds the control request from the freshly fetched Goal', async () => { + // `expectedGoalId`/`expectedRevision` must come from the getGoal round trip, + // not from the possibly-stale snapshot in connection state, or every + // control races the daemon's CAS. + const stale = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-1', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const fresh = { + ...stale, + goal: { ...stale.goal, revision: 9 }, + }; + connectionState.goalState = stale; + getGoal.mockResolvedValue({ snapshot: fresh }); + controlGoal.mockResolvedValue({ snapshot: fresh }); + render({ onOpenGoals: vi.fn() }); + + act(() => { + container! + .querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )! + .click(); + }); + await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(1)); + + expect(controlGoal).toHaveBeenCalledWith({ + action: 'pause', + expectedGoalId: 'goal-1', + expectedRevision: 9, + }); + + // `/goal set` maps to a versioned replace against the same fresh snapshot. + act(() => { + latestOnSubmit!('/goal set ship the other thing'); + }); + await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(2)); + expect(controlGoal).toHaveBeenLastCalledWith({ + action: 'replace', + objective: 'ship the other thing', + expectedGoalId: 'goal-1', + expectedRevision: 9, + }); + expect(appendLocalUserMessage).toHaveBeenCalledWith( + '/goal set ship the other thing', + ); + }); + + it('closes the pane Goal edit dialog when its session changes', async () => { + // Left open, the dialog re-syncs its textarea from the new session's + // objective and the user edits that Goal believing it is the old one. + const goalA = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-a', + revision: 5, + objective: 'session A objective', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + connectionState.goalState = goalA; + getGoal.mockResolvedValue({ snapshot: goalA }); + render(); + + act(() => { + container! + .querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + )! + .click(); + }); + expect(document.querySelector('textarea')).not.toBeNull(); + + act(() => { + connectionState = { + ...connectionState, + goalState: { + ...goalA, + goal: { ...goalA.goal, goalId: 'goal-b', objective: 'goal B' }, + }, + }; + rerender(); + }); + + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('does not dispatch a Goal control after the pane session changes during refresh', async () => { + const current = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-1', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const pendingGoal = deferred<{ snapshot: typeof current }>(); + const onError = vi.fn(); + connectionState.goalState = current; + getGoal.mockReturnValueOnce(pendingGoal.promise); + render({ onError }); + + const pause = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + act(() => { + ownerVersion += 1; + connectionState = { ...connectionState, sessionId: 'sess-2' }; + rerender({ onError }); + }); + await act(async () => pendingGoal.resolve({ snapshot: current })); + + expect(controlGoal).not.toHaveBeenCalled(); + // The operation was dropped on purpose; reporting it would show a failure + // toast for a control the user's own session switch cancelled. + expect(onError).not.toHaveBeenCalled(); + }); + + it('releases Goal control busy state after a same-session reattach', async () => { + const current = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-1', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const pendingControl = deferred<{ snapshot: typeof current }>(); + connectionState.goalState = current; + getGoal.mockResolvedValue({ snapshot: current }); + controlGoal.mockReturnValueOnce(pendingControl.promise); + render(); + + const pause = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + ); + if (!pause) throw new Error('pause control was not rendered'); + act(() => pause.click()); + await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledOnce()); + act(() => { + ownerVersion += 1; + rerender(); + }); + await act(async () => pendingControl.resolve({ snapshot: current })); + + expect( + container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Pause goal"]', + )?.disabled, + ).toBe(false); + }); + + it('reports an edit failure after the edited Goal disappears', async () => { + const current = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-1', + revision: 5, + objective: 'ship it', + status: 'active' as const, + evidenceCursor: { recordId: 'record-1' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const pendingGoal = deferred<{ + snapshot: { v: 2; activity: 'idle'; goal: null }; + }>(); + const onError = vi.fn(); + connectionState.goalState = current; + getGoal.mockReturnValueOnce(pendingGoal.promise); + render({ onError }); + + act(() => { + container! + .querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ) + ?.click(); + }); + const save = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!save) throw new Error('save control was not rendered'); + act(() => save.click()); + act(() => { + connectionState = { + ...connectionState, + goalState: { v: 2, activity: 'idle', goal: null }, + }; + rerender({ onError }); + }); + await act(async () => + pendingGoal.resolve({ + snapshot: { v: 2, activity: 'idle', goal: null }, + }), + ); + + expect(onError).toHaveBeenCalledWith( + // The guard that produces this message is the only protection the + // pause/resume/clear flows have against dereferencing a null goal, so + // pin the message rather than "some Error". + expect.objectContaining({ message: 'The goal is no longer available.' }), + 'Failed to edit the goal', + ); + }); + + it.each(['resolve', 'reject'] as const)( + 'ignores a stale Goal edit %s after the pane session changes', + async (outcome) => { + const goalA = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-a', + revision: 5, + objective: 'session A objective', + status: 'active' as const, + evidenceCursor: { recordId: 'record-a' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const goalB = { + ...goalA, + goal: { + ...goalA.goal, + goalId: 'goal-b', + revision: 1, + objective: 'session B objective', + }, + }; + let resolveEdit!: (value: { snapshot: typeof goalA }) => void; + let rejectEdit!: (error: Error) => void; + const edit = new Promise<{ snapshot: typeof goalA }>( + (resolve, reject) => { + resolveEdit = resolve; + rejectEdit = reject; + }, + ); + connectionState.goalState = goalA; + getGoal.mockResolvedValue({ snapshot: goalA }); + controlGoal.mockReturnValueOnce(edit); + render(); + + const editA = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!editA) throw new Error('session A edit control was not rendered'); + act(() => editA.click()); + const saveA = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!saveA) throw new Error('session A save control was not rendered'); + act(() => saveA.click()); + await vi.waitFor(() => expect(controlGoal).toHaveBeenCalledTimes(1)); + + act(() => { + ownerVersion += 1; + connectionState = { + ...connectionState, + sessionId: 'sess-2', + goalState: goalB, + }; + rerender(); + }); + const editB = container!.querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ); + if (!editB) throw new Error('session B edit control was not rendered'); + expect(editB.disabled).toBe(false); + act(() => editB.click()); + expect(document.querySelector('textarea')).not.toBeNull(); + + await act(async () => { + if (outcome === 'resolve') resolveEdit({ snapshot: goalA }); + else rejectEdit(new Error('session A edit failed')); + await Promise.resolve(); + }); + + expect(document.querySelector('textarea')).not.toBeNull(); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }, + ); + + it('rejects a Goal edit when the same session replaces the goal', async () => { + const goalA = { + v: 2 as const, + activity: 'running' as const, + goal: { + goalId: 'goal-a', + revision: 5, + objective: 'goal A', + status: 'active' as const, + evidenceCursor: { recordId: 'record-a' }, + turnCount: 1, + activeTimeMs: 10, + createdAt: 1, + updatedAt: 1, + }, + }; + const goalB = { + ...goalA, + goal: { ...goalA.goal, goalId: 'goal-b', objective: 'goal B' }, + }; + const pendingGoal = deferred<{ snapshot: typeof goalB }>(); + const onError = vi.fn(); + connectionState.goalState = goalA; + getGoal.mockReturnValueOnce(pendingGoal.promise); + render({ onError }); + + act(() => { + container! + .querySelector( + '[data-testid="goal-status-strip"] button[aria-label="Edit goal"]', + ) + ?.click(); + }); + const save = [ + ...document.querySelectorAll('button'), + ].find((button) => button.textContent === 'Save'); + if (!save) throw new Error('save control was not rendered'); + act(() => save.click()); + act(() => { + connectionState = { ...connectionState, goalState: goalB }; + rerender({ onError }); + }); + await act(async () => pendingGoal.resolve({ snapshot: goalB })); + + expect(controlGoal).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + 'Failed to edit the goal', + ); + }); + it('opens a pane monitor in the shared right panel', async () => { connectionState.capabilities = { features: ['session_monitor_tool_correlation'], @@ -950,6 +1615,42 @@ describe('ChatPane', () => { expect(enqueuePrompt).not.toHaveBeenCalled(); }); + it('holds an idle prompt while the Goal state is still hydrating', () => { + // The session load clears `loadingTranscript` before its `goal()` fetch + // resolves, so the composer is writable with no snapshot yet. The daemon + // has no server-side prompt gate for an active Goal, so a direct send in + // that window bypasses the Goal queue outright — fail closed, exactly as + // the local hold does. + connectionState = { ...connectionState, goalState: undefined }; + render(); + + act(() => + testid('pane-submit')!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ), + ); + + expect(sendPrompt).not.toHaveBeenCalled(); + expect(enqueuePrompt).toHaveBeenCalled(); + + // ...and the gate reopens once the snapshot lands Goal-less — the window + // is a hold, not a lock. + act(() => { + connectionState = { + ...connectionState, + goalState: { v: 2, activity: 'idle', goal: null }, + }; + rerender(); + }); + act(() => + testid('pane-submit')!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ), + ); + + expect(sendPrompt).toHaveBeenCalledTimes(1); + }); + it('lets the host handle a slash command', () => { const onSlashCommand = vi.fn(() => true); render({ onSlashCommand }); @@ -1738,6 +2439,7 @@ describe('ChatPane', () => { }); it('enables mid-turn queue mutations only when advertised', () => { + queuedPromptsMock = [{ id: 1, text: 'queued next' }]; connectionState.capabilities = { features: ['session_mid_turn_message_mutation'], }; @@ -1747,6 +2449,7 @@ describe('ChatPane', () => { }); it('disables mid-turn queue mutations when not advertised', () => { + queuedPromptsMock = [{ id: 1, text: 'queued next' }]; render(); expect(testid('pane-queue')?.dataset.canMutateMidTurn).toBe('false'); }); diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index cb0be82f82..6990fc2959 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -17,6 +17,7 @@ import { useActions, useConnection, useDaemonFollowupSuggestion, + useDaemonSessionOwnerGuard, useStreamingState, useTranscriptHistory, useTranscriptStore, @@ -61,6 +62,9 @@ import { } from '../utils/todos'; import { findMonitorTaskForTool } from '../utils/monitorTasks'; import { invokeSlashCommandHandler } from '../utils/slash-command-action'; +import { parseWebShellGoalCommand } from '../utils/goalCondition'; +import { buildGoalControlRequest } from '../utils/goalControlRequest'; +import { isGoalGateBlocked } from '../utils/goalGate'; import type { WebShellSlashCommandHandler } from '../App'; import { getModelDisplayName } from '../utils/modelDisplay'; import { @@ -83,6 +87,9 @@ import { MessageList } from './MessageList'; import { StreamingStatus } from './StreamingStatus'; import { ChatEditor, type ComposerToolbarAction } from './ChatEditor'; import { QueuedPromptDisplay } from './QueuedPromptDisplay'; +import { GoalStatusStrip } from './GoalStatusStrip'; +import composerStatusStyles from './ComposerStatusStack.module.css'; +import { GoalEditDialog } from './dialogs/GoalEditDialog'; import { ToolApproval } from './messages/ToolApproval'; import { AskUserQuestion } from './messages/AskUserQuestion'; import type { @@ -182,6 +189,7 @@ export interface ChatPaneProps { onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void; /** Host slash-command callback shared with the main chat composer. */ onSlashCommand?: WebShellSlashCommandHandler; + onOpenGoals?: () => void; onRightPanelOpen?: (request: TurnOutputOpenRequest) => void; onOpenMonitor?: ( task: DaemonSessionMonitorTaskStatus, @@ -223,6 +231,7 @@ export function ChatPane({ onError, onImageIngestionNotice, onSlashCommand, + onOpenGoals, onRightPanelOpen, onOpenMonitor, onPaneArtifactsChange, @@ -241,6 +250,7 @@ export function ChatPane({ useWebShellCustomization(); const connection = useConnection(); const actions = useActions(); + const sessionOwnerGuard = useDaemonSessionOwnerGuard(); const workspace = useWorkspace(); const attachmentWorkspaceTarget = useArtifactWorkspaceTarget( connection.workspaceCwd, @@ -253,6 +263,39 @@ export function ChatPane({ const transcriptHistory = useTranscriptHistory(); const store = useTranscriptStore(); const streamingState = useStreamingState(); + const [goalControlBusy, setGoalControlBusy] = useState(false); + const goalControlOpSeqRef = useRef(0); + const goalControlOwnerRef = useRef< + { opId: number; sessionId: string | undefined } | undefined + >(undefined); + const [goalEditOpen, setGoalEditOpen] = useState(false); + const [goalEditError, setGoalEditError] = useState(null); + const connectionRef = useRef(connection); + connectionRef.current = connection; + const connectionGoalComplete = + connection.goalState?.goal?.status === 'complete'; + const liveGoalSnapshot = connectionGoalComplete + ? undefined + : connection.goalState; + useEffect(() => { + const owner = goalControlOwnerRef.current; + // Release the busy latch only when no control operation owns it, or when + // its owner belongs to a session we have left (that operation's `finally` + // can no longer release it here). Releasing it while an operation is still + // in flight — which a server-side goal replacement would otherwise do — + // re-enables the strip and lets a second control dispatch against the same + // expected revision, so one of the two loses with a 409. + if (!owner || owner.sessionId !== connection.sessionId) { + goalControlOwnerRef.current = undefined; + setGoalControlBusy(false); + } + setGoalEditOpen(false); + setGoalEditError(null); + }, [ + connection.goalState?.goal?.goalId, + connection.sessionId, + connectionGoalComplete, + ]); const { artifacts } = useSessionArtifacts(); const openSubagentDetails = useCallback( (tool: ACPToolCall) => { @@ -538,6 +581,7 @@ export function ChatPane({ queuedTexts, enqueuePrompt, removeQueuedPrompt, + insertQueuedPrompt, editQueuedPrompt, editLastQueuedPrompt, clearQueuedPrompts, @@ -551,6 +595,7 @@ export function ChatPane({ canInjectMidTurnMedia, workspaceFileActions: attachmentWorkspaceTarget?.actions, streamingState, + holdQueuedPromptsLocally: isGoalGateBlocked(connection), sessionActions: actions, store, editorRef, @@ -570,6 +615,86 @@ export function ChatPane({ return undefined; }, [messages, isResponding]); + const controlGoal = useCallback( + async ( + action: 'replace' | 'edit' | 'pause' | 'resume' | 'clear', + objective?: string, + ) => { + const busyOwner = sessionOwnerGuard.capture(); + const busySessionId = connectionRef.current.sessionId; + const expectedGoalId = connectionRef.current.goalState?.goal?.goalId; + const opId = ++goalControlOpSeqRef.current; + goalControlOwnerRef.current = { opId, sessionId: busySessionId }; + setGoalControlBusy(true); + try { + const snapshot = (await actions.getGoal()).snapshot; + const goal = snapshot.goal; + if ( + (action === 'replace' || action === 'edit') && + goal?.goalId !== expectedGoalId + ) { + throw new Error(t('goals.error.goalUnavailable')); + } + const request = buildGoalControlRequest(action, goal, objective, { + emptyObjective: t('goals.error.emptyCondition'), + goalUnavailable: t('goals.error.goalUnavailable'), + }); + if (!busyOwner.isCurrent()) { + throw new Error(t('goals.error.goalUnavailable')); + } + try { + return await actions.controlGoal(request); + } catch (error) { + await actions.getGoal().catch(() => undefined); + throw error; + } + } finally { + // A newer operation (or a session change) owns the latch now; leave it + // to whoever owns it rather than releasing it under them. + if (goalControlOwnerRef.current?.opId === opId) { + goalControlOwnerRef.current = undefined; + if (connectionRef.current.sessionId === busySessionId) { + setGoalControlBusy(false); + } + } + } + }, + [actions, sessionOwnerGuard, t], + ); + + const runGoalControl = useCallback( + (action: 'pause' | 'resume' | 'clear') => { + const owner = sessionOwnerGuard.capture(); + void controlGoal(action).catch((error: unknown) => { + // A control dropped because the pane moved to another session is not a + // failure the user needs to see — `handleGoalEditSave` and the main + // composer swallow the same race. + if (!owner.isCurrent()) return; + reportError(error, t(`goals.error.${action}Failed`)); + }); + }, + [controlGoal, reportError, sessionOwnerGuard, t], + ); + + const handleGoalEditSave = useCallback( + (objective: string) => { + const owner = sessionOwnerGuard.capture(); + setGoalEditError(null); + void controlGoal('edit', objective) + .then(() => { + if (owner.isCurrent()) setGoalEditOpen(false); + }) + .catch((error: unknown) => { + if (!owner.isCurrent()) return; + setGoalEditError( + error instanceof Error ? error.message : String(error), + ); + reportError(error, t('goals.error.editFailed')); + }); + }, + [controlGoal, reportError, sessionOwnerGuard, t], + ); + const handleSubmit = useCallback( ( text: string, @@ -582,12 +707,69 @@ export function ChatPane({ if (!trimmed && (images?.length ?? 0) === 0 && (files?.length ?? 0) === 0) return false; if (admissionPayloadLocked) return false; + // The host handler is documented as running before Web Shell handles a + // slash command, so it gets `/goal` first here exactly as it does in the + // main composer — otherwise an override works on one surface only. if ( trimmed && invokeSlashCommandHandler(text, onSlashCommandRef.current, reportError) ) { return true; } + if (/^\/goal(?:\s|$)/i.test(trimmed)) { + // The same guard App.tsx applies before any slash handling: a control + // that cannot reach the daemon must leave the text in the composer + // instead of consuming it, appending a transcript entry, and failing + // later at `requireSessionForAction` with only a toast. + if ( + shouldBlockComposerSubmit({ + connectionStatus: connection.status, + hasSession: Boolean(connection.sessionId), + }) + ) { + return false; + } + if ( + (images?.length ?? 0) > 0 || + (files?.length ?? 0) > 0 || + (metadata?.inputAnnotations?.length ?? 0) > 0 + ) { + const message = t('goals.error.attachmentsUnsupported'); + reportError(new Error(message), message); + return false; + } + const operation = parseWebShellGoalCommand(trimmed); + if (operation.kind === 'status') { + // A pane without a Goals surface (the side-task pane passes no + // handler) would otherwise consume the text and open nothing. + if (!onOpenGoals) { + reportError( + new Error(t('goals.error.goalsUnavailable')), + t('goals.error.goalsUnavailable'), + ); + return false; + } + onOpenGoals(); + return true; + } + if (operation.kind === 'error') { + const message = t('goals.error.requiresObjective', { + keyword: operation.keyword, + }); + reportError(new Error(message), message); + return false; + } + const action = operation.kind === 'set' ? 'replace' : operation.kind; + const objective = + operation.kind === 'set' || operation.kind === 'edit' + ? operation.objective + : undefined; + store.appendLocalUserMessage(text); + void controlGoal(action, objective).catch((error: unknown) => { + reportError(error, `Failed to ${operation.kind} /goal`); + }); + return true; + } if ( shouldBlockComposerSubmit({ connectionStatus: connection.status, @@ -607,7 +789,17 @@ export function ChatPane({ onFirstPromptAdmitted(trimmed); } }; - if (streamingStateRef.current === 'idle') { + // Fail CLOSED on a hydrating `goalState`, exactly as the local hold + // above does: the load makes the composer writable before `goal()` + // resolves, and the daemon has no server-side prompt gate for an active + // Goal, so a direct send in that window bypasses the Goal queue. + if ( + streamingStateRef.current === 'idle' && + !isGoalGateBlocked({ + sessionId: connection.sessionId, + goalState: connection.goalState, + }) + ) { const admissionOwner = admissionOwnerRef.current; let admissionStarted = false; let admitted = false; @@ -680,13 +872,20 @@ export function ChatPane({ admissionPayloadLocked, catalogOwnerCwd, clearFollowup, + // The whole snapshot, not just the status: the gate distinguishes an + // absent (hydrating) snapshot from a Goal-less one, and both read as an + // undefined status. + connection.goalState, connection.sessionId, connection.status, + controlGoal, enqueuePrompt, onFirstPromptAdmitted, onImageIngestionNotice, + onOpenGoals, reportError, sessionCatalogController, + store, t, ], ); @@ -934,6 +1133,19 @@ export function ChatPane({ data-testid="chat-pane" aria-label={headerLabel} > + {goalEditOpen && connection.goalState?.goal && ( + { + if (goalControlBusy) return; + setGoalEditOpen(false); + setGoalEditError(null); + }} + /> + )} {!embedded && (
- + {(queuedPrompts.length > 0 || liveGoalSnapshot?.goal) && ( +
+ + {liveGoalSnapshot?.goal && ( + { + setGoalEditError(null); + setGoalEditOpen(true); + }} + onPause={() => runGoalControl('pause')} + onResume={() => runGoalControl('resume')} + onClear={() => runGoalControl('clear')} + /> + )} +
+ )} {unknownPromptAdmission && (
:global([data-web-shell-queued-prompts]), +.root > [data-web-shell-goal-status] { + width: 100%; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; +} + +.root + > :global([data-web-shell-queued-prompts]) + + [data-web-shell-goal-status] { + border-top: 1px solid color-mix(in srgb, var(--border) 74%, transparent); +} diff --git a/packages/web-shell/client/components/GoalStatusStrip.module.css b/packages/web-shell/client/components/GoalStatusStrip.module.css new file mode 100644 index 0000000000..a592387538 --- /dev/null +++ b/packages/web-shell/client/components/GoalStatusStrip.module.css @@ -0,0 +1,114 @@ +.root { + container-type: inline-size; + display: flex; + align-items: center; + gap: 10px; + width: calc(100% - 32px); + min-width: 0; + min-height: 42px; + box-sizing: border-box; + margin: 0 auto -8px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 12px 12px 0 0; + background: var(--background); + color: var(--muted-foreground); +} + +.target { + flex: 0 0 auto; + color: color-mix(in srgb, var(--foreground) 62%, var(--muted-foreground)); +} + +.summary { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + flex: 1 1 auto; + font-size: 13px; + line-height: 1.25; +} + +.status { + flex: 0 0 auto; + color: var(--foreground); + font-weight: 600; +} + +.activity, +.objective { + color: var(--muted-foreground); +} + +.activity, +.separator, +.elapsed { + flex: 0 0 auto; +} + +.objective { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.separator, +.elapsed { + color: color-mix(in srgb, var(--muted-foreground) 78%, transparent); +} + +.actions { + display: inline-flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; +} + +.action { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 28px; + padding: 0; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.action:hover:not(:disabled), +.action:focus-visible:not(:disabled) { + outline: none; + background: var(--muted); + color: var(--foreground); +} + +.action:focus-visible:not(:disabled) { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent); +} + +.action:disabled { + opacity: 0.45; + cursor: default; +} + +/* + * `.root` establishes the container, so it can only style its DESCENDANTS from + * here — an element is never its own container query target. Compacting the + * strip's own gap/padding would need a separate outer element carrying + * `container-type`; until then keep this block to what actually resolves, + * rather than leaving half a responsive rule that silently does nothing. + */ +@container (max-width: 620px) { + .activity, + .separator, + .elapsed { + display: none; + } +} diff --git a/packages/web-shell/client/components/GoalStatusStrip.test.tsx b/packages/web-shell/client/components/GoalStatusStrip.test.tsx new file mode 100644 index 0000000000..a6996847b6 --- /dev/null +++ b/packages/web-shell/client/components/GoalStatusStrip.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GoalSnapshotV2 } from '@qwen-code/sdk/daemon'; +import { I18nProvider } from '../i18n'; +import { GOAL_EVIDENCE_LIMIT_REASONS } from '../utils/goalGate'; +import { GoalStatusStrip, getGoalActiveTimeMs } from './GoalStatusStrip'; + +function snapshot( + status: NonNullable['status'], +): GoalSnapshotV2 { + return { + v: 2, + activity: status === 'active' ? 'running' : 'idle', + goal: { + goalId: 'goal-1', + revision: 2, + objective: 'ship every surface', + status, + evidenceCursor: { recordId: null }, + turnCount: 3, + activeTimeMs: 4000, + createdAt: 1000, + updatedAt: 5000, + }, + }; +} + +describe('GoalStatusStrip', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + function render(status: NonNullable['status']) { + const handlers = { + onEdit: vi.fn(), + onPause: vi.fn(), + onResume: vi.fn(), + onClear: vi.fn(), + }; + act(() => { + root.render( + + + , + ); + }); + return handlers; + } + + it('shows pause for an active Goal and wires actions', () => { + const handlers = render('active'); + expect(container.textContent).toContain('In progress'); + expect(container.textContent).toContain('ship every surface'); + + act(() => { + container + .querySelector('[aria-label="Edit goal"]')! + .click(); + container + .querySelector('[aria-label="Pause goal"]')! + .click(); + container + .querySelector('[aria-label="Clear goal"]')! + .click(); + }); + + expect(handlers.onEdit).toHaveBeenCalledOnce(); + expect(handlers.onPause).toHaveBeenCalledOnce(); + expect(handlers.onClear).toHaveBeenCalledOnce(); + expect(container.querySelector('[aria-label="Resume goal"]')).toBeNull(); + }); + + it('shows resume for recoverable stopped states and hides completed Goals', () => { + render('blocked'); + expect( + container.querySelector('[aria-label="Resume goal"]'), + ).not.toBeNull(); + expect(container.querySelector('[aria-label="Pause goal"]')).toBeNull(); + + act(() => { + root.render( + + + , + ); + }); + expect( + container.querySelector('[data-testid="goal-status-strip"]'), + ).toBeNull(); + }); + + it('hides resume for an evidence-limited Goal', () => { + // The reducer refuses to resume a Goal stopped at an evidence bound, so + // offering the control only earns the user an invalid-transition 409. + const limited = snapshot('usage_limited'); + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('[aria-label="Resume goal"]')).toBeNull(); + expect( + container.querySelector('[data-testid="goal-status-strip"]'), + ).not.toBeNull(); + }); + + it('hides resume for a Goal evidence-limited before `limitKind` existed', () => { + // The sentinel prose shipped before the `limitKind` field did, so a Goal + // persisted in that window restores as `usage_limited` with no `limitKind` + // at all. The reducer still refuses it; a gate keyed off `limitKind` alone + // offered a Resume button that could only ever earn a 409. + const limited = snapshot('usage_limited'); + for (const lastReason of GOAL_EVIDENCE_LIMIT_REASONS) { + act(() => { + root.render( + + + , + ); + }); + expect(container.querySelector('[aria-label="Resume goal"]')).toBeNull(); + } + }); + + it('still offers resume for an ordinary usage-limited stop', () => { + // Reverse control for the test above: operational stops carry prose in + // `lastReason` too and the reducer resumes them, so the fallback must not + // widen into "any usage_limited Goal with a reason". + const limited = snapshot('usage_limited'); + act(() => { + root.render( + + + , + ); + }); + expect( + container.querySelector('[aria-label="Resume goal"]'), + ).not.toBeNull(); + }); + + it('adds current active time only while active', () => { + expect(getGoalActiveTimeMs(snapshot('active'), 8000)).toBe(7000); + expect(getGoalActiveTimeMs(snapshot('paused'), 8000)).toBe(4000); + }); +}); diff --git a/packages/web-shell/client/components/GoalStatusStrip.tsx b/packages/web-shell/client/components/GoalStatusStrip.tsx new file mode 100644 index 0000000000..990b9887b9 --- /dev/null +++ b/packages/web-shell/client/components/GoalStatusStrip.tsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import type { GoalSnapshotV2 } from '@qwen-code/sdk/daemon'; +import { Pause, Pencil, Play, Target, Trash2 } from 'lucide-react'; +import { useI18n } from '../i18n'; +import { formatRuntime } from '../utils/formatRuntime'; +import { canResumeGoal } from '../utils/goalGate'; +import styles from './GoalStatusStrip.module.css'; + +const TICK_INTERVAL_MS = 1000; + +export interface GoalStatusStripProps { + snapshot: GoalSnapshotV2; + busy?: boolean; + onEdit: () => void; + onPause: () => void; + onResume: () => void; + onClear: () => void; +} + +export function getGoalActiveTimeMs( + snapshot: GoalSnapshotV2, + now: number, +): number { + const goal = snapshot.goal; + if (!goal) return 0; + return ( + goal.activeTimeMs + + (goal.status === 'active' ? Math.max(0, now - goal.updatedAt) : 0) + ); +} + +export function GoalStatusStrip({ + snapshot, + busy = false, + onEdit, + onPause, + onResume, + onClear, +}: GoalStatusStripProps) { + const { t } = useI18n(); + const goal = snapshot.goal; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (goal?.status !== 'active') return; + const id = window.setInterval(() => setNow(Date.now()), TICK_INTERVAL_MS); + return () => window.clearInterval(id); + }, [goal?.status]); + + if (!goal || goal.status === 'complete') return null; + + const canPause = goal.status === 'active'; + // An evidence-limited stop is terminal for resume: the reducer rejects it + // with an invalid-transition 409, so the control must not be offered. The + // reducer's own rule lives in `canResumeGoal` -- keying off `limitKind` + // alone here missed Goals persisted before that field existed. + const canResume = canResumeGoal(goal); + + return ( +
+
+ ); +} diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 003cda4dbd..caf916fa74 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -34,7 +34,6 @@ interface MessageItemProps { onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; workspaceCwd?: string; - isLatest?: boolean; showRetryHint?: boolean; onRetryClick?: () => void; sendFailed?: boolean; @@ -55,7 +54,6 @@ export const MessageItem = memo(function MessageItem({ onImagePreview, onAttachmentPreview, workspaceCwd, - isLatest = false, showRetryHint = false, onRetryClick, sendFailed = false, @@ -151,7 +149,6 @@ export const MessageItem = memo(function MessageItem({ onShowContextDetail={onShowContextDetail} onImagePreview={onImagePreview} onAttachmentPreview={onAttachmentPreview} - isLatest={isLatest} showRetryHint={showRetryHint && message.retryable === true} onRetryClick={onRetryClick} /> @@ -295,7 +292,6 @@ function areMessageItemPropsEqual( if (prev.onImagePreview !== next.onImagePreview) return false; if (prev.onAttachmentPreview !== next.onAttachmentPreview) return false; if (prev.workspaceCwd !== next.workspaceCwd) return false; - if (prev.isLatest !== next.isLatest) return false; if (prev.showRetryHint !== next.showRetryHint) return false; if (prev.onRetryClick !== next.onRetryClick) return false; if (prev.sendFailed !== next.sendFailed) return false; diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index 584cfc6680..5713a44faa 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -4844,10 +4844,7 @@ export const MessageList = memo( const renderVirtualItem = useCallback( (index: number) => { - const renderDisplayItem = ( - displayItem: DisplayItem, - isLatest: boolean, - ): ReactNode => { + const renderDisplayItem = (displayItem: DisplayItem): ReactNode => { if (displayItem.type === 'parallel_agents') { return ( @@ -4957,7 +4954,6 @@ export const MessageList = memo( onImagePreview={onImagePreview} onAttachmentPreview={onAttachmentPreview} workspaceCwd={workspaceCwd} - isLatest={isLatest} showRetryHint={showRetryHint} onRetryClick={onRetryClick} sendFailed={ @@ -4998,7 +4994,7 @@ export const MessageList = memo( const item = visibleItems[itemIndex]; if (!item) return null; - return renderDisplayItem(item, itemIndex === visibleItems.length - 1); + return renderDisplayItem(item); }, [ hasHeader, diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.test.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.test.tsx index 980e8c49dd..99d9d1fd59 100644 --- a/packages/web-shell/client/components/QueuedPromptDisplay.test.tsx +++ b/packages/web-shell/client/components/QueuedPromptDisplay.test.tsx @@ -39,6 +39,7 @@ function setup( ) { const handlers = { onDelete: vi.fn(), + onInsert: vi.fn(), onEdit: vi.fn(), }; const prompts: QueuedPrompt[] = overrides.prompts @@ -199,6 +200,83 @@ describe('QueuedPromptDisplay', () => { expect(container.textContent).not.toContain('插入'); }); + it('shows an explicit insert action for a locally held message', () => { + const { container } = setup({ + prompts: [{ id: 1, text: '等待主动插入' }], + }); + expect(container.textContent).toContain('插入'); + }); + + it('hides insert when mid-turn mutation is unavailable', () => { + const { container } = setup({ + prompts: [{ id: 1, text: '等待主动插入' }], + canMutateMidTurn: false, + }); + expect( + container.querySelector(`[aria-label="${t('queue.insert')}"]`), + ).toBeNull(); + }); + + it('hides insert when there is no running turn', () => { + const { container } = setup({ + prompts: [{ id: 1, text: '等待主动插入' }], + canInsertMidTurn: false, + }); + expect( + container.querySelector(`[aria-label="${t('queue.insert')}"]`), + ).toBeNull(); + }); + + it('hides insert for prompts with input annotations', () => { + const { container } = setup({ + prompts: [ + { + id: 1, + text: 'inspect this file', + inputAnnotations: [ + { + type: 'reference', + start: 8, + end: 17, + text: 'this file', + reference: { + id: 'file-1', + kind: 'data-table', + label: 'File', + value: '/tmp/a.ts', + serialized: 'this file', + }, + }, + ], + }, + ], + }); + expect( + container.querySelector(`[aria-label="${t('queue.insert')}"]`), + ).toBeNull(); + }); + + it('hides insert for prompts with file attachments', () => { + const { container } = setup({ + prompts: [ + { + id: 1, + text: 'inspect this file', + files: [ + { + name: 'a.ts', + media_type: 'text/typescript', + text: 'export {};', + }, + ], + }, + ], + }); + expect( + container.querySelector(`[aria-label="${t('queue.insert')}"]`), + ).toBeNull(); + }); + it('allows deleting but not editing a summary-only server row', () => { const { container } = setup({ prompts: [ @@ -450,11 +528,15 @@ describe('QueuedPromptDisplay', () => { expect(handlers.onDelete).toHaveBeenCalledWith(42); }); - it('does not render an insert action for a command prompt', () => { + it('disables the insert action for a command prompt', () => { const { container } = setup({ prompts: [{ id: 1, text: '/help me' }], }); - expect(container.querySelectorAll('button')).toHaveLength(2); - expect(container.textContent).not.toContain('插入'); + expect(container.querySelectorAll('button')).toHaveLength(3); + const insert = container.querySelector( + `[aria-label="${t('queue.insert')}"]`, + ); + expect(insert?.disabled).toBe(true); + expect(insert?.title).toBe(t('queue.insertCommandDisabled')); }); }); diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.tsx index efc4af8a8b..3c7bb527a7 100644 --- a/packages/web-shell/client/components/QueuedPromptDisplay.tsx +++ b/packages/web-shell/client/components/QueuedPromptDisplay.tsx @@ -10,8 +10,10 @@ import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import { Fragment } from 'react'; import deleteIconUrl from '../assets/icons/delete.svg'; import editIconUrl from '../assets/icons/edit.svg'; +import insertIconUrl from '../assets/icons/insert.svg'; import queueIconUrl from '../assets/icons/queue.svg'; import type { getTranslator } from '../i18n'; +import { isCommandPrompt } from '../utils/localCommandQueue'; import { useWebShellCustomization, type UserMessageContentParser, @@ -134,6 +136,7 @@ export interface QueuedPrompt { midTurnState?: 'submitting' | 'queued'; midTurnMessageId?: string; midTurnFailedAction?: 'delete' | 'edit'; + isInserting?: boolean; isEditing?: boolean; isRemoving?: boolean; payloadCompleteness?: 'complete' | 'summary-only'; @@ -143,7 +146,9 @@ export function QueuedPromptDisplay({ prompts, t, canMutateMidTurn = false, + canInsertMidTurn = true, onDelete, + onInsert, onEdit, onImagePreview, onAttachmentPreview, @@ -151,7 +156,9 @@ export function QueuedPromptDisplay({ prompts: readonly QueuedPrompt[]; t: ReturnType; canMutateMidTurn?: boolean; + canInsertMidTurn?: boolean; onDelete: (id: number) => void; + onInsert: (id: number) => void; onEdit: (id: number) => void; onImagePreview?: (src: string, alt?: string) => void; onAttachmentPreview?: (file: AttachmentPreviewRequest) => void; @@ -172,10 +179,11 @@ export function QueuedPromptDisplay({ latestPrompt.serverState !== 'running' && !latestPrompt.isEditing && !latestPrompt.isRemoving && + !latestPrompt.isInserting && latestPrompt.payloadCompleteness !== 'summary-only'; return ( -
+
{prompts.map((prompt) => { const preview = truncateQueuedPromptParts( getQueuedPromptParts(prompt, parseUserMessageContent), @@ -202,17 +210,29 @@ export function QueuedPromptDisplay({ const isSummaryOnly = prompt.payloadCompleteness === 'summary-only'; const showActions = !isMidTurnPending || canMutateMidTurn; const isRemoving = prompt.isRemoving === true; + const isInserting = prompt.isInserting === true; + const canInsert = + canMutateMidTurn && + canInsertMidTurn && + prompt.serverState === undefined && + prompt.serverPromptId === undefined && + !isMidTurnPending && + imageCount === 0 && + fileCount === 0 && + (prompt.inputAnnotations?.length ?? 0) === 0; const hasStateSpinner = isSubmitting || prompt.midTurnState === 'submitting' || prompt.isEditing === true || - isRemoving; + isRemoving || + isInserting; const isBusy = isSubmitting || isRunning || isMidTurnLocked || prompt.isEditing === true || - isRemoving; + isRemoving || + isInserting; const isEditDisabled = isBusy || isSummaryOnly; let editTitle = t('queue.editTip'); if (isEditDisabled) { @@ -333,7 +353,8 @@ export function QueuedPromptDisplay({ isQueued || isMidTurnPending || prompt.isEditing || - isRemoving ? ( + isRemoving || + isInserting ? ( ) : null} {showActions ? ( <> + {canInsert && ( + + )} )} - {goalLabel && - (onOpenGoals ? ( - - ) : ( - - {goalLabel} - - ))}
); diff --git a/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx b/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx index a10af04eef..3460549003 100644 --- a/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx +++ b/packages/web-shell/client/components/WebShellTranscript.dom.test.tsx @@ -370,15 +370,11 @@ describe('WebShellTranscript DOM integration', () => { expect(container.textContent).toContain('Hidden reasoning'); }); - it('suppresses session and goal events while preserving their text', () => { + it('suppresses session events while preserving their text', () => { const sessionEvents: unknown[] = []; - const goalEvents: unknown[] = []; const onSession = (event: Event) => sessionEvents.push((event as CustomEvent).detail); - const onGoal = (event: Event) => - goalEvents.push((event as CustomEvent).detail); window.addEventListener('qwen:open-session', onSession); - window.addEventListener('web-shell-goal-status-active', onGoal); const { container } = render( { expect(container.querySelector('a[role="button"]')).toBeNull(); expect(container.textContent).toContain('All checks pass'); expect(sessionEvents).toEqual([]); - expect(goalEvents).toEqual([]); window.removeEventListener('qwen:open-session', onSession); - window.removeEventListener('web-shell-goal-status-active', onGoal); }); it('mounts a themed scoped portal root and removes it on unmount', () => { diff --git a/packages/web-shell/client/components/dialogs/GoalEditDialog.test.tsx b/packages/web-shell/client/components/dialogs/GoalEditDialog.test.tsx new file mode 100644 index 0000000000..2f428afbcb --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GoalEditDialog.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../../i18n'; +import { WebShellPortalRootContext } from '../../portalRoot'; +import { ThemeProvider } from '../../themeContext'; +import { GoalEditDialog } from './GoalEditDialog'; + +describe('GoalEditDialog', () => { + let container: HTMLDivElement; + let portalRoot: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + portalRoot = document.createElement('div'); + document.body.append(container, portalRoot); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + portalRoot.remove(); + }); + + it('mounts in the Web Shell portal and locks actions while saving', () => { + const onSave = vi.fn(); + const onClose = vi.fn(); + act(() => { + root.render( + + + + + + + , + ); + }); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + const dialog = portalRoot.querySelector('[role="dialog"]')!; + expect(dialog.getAttribute('aria-label')).toBe('Edit goal'); + expect(dialog.querySelector('textarea')?.value).toBe( + 'ship every surface', + ); + expect( + Array.from(dialog.querySelectorAll('button')).every( + (button) => button.disabled, + ), + ).toBe(true); + }); + + const renderDialog = (objective: string, onSave = vi.fn()) => { + act(() => { + root.render( + + + + + + + , + ); + }); + return portalRoot.querySelector( + '[role="dialog"] textarea', + )!; + }; + + const type = (textarea: HTMLTextAreaElement, text: string) => { + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )!.set!; + setter.call(textarea, text); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + it('keeps a typed draft when the Goal objective changes underneath', () => { + // The parents pass live Goal state, so a concurrent edit from another + // client (or the refresh a failed save triggers) arrives as a new prop + // while the user is editing — the textarea holds the only copy. + const textarea = renderDialog('ship every surface'); + type(textarea, 'my typed edit'); + + renderDialog('concurrent edit from another client'); + + expect( + portalRoot.querySelector('[role="dialog"] textarea') + ?.value, + ).toBe('my typed edit'); + }); + + it('adopts a Goal objective change while the field is pristine', () => { + renderDialog('ship every surface'); + + renderDialog('concurrent edit from another client'); + + expect( + portalRoot.querySelector('[role="dialog"] textarea') + ?.value, + ).toBe('concurrent edit from another client'); + }); + + it('saves the typed draft, not the refreshed objective', () => { + const onSave = vi.fn(); + const textarea = renderDialog('ship every surface', onSave); + type(textarea, 'my typed edit'); + renderDialog('concurrent edit from another client', onSave); + + const save = Array.from( + portalRoot.querySelectorAll('[role="dialog"] button'), + ).find((button) => button.textContent === 'Save')!; + act(() => save.click()); + + expect(onSave).toHaveBeenCalledWith('my typed edit'); + }); +}); diff --git a/packages/web-shell/client/components/dialogs/GoalEditDialog.tsx b/packages/web-shell/client/components/dialogs/GoalEditDialog.tsx new file mode 100644 index 0000000000..2dacf5aa73 --- /dev/null +++ b/packages/web-shell/client/components/dialogs/GoalEditDialog.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react'; +import { useI18n } from '../../i18n'; +import { DialogShell } from './DialogShell'; +import styles from './GoalsDialog.module.css'; + +interface GoalEditDialogProps { + objective: string; + saving: boolean; + error?: string | null; + onSave: (objective: string) => void; + onClose: () => void; +} + +export function GoalEditDialog({ + objective, + saving, + error, + onSave, + onClose, +}: GoalEditDialogProps) { + const { t } = useI18n(); + const [value, setValue] = useState(objective); + const [edited, setEdited] = useState(false); + const [localError, setLocalError] = useState(null); + + // Both mount sites pass live Goal state, so the objective changes under an + // open dialog whenever another client edits the Goal or a failed save + // refreshes the snapshot. Adopt those refreshes only while the field is + // still pristine: once the user has typed, the textarea holds the only copy + // of that draft. + useEffect(() => { + if (edited) return; + setValue(objective); + }, [objective, edited]); + + const submit = () => { + if (saving) return; + const trimmed = value.trim(); + if (!trimmed) { + setLocalError(t('goals.error.emptyCondition')); + return; + } + setLocalError(null); + onSave(trimmed); + }; + + return ( + !saving && onClose()} + > +
+