mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 06:35:07 +00:00
merge: keep this branch's guard import, and its own DESIGN paragraph
`capture-local.ts`: the base's import list plus the `assertUnredirectedParent` this branch's parent guard calls. `DESIGN.md`: this branch's history-rewrite paragraph stays where it was, and the widening paragraph it had a second copy of — the same "keep both sides" leftover the base just removed — goes with it.
This commit is contained in:
commit
c4f652ac89
106 changed files with 12624 additions and 3148 deletions
82
.github/workflows/qwen-fleet-shepherd.yml
vendored
82
.github/workflows/qwen-fleet-shepherd.yml
vendored
|
|
@ -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 <id> --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 <id> --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 |'
|
||||
|
|
|
|||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>();
|
||||
const prompts: string[] = [];
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<GoalStateResponse>;
|
||||
|
||||
/**
|
||||
* 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[];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -222,6 +222,19 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
|
|||
GoalPersistenceUnavailableError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalPersistenceUnavailableError,
|
||||
parseGoalControlRequest: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).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<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalConflictError,
|
||||
GoalInvalidTransitionError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).GoalInvalidTransitionError,
|
||||
SessionIdCaseConflictError: (
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
|
||||
).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<string, unknown>) =>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<ReasoningEffort, string> = {
|
|||
// 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<GoalStateResponse> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
await this.sendAvailableCommandsUpdateOrThrow();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1806,11 +1806,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;
|
||||
|
|
|
|||
|
|
@ -230,7 +230,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
|
||||
|
|
@ -239,8 +239,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],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -30,7 +30,7 @@ import { atomicWriteFileSync } from '@qwen-code/qwen-code-core';
|
|||
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,
|
||||
|
|
@ -45,7 +45,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,
|
||||
|
|
@ -53,11 +52,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 {
|
||||
assertUnredirectedParent,
|
||||
REVIEW_TMP_DIR,
|
||||
|
|
@ -68,7 +70,6 @@ import {
|
|||
import { planEffortField } from './lib/effort.js';
|
||||
import {
|
||||
buildDiffPlan,
|
||||
parseDiff,
|
||||
DEFAULT_MAX_CHUNK_LINES,
|
||||
READ_FILE_CHAR_CAP,
|
||||
} from './lib/diff-plan.js';
|
||||
|
|
@ -102,10 +103,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;
|
||||
|
|
@ -271,7 +268,8 @@ type FetchPrResult = PlanReport & {
|
|||
* Present when `--since <sha>` 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
|
||||
|
|
@ -284,22 +282,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
|
||||
|
|
@ -318,47 +316,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. */
|
||||
|
|
@ -473,6 +455,20 @@ export function resolveIncrementalAnchor(
|
|||
return { incremental: { since, effective: true }, diffBase: resolved };
|
||||
}
|
||||
|
||||
/** Count lines of `<ref>:<path>`, 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
|
||||
|
|
@ -525,21 +521,36 @@ const gitProbe: GitProbe = {
|
|||
// refs/tags and refs/heads FIRST, so a tag or branch named
|
||||
// `origin/<ref>` — 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',
|
||||
|
|
@ -547,7 +558,9 @@ const gitProbe: GitProbe = {
|
|||
a,
|
||||
b,
|
||||
);
|
||||
return { sha: out, status };
|
||||
if (status === 0) return out;
|
||||
if (status === 1) return null;
|
||||
throw new GitUnavailable();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -586,58 +599,6 @@ function cleanStale(prNumber: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `path`'s tree entry identical at both ends of the PR?
|
||||
*
|
||||
* The whole ENTRY — `<mode> <oid>` — not the blob. `rev-parse <ref>:<path>`
|
||||
* 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 {
|
||||
|
|
@ -776,35 +737,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<void> {
|
||||
// Sampled HERE, at the start of the round: see `reviewModelId`.
|
||||
const roundModelId = roundModelIdFrom(process.env);
|
||||
|
|
@ -1038,18 +970,29 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
// 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 ` +
|
||||
|
|
@ -1279,12 +1222,13 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
}
|
||||
/** 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 —
|
||||
|
|
@ -1304,211 +1248,103 @@ async function runFetchPr(args: FetchPrArgs): Promise<void> {
|
|||
// 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<string, boolean | null>();
|
||||
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<string, string>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ')) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -293,29 +293,6 @@ export function gitRaw(...args: string[]): Buffer {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count lines of `<ref>:<path>`, 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.
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
/** 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,18 +12,6 @@ function fakeGit(opts: {
|
|||
fetchOk?: boolean;
|
||||
refs?: string[];
|
||||
bases?: Record<string, string>;
|
||||
/**
|
||||
* 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/<name> shadow tag', () => {
|
||||
// A tag literally named `origin/main` — a pushable, server-controlled
|
||||
// refname a plain clone auto-carries — resolves FIRST for the
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
1212
packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts
Normal file
1212
packages/cli/src/commands/review/lib/narrow-diff.integration.test.ts
Normal file
File diff suppressed because it is too large
Load diff
226
packages/cli/src/commands/review/lib/narrow-diff.ts
Normal file
226
packages/cli/src/commands/review/lib/narrow-diff.ts
Normal file
|
|
@ -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<typeof parseDiff>['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<string>;
|
||||
}
|
||||
|
||||
/** 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<string, string>();
|
||||
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<string>,
|
||||
): 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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<string, BridgeSessionGoal> = {
|
||||
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<string, BridgeSessionGoal> = {
|
||||
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,
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <objective>` 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<T, R>(
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<AcpSessionBridge['enqueueMidTurnMessage']>[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<AcpSessionBridge['enqueueMidTurnMessage']>[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<BridgeClientRequestContext | undefined>;
|
||||
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<BridgeClientRequestContext | undefined> =
|
||||
[];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -548,21 +548,18 @@ 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 <lastCommitSha>..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 `<mode>:<blob>`, 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 commit anchor's one blind spot is history rewrites, and the answer is that the anchor was never really a commit. What the round certified, per file, is a PAIR of tree entries — base side and head side, mode included, whose difference is exactly the diff it read — and tree entries are content-addressed: after a rebase that changed nothing about a file's change, its `(base, head)` pair is byte-for-byte the certified pair, and the verdict transfers; a pair that moved (the change amended, the merge-base slid under it, an exec bit flipped, a file swapped for a symlink) re-enters in full. Two pair classes never transfer at all: an absent-base pair (an added file — which is also what a pure rename records for its destination, and a keep-both restructure reproduces the pair while the file's true diff became an all-new addition no round read), and any pair the listing could not produce (the whole lookup goes unusable rather than reading "everything absent"). The pairs also apply while the commit anchor is ALIVE: an upstream-moved merge base changes a file's diff-under-review without one new commit past the anchor, so the scope is the union of the interdiff's files and the pair-moved files, and "empty interdiff" alone never certifies "nothing new" when verdicts are available to check. `fetch-pr` records the pairs at capture time, `cache-commit` promotes them mechanically on a clean high-effort end — the merge moved out of prose the day the cache grew a per-file map, because a model-transcribed pair that drops an entry reads exactly like a file that was never captured — and the CONSUMER is not landed: the transfer was to be `rescope --cache`, and `rescope` is gone — its scoping moved into `fetch-pr --since` when that command grew anchor validation. So a rebase still degrades to a full review today. What this half buys is a record that is sound when the consumer arrives — mode-aware, `.gitattributes`-aware (a round reviews the RENDERING, and an attribute change moves it while the blobs stand still), and refusing an absent-base pair outright. The terms the consumer must honour are unchanged: transfer only under the model that certified the pairs, and only when at least one pair actually transfers, since an "incremental" plan of everything would be a full review wearing the wrong label. The pairs deliberately do not ride the posted-review marker: a hundred-file map does not fit a footnote, so a fresh environment keeps the commit anchor and only the machine that reviewed keeps rebase survival — the cache's original degradation, unchanged.
|
||||
|
||||
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:
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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 <sha> --since-model <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', () => {
|
||||
|
|
|
|||
|
|
@ -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<GoalStateResponse> {
|
||||
return this.jsonRequest<GoalStateResponse>(
|
||||
`/session/${urlEncode(sessionId)}/goal`,
|
||||
'GET /session/:id/goal',
|
||||
{ clientId },
|
||||
);
|
||||
}
|
||||
|
||||
sessionGoalControl(
|
||||
sessionId: string,
|
||||
request: GoalControlRequest,
|
||||
clientId?: string,
|
||||
): Promise<GoalStateResponse> {
|
||||
return this.jsonRequest<GoalStateResponse>(
|
||||
`/session/${urlEncode(sessionId)}/goal`,
|
||||
'POST /session/:id/goal',
|
||||
{ method: 'POST', body: request, clientId },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<HeartbeatResult> {
|
||||
return await this.client.heartbeat(this.sessionId, this.clientId);
|
||||
heartbeat(): Promise<HeartbeatResult> {
|
||||
return this.client.heartbeat(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async artifacts(): Promise<DaemonSessionArtifactsEnvelope> {
|
||||
return await this.client.listSessionArtifacts(
|
||||
this.sessionId,
|
||||
this.clientId,
|
||||
);
|
||||
artifacts(): Promise<DaemonSessionArtifactsEnvelope> {
|
||||
return this.client.listSessionArtifacts(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async addArtifact(
|
||||
addArtifact(
|
||||
artifact: DaemonSessionArtifactInput,
|
||||
): Promise<DaemonSessionArtifactMutationResult> {
|
||||
return await this.client.addSessionArtifact(
|
||||
return this.client.addSessionArtifact(
|
||||
this.sessionId,
|
||||
artifact,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
async removeArtifact(
|
||||
removeArtifact(
|
||||
artifactId: string,
|
||||
): Promise<DaemonSessionArtifactMutationResult> {
|
||||
return await this.client.removeSessionArtifact(
|
||||
return this.client.removeSessionArtifact(
|
||||
this.sessionId,
|
||||
artifactId,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
async setModel(modelId: string): Promise<SetModelResult> {
|
||||
return await this.client.setSessionModel(
|
||||
this.sessionId,
|
||||
modelId,
|
||||
this.clientId,
|
||||
);
|
||||
setModel(modelId: string): Promise<SetModelResult> {
|
||||
return this.client.setSessionModel(this.sessionId, modelId, this.clientId);
|
||||
}
|
||||
|
||||
async setConfigOption(
|
||||
setConfigOption(
|
||||
configId: 'reasoning_effort',
|
||||
value: string,
|
||||
): Promise<DaemonSessionConfigOptionResult> {
|
||||
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<DaemonRewindResult> {
|
||||
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<DaemonForkSessionResult> {
|
||||
return await this.client.forkSession(
|
||||
fork(directive: string): Promise<DaemonForkSessionResult> {
|
||||
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<DaemonSessionRecapResult> {
|
||||
return await this.client.recapSession(this.sessionId, {
|
||||
recap(opts?: { signal?: AbortSignal }): Promise<DaemonSessionRecapResult> {
|
||||
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<DaemonSessionBtwResult> {
|
||||
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<DaemonMidTurnMessageResult> {
|
||||
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<DaemonRemoveMidTurnMessageResult> {
|
||||
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<DaemonRemovePendingPromptResult> {
|
||||
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<DaemonShellCommandResult> {
|
||||
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<DaemonSessionContextStatus> {
|
||||
return await this.client.sessionContext(this.sessionId, this.clientId);
|
||||
context(): Promise<DaemonSessionContextStatus> {
|
||||
return this.client.sessionContext(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async status(): Promise<DaemonSessionSummary> {
|
||||
return await this.client.sessionStatus(this.sessionId, this.clientId);
|
||||
status(): Promise<DaemonSessionSummary> {
|
||||
return this.client.sessionStatus(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async contextUsage(
|
||||
contextUsage(
|
||||
opts: { detail?: boolean } = {},
|
||||
): Promise<DaemonSessionContextUsageStatus> {
|
||||
return await this.client.sessionContextUsage(
|
||||
this.sessionId,
|
||||
opts,
|
||||
this.clientId,
|
||||
);
|
||||
return this.client.sessionContextUsage(this.sessionId, opts, this.clientId);
|
||||
}
|
||||
|
||||
async supportedCommands(): Promise<DaemonSessionSupportedCommandsStatus> {
|
||||
return await this.client.sessionSupportedCommands(
|
||||
this.sessionId,
|
||||
this.clientId,
|
||||
);
|
||||
supportedCommands(): Promise<DaemonSessionSupportedCommandsStatus> {
|
||||
return this.client.sessionSupportedCommands(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async tasks(): Promise<DaemonSessionTasksStatus> {
|
||||
return await this.client.sessionTasks(this.sessionId, this.clientId);
|
||||
tasks(): Promise<DaemonSessionTasksStatus> {
|
||||
return this.client.sessionTasks(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async lspStatus(): Promise<DaemonSessionLspStatus> {
|
||||
return await this.client.sessionLspStatus(this.sessionId, this.clientId);
|
||||
lspStatus(): Promise<DaemonSessionLspStatus> {
|
||||
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<DaemonSessionStatsStatus> {
|
||||
return await this.client.sessionStats(this.sessionId, this.clientId);
|
||||
goal(): Promise<GoalStateResponse> {
|
||||
return this.client.sessionGoal(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
controlGoal(request: GoalControlRequest): Promise<GoalStateResponse> {
|
||||
return this.client.sessionGoalControl(
|
||||
this.sessionId,
|
||||
request,
|
||||
this.clientId,
|
||||
);
|
||||
}
|
||||
|
||||
stats(): Promise<DaemonSessionStatsStatus> {
|
||||
return this.client.sessionStats(this.sessionId, this.clientId);
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
|
|
|
|||
|
|
@ -344,6 +344,14 @@ export type {
|
|||
KnownDaemonEvent,
|
||||
} from './events.js';
|
||||
export type {
|
||||
GoalActivity,
|
||||
GoalControlRequest,
|
||||
GoalLimitKind,
|
||||
GoalRecord,
|
||||
GoalSnapshotV2,
|
||||
GoalStateResponse,
|
||||
GoalStatus,
|
||||
TranscriptCursor,
|
||||
DaemonAgentLevel,
|
||||
DaemonAgentMutationResult,
|
||||
DaemonGeneratedAgentContent,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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<ActiveGoalStatus | null>(null);
|
||||
useLayoutEffect(() => setActiveGoal(null), [logicalSessionKey]);
|
||||
const [goalSnapshot, setGoalSnapshot] = useState<GoalSnapshotV2 | null>(null);
|
||||
const goalSnapshotRef = useRef<GoalSnapshotV2 | null>(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<string | null>(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<ActiveGoalStatus | null>(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 <condition>`
|
||||
* 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<string | undefined>(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<number | null>(null);
|
||||
|
|
@ -8466,6 +8445,13 @@ export function App({
|
|||
const enqueueManualRun = useCallback(
|
||||
(prompt: string): Promise<void> =>
|
||||
new Promise<void>((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({
|
|||
/>
|
||||
</DialogShell>
|
||||
)}
|
||||
{goalEditOpen && goalSnapshot?.goal && (
|
||||
<GoalEditDialog
|
||||
objective={goalSnapshot.goal.objective}
|
||||
saving={goalControlBusy}
|
||||
error={goalEditError}
|
||||
onSave={handleGoalEditSave}
|
||||
onClose={() => {
|
||||
if (goalControlBusy) return;
|
||||
setGoalEditOpen(false);
|
||||
setGoalEditError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAuthDialog && (
|
||||
<DialogShell
|
||||
title={t('auth.title')}
|
||||
|
|
@ -12056,73 +12157,67 @@ export function App({
|
|||
<div className={styles.fullPageBody}>
|
||||
<GoalsDialog
|
||||
onCreateGoal={async (condition) => {
|
||||
// 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({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<QueuedPromptDisplay
|
||||
prompts={queuedPrompts}
|
||||
t={t}
|
||||
canMutateMidTurn={canMutateMidTurn}
|
||||
onDelete={removeQueuedPrompt}
|
||||
onEdit={editQueuedPrompt}
|
||||
onImagePreview={openImagePanel}
|
||||
onAttachmentPreview={openAttachmentPanel}
|
||||
/>
|
||||
{(queuedPrompts.length > 0 ||
|
||||
liveGoalSnapshot?.goal) && (
|
||||
<div
|
||||
className={composerStatusStyles.root}
|
||||
data-testid="composer-status-stack"
|
||||
>
|
||||
<QueuedPromptDisplay
|
||||
prompts={queuedPrompts}
|
||||
t={t}
|
||||
canMutateMidTurn={canMutateMidTurn}
|
||||
canInsertMidTurn={streamingState !== 'idle'}
|
||||
onDelete={removeQueuedPrompt}
|
||||
onInsert={insertQueuedPrompt}
|
||||
onEdit={editQueuedPrompt}
|
||||
onImagePreview={openImagePanel}
|
||||
onAttachmentPreview={openAttachmentPanel}
|
||||
/>
|
||||
{liveGoalSnapshot?.goal && (
|
||||
<GoalStatusStrip
|
||||
snapshot={liveGoalSnapshot}
|
||||
busy={goalControlBusy}
|
||||
onEdit={() => {
|
||||
setGoalEditError(null);
|
||||
setGoalEditOpen(true);
|
||||
}}
|
||||
onPause={() => runGoalControl('pause')}
|
||||
onResume={() => runGoalControl('resume')}
|
||||
onClear={() => runGoalControl('clear')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{CustomComposerHeader && (
|
||||
<div className={styles.composerHeader}>
|
||||
<CustomComposerHeader
|
||||
|
|
@ -12802,6 +12921,7 @@ export function App({
|
|||
(connection.contextWindow ?? 0)
|
||||
: 0
|
||||
}
|
||||
goalSnapshot={goalSnapshot}
|
||||
activeGoal={activeGoal}
|
||||
tasks={footerTasks}
|
||||
availableModes={MODES_CYCLE}
|
||||
|
|
@ -12829,6 +12949,7 @@ export function App({
|
|||
(connection.contextWindow ?? 0)
|
||||
: 0
|
||||
}
|
||||
goalSnapshot={goalSnapshot}
|
||||
activeGoal={activeGoal}
|
||||
tasks={footerTasks}
|
||||
availableModes={MODES_CYCLE}
|
||||
|
|
@ -12865,8 +12986,6 @@ export function App({
|
|||
? backgroundTasks
|
||||
: []
|
||||
}
|
||||
activeGoal={activeGoal}
|
||||
onOpenGoals={openGoals}
|
||||
hideSettings={hideSettings}
|
||||
onToggleShortcuts={handleToggleShortcuts}
|
||||
compact={true}
|
||||
|
|
|
|||
1
packages/web-shell/client/assets/icons/insert.svg
Normal file
1
packages/web-shell/client/assets/icons/insert.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none"><path d="M2 2v6a3 3 0 0 0 3 3h7M9 7l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
|
After Width: | Height: | Size: 213 B |
|
|
@ -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<string, unknown> | 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', () => ({
|
|||
<div
|
||||
data-testid="pane-queue"
|
||||
data-can-mutate-mid-turn={String(props.canMutateMidTurn)}
|
||||
data-can-insert-mid-turn={String(props.canInsertMidTurn)}
|
||||
>
|
||||
{String(props.prompts.length)}
|
||||
</div>
|
||||
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const save = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>('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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[data-testid="goal-status-strip"] button[aria-label="Edit goal"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
const save = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 && (
|
||||
<GoalEditDialog
|
||||
objective={connection.goalState.goal.objective}
|
||||
saving={goalControlBusy}
|
||||
error={goalEditError}
|
||||
onSave={handleGoalEditSave}
|
||||
onClose={() => {
|
||||
if (goalControlBusy) return;
|
||||
setGoalEditOpen(false);
|
||||
setGoalEditError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!embedded && (
|
||||
<header
|
||||
className={`${styles.header} ${workspaceAccentClass ?? ''}`.trim()}
|
||||
|
|
@ -1116,15 +1328,37 @@ export function ChatPane({
|
|||
token count + cancel hint, but no rotating "witty" loading
|
||||
phrase. */}
|
||||
<StreamingStatus startedAt={activeTurnStartedAt} showPhrase={false} />
|
||||
<QueuedPromptDisplay
|
||||
prompts={queuedPrompts}
|
||||
t={t}
|
||||
canMutateMidTurn={canMutateMidTurn}
|
||||
onDelete={removeQueuedPrompt}
|
||||
onEdit={editQueuedPrompt}
|
||||
onImagePreview={handleImagePreview}
|
||||
onAttachmentPreview={handleAttachmentPreview}
|
||||
/>
|
||||
{(queuedPrompts.length > 0 || liveGoalSnapshot?.goal) && (
|
||||
<div
|
||||
className={composerStatusStyles.root}
|
||||
data-testid="composer-status-stack"
|
||||
>
|
||||
<QueuedPromptDisplay
|
||||
prompts={queuedPrompts}
|
||||
t={t}
|
||||
canMutateMidTurn={canMutateMidTurn}
|
||||
canInsertMidTurn={streamingState !== 'idle'}
|
||||
onDelete={removeQueuedPrompt}
|
||||
onInsert={insertQueuedPrompt}
|
||||
onEdit={editQueuedPrompt}
|
||||
onImagePreview={handleImagePreview}
|
||||
onAttachmentPreview={handleAttachmentPreview}
|
||||
/>
|
||||
{liveGoalSnapshot?.goal && (
|
||||
<GoalStatusStrip
|
||||
snapshot={liveGoalSnapshot}
|
||||
busy={goalControlBusy}
|
||||
onEdit={() => {
|
||||
setGoalEditError(null);
|
||||
setGoalEditOpen(true);
|
||||
}}
|
||||
onPause={() => runGoalControl('pause')}
|
||||
onResume={() => runGoalControl('resume')}
|
||||
onClear={() => runGoalControl('clear')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{unknownPromptAdmission && (
|
||||
<div
|
||||
className={styles.admissionUnknown}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
.root {
|
||||
box-sizing: border-box;
|
||||
width: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
margin: 0 auto -8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px 12px 0 0;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.root > :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);
|
||||
}
|
||||
114
packages/web-shell/client/components/GoalStatusStrip.module.css
Normal file
114
packages/web-shell/client/components/GoalStatusStrip.module.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
194
packages/web-shell/client/components/GoalStatusStrip.test.tsx
Normal file
194
packages/web-shell/client/components/GoalStatusStrip.test.tsx
Normal file
|
|
@ -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<GoalSnapshotV2['goal']>['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<GoalSnapshotV2['goal']>['status']) {
|
||||
const handlers = {
|
||||
onEdit: vi.fn(),
|
||||
onPause: vi.fn(),
|
||||
onResume: vi.fn(),
|
||||
onClear: vi.fn(),
|
||||
};
|
||||
act(() => {
|
||||
root.render(
|
||||
<I18nProvider language="en">
|
||||
<GoalStatusStrip snapshot={snapshot(status)} {...handlers} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
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<HTMLButtonElement>('[aria-label="Edit goal"]')!
|
||||
.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Pause goal"]')!
|
||||
.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[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(
|
||||
<I18nProvider language="en">
|
||||
<GoalStatusStrip
|
||||
snapshot={snapshot('complete')}
|
||||
onEdit={vi.fn()}
|
||||
onPause={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<I18nProvider language="en">
|
||||
<GoalStatusStrip
|
||||
snapshot={{
|
||||
...limited,
|
||||
goal: { ...limited.goal!, limitKind: 'evidence_catalog' },
|
||||
}}
|
||||
onEdit={vi.fn()}
|
||||
onPause={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<I18nProvider language="en">
|
||||
<GoalStatusStrip
|
||||
snapshot={{ ...limited, goal: { ...limited.goal!, lastReason } }}
|
||||
onEdit={vi.fn()}
|
||||
onPause={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
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(
|
||||
<I18nProvider language="en">
|
||||
<GoalStatusStrip
|
||||
snapshot={{
|
||||
...limited,
|
||||
goal: {
|
||||
...limited.goal!,
|
||||
lastReason: 'The provider rate-limited this account.',
|
||||
},
|
||||
}}
|
||||
onEdit={vi.fn()}
|
||||
onPause={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
129
packages/web-shell/client/components/GoalStatusStrip.tsx
Normal file
129
packages/web-shell/client/components/GoalStatusStrip.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className={styles.root}
|
||||
data-testid="goal-status-strip"
|
||||
data-web-shell-goal-status=""
|
||||
>
|
||||
<Target className={styles.target} size={17} aria-hidden="true" />
|
||||
<div className={styles.summary}>
|
||||
<span className={styles.status}>{t(`goal.status.${goal.status}`)}</span>
|
||||
<span className={styles.activity}>
|
||||
{t(`goal.activity.${snapshot.activity}`)}
|
||||
</span>
|
||||
<span className={styles.objective} title={goal.objective}>
|
||||
{goal.objective}
|
||||
</span>
|
||||
<span className={styles.separator} aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className={styles.elapsed} data-testid="goal-active-elapsed">
|
||||
{formatRuntime(getGoalActiveTimeMs(snapshot, now))}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={onEdit}
|
||||
disabled={busy}
|
||||
title={t('goal.edit')}
|
||||
aria-label={t('goal.edit')}
|
||||
>
|
||||
<Pencil size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canPause && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={onPause}
|
||||
disabled={busy}
|
||||
title={t('goal.pause')}
|
||||
aria-label={t('goal.pause')}
|
||||
>
|
||||
<Pause size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={onResume}
|
||||
disabled={busy}
|
||||
title={t('goal.resume')}
|
||||
aria-label={t('goal.resume')}
|
||||
>
|
||||
<Play size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.action}
|
||||
onClick={onClear}
|
||||
disabled={busy}
|
||||
title={t('goals.clear')}
|
||||
aria-label={t('goals.clear')}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<MessageTimestamp timestamp={displayItem.timestamp}>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>(
|
||||
`[aria-label="${t('queue.insert')}"]`,
|
||||
);
|
||||
expect(insert?.disabled).toBe(true);
|
||||
expect(insert?.title).toBe(t('queue.insertCommandDisabled'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof getTranslator>;
|
||||
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 (
|
||||
<div className={styles.queuedPrompts}>
|
||||
<div className={styles.queuedPrompts} data-web-shell-queued-prompts="">
|
||||
{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 ? (
|
||||
<span
|
||||
className={`${styles.queuedPromptState}${
|
||||
hasStateSpinner ? ` ${styles.queuedPromptStateLoading}` : ''
|
||||
|
|
@ -348,17 +369,42 @@ export function QueuedPromptDisplay({
|
|||
? t('queue.removing')
|
||||
: prompt.isEditing
|
||||
? t('queue.editing')
|
||||
: isMidTurnPending
|
||||
? t('queue.midTurnQueued')
|
||||
: isQueued
|
||||
? t('queue.serverQueued')
|
||||
: t('queue.submitting')}
|
||||
: isInserting
|
||||
? t('queue.inserting')
|
||||
: isMidTurnPending
|
||||
? t('queue.midTurnQueued')
|
||||
: isQueued
|
||||
? t('queue.serverQueued')
|
||||
: t('queue.submitting')}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className={styles.queuedPromptActions}>
|
||||
{showActions ? (
|
||||
<>
|
||||
{canInsert && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.queuedPromptAction}
|
||||
onClick={() => onInsert(prompt.id)}
|
||||
disabled={isBusy || isCommandPrompt(prompt.text)}
|
||||
aria-label={t('queue.insert')}
|
||||
title={
|
||||
isCommandPrompt(prompt.text)
|
||||
? t('queue.insertCommandDisabled')
|
||||
: isBusy
|
||||
? t('queue.submittingDisabled')
|
||||
: t('queue.insertTip')
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={styles.queuedPromptActionIcon}
|
||||
style={cssUrlVar('--queued-icon-url', insertIconUrl)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('queue.insert')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.queuedPromptAction}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export interface SplitViewProps {
|
|||
onError?: (error: unknown, fallback: string) => void;
|
||||
onImageIngestionNotice?: (tone: 'warning' | 'error', message: string) => void;
|
||||
onSlashCommand?: WebShellSlashCommandHandler;
|
||||
onOpenGoals?: () => void;
|
||||
onRightPanelOpen?: (request: TurnOutputOpenRequest) => void;
|
||||
onOpenMonitor?: (
|
||||
task: DaemonSessionMonitorTaskStatus,
|
||||
|
|
@ -102,6 +103,7 @@ export function SplitView({
|
|||
onError,
|
||||
onImageIngestionNotice,
|
||||
onSlashCommand,
|
||||
onOpenGoals,
|
||||
onRightPanelOpen,
|
||||
onOpenMonitor,
|
||||
onPaneArtifactsChange,
|
||||
|
|
@ -504,6 +506,7 @@ export function SplitView({
|
|||
onError={onError}
|
||||
onImageIngestionNotice={onImageIngestionNotice}
|
||||
onSlashCommand={onSlashCommand}
|
||||
onOpenGoals={onOpenGoals}
|
||||
onRightPanelOpen={onRightPanelOpen}
|
||||
onOpenMonitor={onOpenMonitor}
|
||||
onPaneArtifactsChange={onPaneArtifactsChange}
|
||||
|
|
|
|||
|
|
@ -1,115 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const { mockConnection } = vi.hoisted(() => ({
|
||||
mockConnection: {
|
||||
sessionId: 'session-1' as string | undefined,
|
||||
currentModel: undefined as string | undefined,
|
||||
contextWindow: 0,
|
||||
tokenCount: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({
|
||||
useConnection: () => mockConnection,
|
||||
}));
|
||||
|
||||
const { StatusBar } = await import('./StatusBar');
|
||||
const { I18nProvider } = await import('../i18n');
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function mount(
|
||||
props: Partial<Parameters<typeof StatusBar>[0]> = {},
|
||||
): HTMLDivElement {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<StatusBar
|
||||
onSelectMode={vi.fn()}
|
||||
onSelectModel={vi.fn()}
|
||||
onShowContext={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
tasks={[]}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
const goalButton = () =>
|
||||
document.querySelector<HTMLButtonElement>('button[aria-label^="Goals"]');
|
||||
|
||||
describe('StatusBar goal pill', () => {
|
||||
it('names the active goal in its accessible label', () => {
|
||||
// The visible pill is only "◎ Goal (2m)" — the condition never appears in
|
||||
// it, and `title` is a hover tooltip screen readers do not reliably
|
||||
// announce. Without the condition here, a screen-reader user cannot tell
|
||||
// which goal is running without opening the Goals page.
|
||||
mount({
|
||||
activeGoal: { condition: 'all tests pass', setAt: Date.now() - 5000 },
|
||||
onOpenGoals: vi.fn(),
|
||||
});
|
||||
|
||||
expect(goalButton()?.getAttribute('aria-label')).toBe(
|
||||
'Goals: all tests pass',
|
||||
);
|
||||
// The purpose stays in front of the condition: a bare condition string
|
||||
// gives no hint that activating this opens anything.
|
||||
expect(goalButton()?.getAttribute('aria-label')).toMatch(/^Goals: /);
|
||||
});
|
||||
|
||||
it('falls back to the plain label when no goal is active', () => {
|
||||
mount({ onOpenGoals: vi.fn() });
|
||||
expect(goalButton()).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the Goals page when activated', () => {
|
||||
const onOpenGoals = vi.fn();
|
||||
mount({
|
||||
activeGoal: { condition: 'ship it', setAt: Date.now() },
|
||||
onOpenGoals,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
goalButton()?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onOpenGoals).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the goal as static text when there is nowhere to open', () => {
|
||||
// No `onOpenGoals` (e.g. embedded without the Goals page): the pill must
|
||||
// not pretend to be interactive.
|
||||
mount({ activeGoal: { condition: 'ship it', setAt: Date.now() } });
|
||||
|
||||
expect(goalButton()).toBeNull();
|
||||
expect(document.body.textContent).toContain('/goal active');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon';
|
||||
|
|
@ -13,8 +11,6 @@ import { useI18n } from '../i18n';
|
|||
import { isComposerTask } from '../utils/composerTasks';
|
||||
import styles from './StatusBar.module.css';
|
||||
|
||||
const GOAL_PILL_INTERVAL_MS = 1000;
|
||||
|
||||
export interface StatusBarHandle {
|
||||
focusTaskPill(): boolean;
|
||||
}
|
||||
|
|
@ -51,12 +47,6 @@ interface StatusBarProps {
|
|||
onOpenTasks?: () => void;
|
||||
onReturnToInput?: (text?: string) => void;
|
||||
tasks: readonly DaemonSessionTaskStatus[];
|
||||
activeGoal?: {
|
||||
condition: string;
|
||||
setAt: number;
|
||||
} | null;
|
||||
/** Open the Goals page. When omitted the goal pill stays a plain label. */
|
||||
onOpenGoals?: () => void;
|
||||
/** Hide the settings gear button (e.g. when /settings is in hiddenSlashCommands). */
|
||||
hideSettings?: boolean;
|
||||
/** Toggle the keyboard-shortcuts panel (same as typing `?` in the editor). */
|
||||
|
|
@ -136,16 +126,6 @@ export function getTaskPillLabel(
|
|||
);
|
||||
}
|
||||
|
||||
function formatGoalElapsed(ms: number): string {
|
||||
if (ms < 1000) return '';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
|
||||
function StatusBar(
|
||||
{
|
||||
|
|
@ -156,8 +136,6 @@ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
|
|||
onOpenTasks,
|
||||
onReturnToInput,
|
||||
tasks,
|
||||
activeGoal,
|
||||
onOpenGoals,
|
||||
hideSettings,
|
||||
onToggleShortcuts,
|
||||
compact = false,
|
||||
|
|
@ -174,31 +152,15 @@ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
|
|||
const pct = contextWindow > 0 ? (tokenCount / contextWindow) * 100 : 0;
|
||||
const pctDisplay = pct.toFixed(1);
|
||||
const modeIndicator = getModeIndicator(currentMode, t);
|
||||
const [, setGoalTick] = useState(0);
|
||||
const taskPillRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeGoal) return;
|
||||
const id = setInterval(
|
||||
() => setGoalTick((tick) => (tick + 1) % 1_000_000),
|
||||
GOAL_PILL_INTERVAL_MS,
|
||||
);
|
||||
return () => clearInterval(id);
|
||||
}, [activeGoal]);
|
||||
|
||||
const taskPillLabel = useMemo(() => getTaskPillLabel(tasks, t), [tasks, t]);
|
||||
const hasLeftPrefix = !compact && (connected || !!modeIndicator);
|
||||
const goalElapsed = activeGoal
|
||||
? formatGoalElapsed(Date.now() - activeGoal.setAt)
|
||||
: '';
|
||||
const goalLabel = activeGoal
|
||||
? `◎ ${t('goal.statusActive')}${goalElapsed ? ` (${goalElapsed})` : ''}`
|
||||
: '';
|
||||
const hasLeftContent = !!taskPillLabel || !compact;
|
||||
const hasRightContent =
|
||||
(!compact && !!currentModel) ||
|
||||
(!compact && contextWindow > 0 && tokenCount > 0) ||
|
||||
!!goalLabel;
|
||||
false;
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
|
|
@ -348,31 +310,6 @@ export const StatusBar = forwardRef<StatusBarHandle, StatusBarProps>(
|
|||
</span>
|
||||
</button>
|
||||
)}
|
||||
{goalLabel &&
|
||||
(onOpenGoals ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.goalButton}
|
||||
onClick={onOpenGoals}
|
||||
title={activeGoal?.condition}
|
||||
// The visible label is truncated and the full condition lives
|
||||
// only in `title`, which is a hover tooltip screen readers do
|
||||
// not reliably announce. Name the goal here, but keep the
|
||||
// button's purpose in front of it — the condition alone would
|
||||
// read as a bare string with no hint it opens anything.
|
||||
aria-label={
|
||||
activeGoal?.condition
|
||||
? `${t('sidebar.goals')}: ${activeGoal.condition}`
|
||||
: t('sidebar.goals')
|
||||
}
|
||||
>
|
||||
<span className={styles.goal}>{goalLabel}</span>
|
||||
</button>
|
||||
) : (
|
||||
<span className={styles.goal} title={activeGoal?.condition}>
|
||||
{goalLabel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<WebShellTranscript
|
||||
blocks={[
|
||||
|
|
@ -413,9 +409,7 @@ describe('WebShellTranscript DOM integration', () => {
|
|||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">
|
||||
<WebShellPortalRootContext.Provider value={portalRoot}>
|
||||
<GoalEditDialog
|
||||
objective="ship every surface"
|
||||
saving
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</WebShellPortalRootContext.Provider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
const dialog = portalRoot.querySelector('[role="dialog"]')!;
|
||||
expect(dialog.getAttribute('aria-label')).toBe('Edit goal');
|
||||
expect(dialog.querySelector<HTMLTextAreaElement>('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(
|
||||
<I18nProvider language="en">
|
||||
<ThemeProvider value="dark">
|
||||
<WebShellPortalRootContext.Provider value={portalRoot}>
|
||||
<GoalEditDialog
|
||||
objective={objective}
|
||||
saving={false}
|
||||
onSave={onSave}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
</WebShellPortalRootContext.Provider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return portalRoot.querySelector<HTMLTextAreaElement>(
|
||||
'[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<HTMLTextAreaElement>('[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<HTMLTextAreaElement>('[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<HTMLButtonElement>('[role="dialog"] button'),
|
||||
).find((button) => button.textContent === 'Save')!;
|
||||
act(() => save.click());
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('my typed edit');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<DialogShell
|
||||
title={t('goals.edit')}
|
||||
size="md"
|
||||
dismissible={!saving}
|
||||
onClose={() => !saving && onClose()}
|
||||
>
|
||||
<div className={styles.formFields}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>{t('goals.objective')}</span>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={value}
|
||||
rows={4}
|
||||
disabled={saving}
|
||||
onChange={(event) => {
|
||||
setEdited(true);
|
||||
setValue(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{(localError || error) && (
|
||||
<div className={styles.formError} role="alert">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => !saving && onClose()}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('goals.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t('goals.saving') : t('goals.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import { GOAL_EVIDENCE_LIMIT_REASONS } from '../../utils/goalGate';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
interface MockGoal {
|
||||
|
|
@ -19,12 +21,30 @@ interface MockGoal {
|
|||
setAt: number;
|
||||
lastReason?: string;
|
||||
hasActivePrompt: boolean;
|
||||
snapshot: {
|
||||
v: 2;
|
||||
activity: 'idle' | 'running' | 'verifying';
|
||||
goal: {
|
||||
goalId: string;
|
||||
revision: number;
|
||||
objective: string;
|
||||
status: 'active' | 'paused' | 'blocked' | 'usage_limited' | 'complete';
|
||||
evidenceCursor: { recordId: string | null };
|
||||
turnCount: number;
|
||||
activeTimeMs: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastReason?: string;
|
||||
limitKind?: 'evidence_catalog' | 'checkpoint_request';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const { actions } = vi.hoisted(() => ({
|
||||
actions: {
|
||||
listGoals: vi.fn(),
|
||||
clearGoal: vi.fn(),
|
||||
controlGoal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -90,6 +110,9 @@ async function mount(
|
|||
droppedCount: opts.droppedCount ?? 0,
|
||||
});
|
||||
actions.clearGoal.mockResolvedValue({ cleared: true });
|
||||
actions.controlGoal.mockResolvedValue({
|
||||
snapshot: { v: 2, activity: 'idle', goal: null },
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
|
@ -107,15 +130,38 @@ async function mount(
|
|||
await flush();
|
||||
}
|
||||
|
||||
const baseGoal = (over: Partial<MockGoal> = {}): MockGoal => ({
|
||||
sessionId: 'sess-1',
|
||||
displayName: 'fix-ci',
|
||||
condition: 'all tests pass',
|
||||
iterations: 0,
|
||||
setAt: Date.now() - 5000,
|
||||
hasActivePrompt: false,
|
||||
...over,
|
||||
});
|
||||
const baseGoal = (over: Partial<MockGoal> = {}): MockGoal => {
|
||||
const setAt = over.setAt ?? Date.now() - 5000;
|
||||
const condition = over.condition ?? 'all tests pass';
|
||||
const iterations = over.iterations ?? 0;
|
||||
const lastReason = over.lastReason;
|
||||
const hasActivePrompt = over.hasActivePrompt ?? false;
|
||||
return {
|
||||
sessionId: 'sess-1',
|
||||
displayName: 'fix-ci',
|
||||
condition,
|
||||
iterations,
|
||||
setAt,
|
||||
hasActivePrompt,
|
||||
...over,
|
||||
snapshot: over.snapshot ?? {
|
||||
v: 2,
|
||||
activity: hasActivePrompt ? 'running' : 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 1,
|
||||
objective: condition,
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'cursor-1' },
|
||||
turnCount: iterations,
|
||||
activeTimeMs: 0,
|
||||
createdAt: setAt,
|
||||
updatedAt: setAt,
|
||||
...(lastReason ? { lastReason } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
|
@ -158,6 +204,51 @@ describe('GoalsDialog', () => {
|
|||
expect(document.querySelector('[data-testid="goals-dropped"]')).toBeNull();
|
||||
});
|
||||
|
||||
const stopped = (
|
||||
over: Partial<MockGoal['snapshot']['goal']> = {},
|
||||
): MockGoal => {
|
||||
const base = baseGoal();
|
||||
return {
|
||||
...base,
|
||||
snapshot: {
|
||||
...base.snapshot,
|
||||
goal: { ...base.snapshot.goal, status: 'usage_limited', ...over },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const resumeButton = () =>
|
||||
document.querySelector('[aria-label="Resume goal"]');
|
||||
|
||||
it('offers resume for an ordinary usage-limited stop', async () => {
|
||||
// Reverse control for the two tests below: operational stops carry prose
|
||||
// in `lastReason` too and the reducer resumes them, so the evidence gate
|
||||
// must not widen into "any usage_limited Goal with a reason".
|
||||
await mount([stopped({ lastReason: 'The provider rate-limited us.' })]);
|
||||
expect(resumeButton()).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides resume for an evidence-limited Goal', async () => {
|
||||
// The reducer refuses `resume` on a Goal stopped at an evidence bound, so
|
||||
// offering the control only earns the user an invalid-transition 409.
|
||||
await mount([stopped({ limitKind: 'evidence_catalog' })]);
|
||||
expect(resumeButton()).toBeNull();
|
||||
});
|
||||
|
||||
// One `it` per sentinel: `mount` installs a fresh container each call and
|
||||
// only the last is torn down, so two mounts in one test strand a stale DOM
|
||||
// that every later test then queries.
|
||||
it.each([...GOAL_EVIDENCE_LIMIT_REASONS])(
|
||||
'hides resume for a Goal evidence-limited before `limitKind` existed (%#)',
|
||||
async (lastReason) => {
|
||||
// The sentinel prose shipped before the `limitKind` field did: a Goal
|
||||
// persisted in that window restores as `usage_limited` with no
|
||||
// `limitKind`, and this gate used to read it as resumable.
|
||||
await mount([stopped({ lastReason })]);
|
||||
expect(resumeButton()).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('renders a goal with its condition, turn count and judge verdict', async () => {
|
||||
await mount([
|
||||
baseGoal({ iterations: 3, lastReason: 'two tests still fail' }),
|
||||
|
|
@ -199,32 +290,36 @@ describe('GoalsDialog', () => {
|
|||
expect(onOpenSession).toHaveBeenCalledWith('sess-1');
|
||||
});
|
||||
|
||||
it('clears a goal after confirmation and reloads the list', async () => {
|
||||
it('clears a goal immediately and reloads the list', async () => {
|
||||
await mount([baseGoal()]);
|
||||
actions.listGoals.mockResolvedValue({ goals: [], droppedCount: 0 });
|
||||
|
||||
click(document.querySelector('button[aria-label="Clear goal"]'));
|
||||
await flush();
|
||||
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(actions.clearGoal).toHaveBeenCalledWith('sess-1');
|
||||
expect(window.confirm).not.toHaveBeenCalled();
|
||||
expect(actions.controlGoal).toHaveBeenCalledWith('sess-1', {
|
||||
action: 'clear',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 1,
|
||||
});
|
||||
expect(document.body.textContent).toContain('No active goals');
|
||||
});
|
||||
|
||||
it('does not clear when the confirmation is declined', async () => {
|
||||
it('does not require confirmation to clear', async () => {
|
||||
vi.mocked(window.confirm).mockReturnValue(false);
|
||||
await mount([baseGoal()]);
|
||||
|
||||
click(document.querySelector('button[aria-label="Clear goal"]'));
|
||||
await flush();
|
||||
|
||||
expect(actions.clearGoal).not.toHaveBeenCalled();
|
||||
expect(actions.controlGoal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a clear failure through onError', async () => {
|
||||
const onError = vi.fn();
|
||||
await mount([baseGoal()], { onError });
|
||||
actions.clearGoal.mockRejectedValue(new Error('session is gone'));
|
||||
actions.controlGoal.mockRejectedValue(new Error('session is gone'));
|
||||
|
||||
click(document.querySelector('button[aria-label="Clear goal"]'));
|
||||
await flush();
|
||||
|
|
@ -255,7 +350,7 @@ describe('GoalsDialog', () => {
|
|||
await mount([baseGoal()]);
|
||||
// After mount: the helper itself stubs clearGoal with a resolved value.
|
||||
let release: (() => void) | undefined;
|
||||
actions.clearGoal.mockImplementation(
|
||||
actions.controlGoal.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ cleared: true });
|
||||
|
|
@ -271,13 +366,13 @@ describe('GoalsDialog', () => {
|
|||
click(clearButton());
|
||||
await flush();
|
||||
|
||||
expect(actions.clearGoal).toHaveBeenCalledTimes(1);
|
||||
expect(actions.controlGoal).toHaveBeenCalledTimes(1);
|
||||
expect(clearButton()?.disabled).toBe(true);
|
||||
|
||||
// A second click while the first is still in flight must do nothing.
|
||||
click(clearButton());
|
||||
await flush();
|
||||
expect(actions.clearGoal).toHaveBeenCalledTimes(1);
|
||||
expect(actions.controlGoal).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
release?.();
|
||||
|
|
@ -286,6 +381,56 @@ describe('GoalsDialog', () => {
|
|||
await flush();
|
||||
});
|
||||
|
||||
it('keeps independent sessions busy until their own controls settle', async () => {
|
||||
const first = baseGoal({ sessionId: 'sess-1', displayName: 'one' });
|
||||
const second = baseGoal({
|
||||
sessionId: 'sess-2',
|
||||
displayName: 'two',
|
||||
snapshot: {
|
||||
...baseGoal().snapshot,
|
||||
goal: {
|
||||
...baseGoal().snapshot.goal,
|
||||
goalId: 'goal-2',
|
||||
},
|
||||
},
|
||||
});
|
||||
await mount([first, second]);
|
||||
const releases = new Map<string, () => void>();
|
||||
actions.controlGoal.mockImplementation(
|
||||
(sessionId: string) =>
|
||||
new Promise((resolve) => {
|
||||
releases.set(sessionId, () =>
|
||||
resolve({ snapshot: { v: 2, activity: 'idle', goal: null } }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const cards = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('[role="listitem"]'));
|
||||
const clear = (index: number) =>
|
||||
cards()[index]?.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Clear goal"]',
|
||||
);
|
||||
|
||||
click(clear(0));
|
||||
click(clear(1));
|
||||
await flush();
|
||||
expect(actions.controlGoal).toHaveBeenCalledTimes(2);
|
||||
expect(clear(0)?.disabled).toBe(true);
|
||||
expect(clear(1)?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
releases.get('sess-1')?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(clear(0)?.disabled).toBe(false);
|
||||
expect(clear(1)?.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
releases.get('sess-2')?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an empty condition instead of submitting it', async () => {
|
||||
const onCreateGoal = vi.fn();
|
||||
await mount([], { onCreateGoal });
|
||||
|
|
@ -318,19 +463,16 @@ describe('GoalsDialog', () => {
|
|||
expect(onCreateGoal).toHaveBeenCalledWith(condition);
|
||||
});
|
||||
|
||||
it('rejects a clear keyword, which would drop the goal instead of setting it', async () => {
|
||||
it('accepts a clear word as a literal objective in the create form', async () => {
|
||||
const onCreateGoal = vi.fn();
|
||||
await mount([], { onCreateGoal });
|
||||
|
||||
click(findButton('New goal'));
|
||||
// `/goal clear` clears; a form that accepted it would spawn a session that
|
||||
// immediately drops its own goal.
|
||||
setTextarea(' Clear ');
|
||||
click(findButton('Set goal'));
|
||||
await flush();
|
||||
|
||||
expect(onCreateGoal).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain('clears a goal rather than');
|
||||
expect(onCreateGoal).toHaveBeenCalledWith('Clear');
|
||||
});
|
||||
|
||||
it('discards the typed condition when the form is cancelled', async () => {
|
||||
|
|
@ -365,6 +507,151 @@ describe('GoalsDialog', () => {
|
|||
expect(document.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
|
||||
it('cannot be dismissed while a submit is in flight', async () => {
|
||||
// The submit outlives the form it was started from: its success arm calls
|
||||
// resetForm() and its failure arm renders an error, both against whatever
|
||||
// form is open when it settles. Closing mid-flight would hand those to the
|
||||
// next goal's form and lose the objective typed into it.
|
||||
let settleCreate: ((created: boolean) => void) | undefined;
|
||||
const onCreateGoal = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
settleCreate = resolve;
|
||||
}),
|
||||
);
|
||||
await mount([], { onCreateGoal });
|
||||
|
||||
click(findButton('New goal'));
|
||||
setTextarea('ship it');
|
||||
click(findButton('Set goal'));
|
||||
await flush();
|
||||
|
||||
expect(document.querySelector('button[aria-label="Close"]')).toBeNull();
|
||||
act(() => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
expect(document.querySelector('textarea')?.value).toBe('ship it');
|
||||
|
||||
await act(async () => {
|
||||
settleCreate?.(true);
|
||||
await flush();
|
||||
});
|
||||
expect(document.querySelector('textarea')).toBeNull();
|
||||
});
|
||||
|
||||
it('submits an edit with the latest polled goal revision', async () => {
|
||||
await mount([baseGoal()]);
|
||||
|
||||
click(document.querySelector('button[aria-label="Edit goal"]'));
|
||||
setTextarea('updated objective');
|
||||
actions.listGoals.mockResolvedValue({
|
||||
goals: [
|
||||
baseGoal({
|
||||
snapshot: {
|
||||
...baseGoal().snapshot,
|
||||
goal: { ...baseGoal().snapshot.goal, revision: 2 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
droppedCount: 0,
|
||||
});
|
||||
click(findButton('Refresh'));
|
||||
await flush();
|
||||
|
||||
click(findButton('Save'));
|
||||
await flush();
|
||||
|
||||
expect(actions.controlGoal).toHaveBeenCalledWith('sess-1', {
|
||||
action: 'edit',
|
||||
objective: 'updated objective',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers no Edit control for a completed goal', async () => {
|
||||
// The reducer rejects `edit` on a completed Goal and completion does not
|
||||
// bump the revision, so the version check passes and the edit dead-ends in
|
||||
// an error toast — the affordance has to disappear with the capability.
|
||||
await mount([
|
||||
baseGoal({
|
||||
snapshot: {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 4,
|
||||
objective: 'all tests pass',
|
||||
status: 'complete',
|
||||
evidenceCursor: { recordId: 'cursor-1' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(document.querySelector('button[aria-label="Edit goal"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('reports an edit for a vanished session as unavailable', async () => {
|
||||
// Falling back to the stale snapshot would compare it against itself and
|
||||
// send a stale expectedRevision, surfacing the daemon's raw conflict error
|
||||
// instead of the friendly copy this path was written for.
|
||||
await mount([baseGoal()]);
|
||||
|
||||
click(document.querySelector('button[aria-label="Edit goal"]'));
|
||||
setTextarea('updated objective');
|
||||
actions.listGoals.mockResolvedValue({ goals: [], droppedCount: 0 });
|
||||
click(findButton('Refresh'));
|
||||
await flush();
|
||||
|
||||
click(findButton('Save'));
|
||||
await flush();
|
||||
|
||||
expect(actions.controlGoal).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'no longer available',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an edit when polling finds a replacement goal', async () => {
|
||||
await mount([baseGoal()]);
|
||||
|
||||
click(document.querySelector('button[aria-label="Edit goal"]'));
|
||||
setTextarea('text meant for the old goal');
|
||||
actions.listGoals.mockResolvedValue({
|
||||
goals: [
|
||||
baseGoal({
|
||||
snapshot: {
|
||||
...baseGoal().snapshot,
|
||||
goal: {
|
||||
...baseGoal().snapshot.goal,
|
||||
goalId: 'goal-2',
|
||||
revision: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
droppedCount: 0,
|
||||
});
|
||||
click(findButton('Refresh'));
|
||||
await flush();
|
||||
|
||||
click(findButton('Save'));
|
||||
await flush();
|
||||
|
||||
expect(actions.controlGoal).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'no longer available',
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a slow /goals poll overlap itself', async () => {
|
||||
// `GET /goals` fans out one probe per live session and a wedged child can
|
||||
// hold it for the bridge's ext-method timeout, which is the same order as
|
||||
|
|
|
|||
|
|
@ -5,14 +5,17 @@
|
|||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { buildGoalControlRequest } from '../../utils/goalControlRequest';
|
||||
import { canResumeGoal } from '../../utils/goalGate';
|
||||
import {
|
||||
useWorkspaceActions,
|
||||
type DaemonGoal,
|
||||
} from '@qwen-code/webui/daemon-react-sdk';
|
||||
import { Pause, Pencil, Play, Trash2 } from 'lucide-react';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { DialogShell } from './DialogShell';
|
||||
import { formatRuntime } from '../../utils/formatRuntime';
|
||||
import { isGoalClearKeyword } from '../../utils/goalCondition';
|
||||
import { getGoalActiveTimeMs } from '../GoalStatusStrip';
|
||||
import styles from './GoalsDialog.module.css';
|
||||
|
||||
/**
|
||||
|
|
@ -26,13 +29,9 @@ const RELOAD_INTERVAL_MS = 10_000;
|
|||
const TICK_INTERVAL_MS = 1000;
|
||||
|
||||
interface GoalsDialogProps {
|
||||
/** Send `/goal <condition>` into a brand-new session and switch to it. Setting
|
||||
* a goal is not a pure write — the daemon registers the Stop hook AND kicks
|
||||
* off the first turn — so it has to travel the prompt path, not a REST POST.
|
||||
*
|
||||
* Return `false` to report a failure this form must not treat as a creation —
|
||||
* the condition stays in the box. Reserved for failures already surfaced
|
||||
* elsewhere; throw to have the message rendered inline instead. */
|
||||
/** Create a canonical Goal in a brand-new session and switch to it.
|
||||
* Return `false` when session setup failed and the error was already shown;
|
||||
* throw to render the control failure inline. */
|
||||
onCreateGoal: (condition: string) => boolean | void | Promise<boolean | void>;
|
||||
/** Open the session driving a goal — its transcript IS the goal's history. */
|
||||
onOpenSession: (sessionId: string) => void;
|
||||
|
|
@ -51,9 +50,13 @@ export function GoalsDialog({
|
|||
/** Sessions the daemon could not probe; their goals are missing from `goals`. */
|
||||
const [droppedCount, setDroppedCount] = useState(0);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [busySessionId, setBusySessionId] = useState<string | null>(null);
|
||||
const [busySessionIds, setBusySessionIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const busySessionIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<DaemonGoal | null>(null);
|
||||
const [condition, setCondition] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
|
@ -112,7 +115,9 @@ export function GoalsDialog({
|
|||
}, [reload]);
|
||||
|
||||
// Only tick the elapsed column while something is actually elapsing.
|
||||
const hasGoals = !!goals?.length;
|
||||
const hasGoals = goals?.some(
|
||||
({ snapshot }) => snapshot.goal?.status === 'active',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasGoals) return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), TICK_INTERVAL_MS);
|
||||
|
|
@ -121,68 +126,119 @@ export function GoalsDialog({
|
|||
|
||||
const resetForm = useCallback(() => {
|
||||
setCondition('');
|
||||
setEditingGoal(null);
|
||||
setFormError(null);
|
||||
setShowForm(false);
|
||||
}, []);
|
||||
|
||||
const setSessionBusy = useCallback((sessionId: string, busy: boolean) => {
|
||||
const next = new Set(busySessionIdsRef.current);
|
||||
if (busy) next.add(sessionId);
|
||||
else next.delete(sessionId);
|
||||
busySessionIdsRef.current = next;
|
||||
if (mountedRef.current) setBusySessionIds(next);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((goal: DaemonGoal) => {
|
||||
setCondition(goal.snapshot.goal?.objective ?? '');
|
||||
setEditingGoal(goal);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const trimmed = condition.trim();
|
||||
if (trimmed.length === 0) {
|
||||
setFormError(t('goals.error.emptyCondition'));
|
||||
return;
|
||||
}
|
||||
// No length cap: `/goal` accepts a condition of any length, and refusing
|
||||
// one here that the daemon would accept only splits the two surfaces.
|
||||
//
|
||||
// The condition travels to the daemon as `/goal <condition>`, so a bare
|
||||
// clear keyword arrives as a clear command: the fresh session would drop
|
||||
// the goal the instant it was set, with nothing to show for it.
|
||||
if (isGoalClearKeyword(trimmed)) {
|
||||
setFormError(t('goals.error.clearKeyword', { word: trimmed }));
|
||||
const editingSessionId = editingGoal?.sessionId;
|
||||
if (editingSessionId && busySessionIdsRef.current.has(editingSessionId)) {
|
||||
return;
|
||||
}
|
||||
if (editingSessionId) setSessionBusy(editingSessionId, true);
|
||||
setSubmitting(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const created = await onCreateGoal(trimmed);
|
||||
if (!mountedRef.current) return;
|
||||
// No goal was started, and the caller already said why. Resetting here
|
||||
// would close the form and drop the condition the user typed.
|
||||
if (created === false) return;
|
||||
if (editingGoal) {
|
||||
// No fallback to the stale snapshot: a session that has dropped out of
|
||||
// the list entirely is exactly the "no longer available" case this
|
||||
// branch reports, and resurrecting it would compare the stale goal
|
||||
// against itself and send a stale expectedRevision the daemon rejects
|
||||
// with a raw conflict error.
|
||||
const currentEditingGoal = goals?.find(
|
||||
(item) => item.sessionId === editingGoal.sessionId,
|
||||
);
|
||||
const goal = currentEditingGoal?.snapshot.goal;
|
||||
if (
|
||||
!currentEditingGoal ||
|
||||
!goal ||
|
||||
goal.goalId !== editingGoal.snapshot.goal?.goalId
|
||||
) {
|
||||
throw new Error(t('goals.error.goalUnavailable'));
|
||||
}
|
||||
await actions.controlGoal(
|
||||
currentEditingGoal.sessionId,
|
||||
buildGoalControlRequest('edit', goal, trimmed, {
|
||||
emptyObjective: t('goals.error.emptyCondition'),
|
||||
goalUnavailable: t('goals.error.goalUnavailable'),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const created = await onCreateGoal(trimmed);
|
||||
if (!mountedRef.current) return;
|
||||
if (created === false) return;
|
||||
}
|
||||
await reload();
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
// The page closed while the prompt was in flight, so the inline form
|
||||
// error has nowhere to render. Toast rather than swallow it.
|
||||
onError(err, t('goals.error.createFailed'));
|
||||
onError(err, t('goals.error.saveFailed'));
|
||||
return;
|
||||
}
|
||||
if (editingGoal) await reload();
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (editingSessionId) setSessionBusy(editingSessionId, false);
|
||||
if (mountedRef.current) setSubmitting(false);
|
||||
}
|
||||
}, [condition, onCreateGoal, onError, resetForm, t]);
|
||||
}, [
|
||||
actions,
|
||||
condition,
|
||||
editingGoal,
|
||||
goals,
|
||||
onCreateGoal,
|
||||
onError,
|
||||
reload,
|
||||
resetForm,
|
||||
setSessionBusy,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleClear = useCallback(
|
||||
async (goal: DaemonGoal) => {
|
||||
const label =
|
||||
goal.condition.length > 60
|
||||
? `${goal.condition.slice(0, 57)}…`
|
||||
: goal.condition;
|
||||
if (!window.confirm(t('goals.clearConfirm', { condition: label }))) {
|
||||
return;
|
||||
}
|
||||
setBusySessionId(goal.sessionId);
|
||||
const control = useCallback(
|
||||
async (item: DaemonGoal, action: 'pause' | 'resume' | 'clear') => {
|
||||
const goal = item.snapshot.goal;
|
||||
if (!goal || busySessionIdsRef.current.has(item.sessionId)) return;
|
||||
setSessionBusy(item.sessionId, true);
|
||||
try {
|
||||
await actions.clearGoal(goal.sessionId);
|
||||
await actions.controlGoal(
|
||||
item.sessionId,
|
||||
buildGoalControlRequest(action, goal, undefined, {
|
||||
emptyObjective: t('goals.error.emptyCondition'),
|
||||
goalUnavailable: t('goals.error.goalUnavailable'),
|
||||
}),
|
||||
);
|
||||
await reload();
|
||||
} catch (err) {
|
||||
onError(err, t('goals.error.clearFailed'));
|
||||
await reload();
|
||||
onError(err, t(`goals.error.${action}Failed`));
|
||||
} finally {
|
||||
if (mountedRef.current) setBusySessionId(null);
|
||||
setSessionBusy(item.sessionId, false);
|
||||
}
|
||||
},
|
||||
[actions, onError, reload, t],
|
||||
[actions, onError, reload, setSessionBusy, t],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -208,6 +264,7 @@ export function GoalsDialog({
|
|||
className={styles.primaryButton}
|
||||
onClick={() => {
|
||||
setCondition('');
|
||||
setEditingGoal(null);
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
|
|
@ -218,11 +275,19 @@ export function GoalsDialog({
|
|||
</div>
|
||||
|
||||
{showForm && (
|
||||
<DialogShell title={t('goals.new')} size="md" onClose={resetForm}>
|
||||
<DialogShell
|
||||
title={t(editingGoal ? 'goals.edit' : 'goals.new')}
|
||||
size="md"
|
||||
// A submit that outlives its form applies `resetForm()`/`setFormError`
|
||||
// to whatever form is open when it settles, so closing mid-flight
|
||||
// would dismiss (or misattribute an error to) the next goal's form.
|
||||
dismissible={!submitting}
|
||||
onClose={resetForm}
|
||||
>
|
||||
<div className={styles.formFields}>
|
||||
<label className={styles.field}>
|
||||
<span className={styles.fieldLabel}>
|
||||
{t('goals.condition')}
|
||||
{t('goals.objective')}
|
||||
<span className={styles.required}>*</span>
|
||||
</span>
|
||||
<textarea
|
||||
|
|
@ -260,7 +325,9 @@ export function GoalsDialog({
|
|||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? t('goals.creating') : t('goals.create')}
|
||||
{submitting
|
||||
? t('goals.saving')
|
||||
: t(editingGoal ? 'goals.save' : 'goals.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -290,28 +357,76 @@ export function GoalsDialog({
|
|||
implicit role under `display: flex` in Safari. Without them a screen
|
||||
reader cannot announce "list, N items" or navigate goal by goal. */}
|
||||
<div className={styles.list} role="list">
|
||||
{(goals ?? []).map((goal) => {
|
||||
const busy = busySessionId === goal.sessionId;
|
||||
{(goals ?? []).map((item) => {
|
||||
const goal = item.snapshot.goal;
|
||||
if (!goal) return null;
|
||||
const busy = busySessionIds.has(item.sessionId);
|
||||
// The reducer rejects `edit` on a completed Goal, and completion does
|
||||
// not bump the revision, so the version check passes and the edit
|
||||
// dead-ends in an error toast. Gate the affordance the way
|
||||
// pause/resume are gated.
|
||||
const canEdit = goal.status !== 'complete';
|
||||
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.
|
||||
// Shared with `GoalStatusStrip` so the two gates cannot drift apart.
|
||||
const canResume = canResumeGoal(goal);
|
||||
return (
|
||||
<div key={goal.sessionId} className={styles.card} role="listitem">
|
||||
<div key={item.sessionId} className={styles.card} role="listitem">
|
||||
<div className={styles.cardHeader}>
|
||||
<span
|
||||
className={`${styles.statusDot} ${goal.hasActivePrompt ? styles.statusDotRunning : ''}`}
|
||||
className={`${styles.statusDot} ${item.snapshot.activity !== 'idle' ? styles.statusDotRunning : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className={styles.cardTitle} title={goal.condition}>
|
||||
{goal.condition}
|
||||
<div className={styles.cardTitle} title={goal.objective}>
|
||||
{goal.objective}
|
||||
</div>
|
||||
<div className={styles.cardMenu}>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconAction}
|
||||
onClick={() => openEdit(item)}
|
||||
disabled={busy}
|
||||
title={t('goal.edit')}
|
||||
aria-label={t('goal.edit')}
|
||||
>
|
||||
<Pencil size={15} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{canPause && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconAction}
|
||||
onClick={() => void control(item, 'pause')}
|
||||
disabled={busy}
|
||||
title={t('goal.pause')}
|
||||
aria-label={t('goal.pause')}
|
||||
>
|
||||
<Pause size={15} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{canResume && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconAction}
|
||||
onClick={() => void control(item, 'resume')}
|
||||
disabled={busy}
|
||||
title={t('goal.resume')}
|
||||
aria-label={t('goal.resume')}
|
||||
>
|
||||
<Play size={15} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.iconAction}
|
||||
onClick={() => void handleClear(goal)}
|
||||
onClick={() => void control(item, 'clear')}
|
||||
disabled={busy}
|
||||
title={t('goals.clear')}
|
||||
aria-label={t('goals.clear')}
|
||||
>
|
||||
✕
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -327,30 +442,33 @@ export function GoalsDialog({
|
|||
|
||||
<div className={styles.cardFooter}>
|
||||
<span className={styles.statusPill}>
|
||||
{t(goal.hasActivePrompt ? 'goals.running' : 'goals.idle')}
|
||||
{t(`goal.status.${goal.status}`)}
|
||||
</span>
|
||||
<span className={styles.meta} data-testid="goal-activity">
|
||||
{t(`goal.activity.${item.snapshot.activity}`)}
|
||||
</span>
|
||||
<span className={styles.meta}>
|
||||
{goal.iterations > 0
|
||||
? t(goal.iterations === 1 ? 'goal.turn' : 'goal.turns', {
|
||||
count: goal.iterations,
|
||||
{goal.turnCount > 0
|
||||
? t(goal.turnCount === 1 ? 'goal.turn' : 'goal.turns', {
|
||||
count: goal.turnCount,
|
||||
})
|
||||
: t('goals.notYetEvaluated')}
|
||||
</span>
|
||||
<span className={styles.meta} data-testid="goal-elapsed">
|
||||
{formatRuntime(Math.max(0, now - goal.setAt))}
|
||||
{formatRuntime(getGoalActiveTimeMs(item.snapshot, now))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sessionLink}
|
||||
onClick={() => onOpenSession(goal.sessionId)}
|
||||
onClick={() => onOpenSession(item.sessionId)}
|
||||
title={t('goals.openSessionHint')}
|
||||
// The visible text is just the session's name, which says
|
||||
// nothing about what activating it does. Name the action AND
|
||||
// the target — the target stays in the accessible name so it
|
||||
// still contains the visible label (WCAG 2.5.3).
|
||||
aria-label={`${t('goals.openSessionHint')}: ${goal.displayName || goal.sessionId}`}
|
||||
aria-label={`${t('goals.openSessionHint')}: ${item.displayName || item.sessionId}`}
|
||||
>
|
||||
{goal.displayName || goal.sessionId}
|
||||
{item.displayName || item.sessionId}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ describe('ScheduledTasksDialog run now', () => {
|
|||
click(document.querySelector('[aria-label="Run now"]'));
|
||||
await flush();
|
||||
// Server-side run record (updates last-run) + client run in the bound session.
|
||||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1', undefined);
|
||||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1', undefined); // consumed
|
||||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||||
});
|
||||
|
||||
|
|
@ -834,7 +834,7 @@ describe('ScheduledTasksDialog run now', () => {
|
|||
);
|
||||
click(document.querySelector('[aria-label="Run now"]'));
|
||||
await flush();
|
||||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1', undefined); // consumed
|
||||
expect(actions.runScheduledTask).toHaveBeenCalledWith('t1', undefined);
|
||||
expect(onRunPrompt).toHaveBeenCalledWith('do it', 'sess-9');
|
||||
expect(onError).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
|
|
|
|||
|
|
@ -1057,9 +1057,9 @@ export function ScheduledTasksDialog({
|
|||
// One-shot: /run IS its single fire — it deletes the task. Consume it
|
||||
// BEFORE enqueuing so it can't ALSO fire at its own scheduled slot (a
|
||||
// silent double execution). The trade-off is that a failed delivery
|
||||
// leaves the task gone AND un-run — and reload() has already dropped it
|
||||
// from the list — so surface THAT explicitly rather than the generic
|
||||
// "run failed", which would hide the deletion.
|
||||
// leaves the task gone AND un-run — and reload() has already dropped
|
||||
// it from the list — so surface THAT explicitly rather than the
|
||||
// generic "run failed", which would hide the deletion.
|
||||
await actions.runScheduledTask(fresh.id, task.workspaceId);
|
||||
await reload();
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { DAEMON_GOAL_STATUS_SENTINEL_PREFIX } from '@qwen-code/sdk/daemon';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { useTranscriptRenderMode } from '../../transcriptRenderMode';
|
||||
import { formatRuntime } from '../../utils/formatRuntime';
|
||||
import { createSentinelSerializer } from '../../utils/sentinelMessage';
|
||||
import styles from './GoalStatusMessage.module.css';
|
||||
|
|
@ -24,8 +22,6 @@ export interface SerializedGoalStatusMessage {
|
|||
lastReason?: string;
|
||||
}
|
||||
|
||||
export const GOAL_STATUS_ACTIVE_EVENT = 'web-shell-goal-status-active';
|
||||
|
||||
const {
|
||||
serialize: serializeGoalStatusMessage,
|
||||
parse: parseRawGoalStatusMessage,
|
||||
|
|
@ -142,27 +138,10 @@ function getTitle(
|
|||
|
||||
export function GoalStatusMessage({
|
||||
status,
|
||||
activateFooter = false,
|
||||
}: {
|
||||
status: SerializedGoalStatusMessage;
|
||||
activateFooter?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const renderMode = useTranscriptRenderMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!activateFooter || renderMode === 'readonly') return;
|
||||
const active = status.kind === 'set' || status.kind === 'checking';
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(GOAL_STATUS_ACTIVE_EVENT, {
|
||||
detail: {
|
||||
active,
|
||||
condition: status.condition,
|
||||
setAt: status.setAt,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [activateFooter, renderMode, status.condition, status.kind, status.setAt]);
|
||||
|
||||
const title = getTitle(status, t);
|
||||
const stats: string[] = [];
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||
import { act, type ReactNode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../../i18n';
|
||||
import { TranscriptRenderModeProvider } from '../../transcriptRenderMode';
|
||||
import { serializeGoalStatusMessage } from './GoalStatusMessage';
|
||||
import { SystemMessage } from './SystemMessage';
|
||||
|
||||
(
|
||||
|
|
@ -348,38 +346,6 @@ describe('SystemMessage — background notification i18n body', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('SystemMessage — goal status activation', () => {
|
||||
const content = serializeGoalStatusMessage({
|
||||
kind: 'set',
|
||||
condition: 'Ship safely',
|
||||
setAt: 1,
|
||||
});
|
||||
|
||||
it('keeps the existing interactive event behavior by default', () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('web-shell-goal-status-active', handler);
|
||||
const container = render(
|
||||
<SystemMessage content={content} variant="info" isLatest />,
|
||||
);
|
||||
expect(container.textContent).toContain('Ship safely');
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
window.removeEventListener('web-shell-goal-status-active', handler);
|
||||
});
|
||||
|
||||
it('does not dispatch the goal event in readonly mode', () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('web-shell-goal-status-active', handler);
|
||||
const container = render(
|
||||
<TranscriptRenderModeProvider value="readonly">
|
||||
<SystemMessage content={content} variant="info" isLatest />
|
||||
</TranscriptRenderModeProvider>,
|
||||
);
|
||||
expect(container.textContent).toContain('Ship safely');
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
window.removeEventListener('web-shell-goal-status-active', handler);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SystemMessage — inline images', () => {
|
||||
it('renders image thumbnails when images prop is provided', () => {
|
||||
const container = render(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ interface SystemMessageProps {
|
|||
mimeType?: string;
|
||||
attachmentId?: string;
|
||||
}) => void;
|
||||
isLatest?: boolean;
|
||||
showRetryHint?: boolean;
|
||||
onRetryClick?: () => void;
|
||||
}
|
||||
|
|
@ -57,7 +56,6 @@ export const SystemMessage = memo(function SystemMessage({
|
|||
onShowContextDetail,
|
||||
onImagePreview,
|
||||
onAttachmentPreview,
|
||||
isLatest = false,
|
||||
showRetryHint = false,
|
||||
onRetryClick,
|
||||
}: SystemMessageProps) {
|
||||
|
|
@ -141,7 +139,7 @@ export const SystemMessage = memo(function SystemMessage({
|
|||
if (goalStatus) {
|
||||
return (
|
||||
<div className={styles.flushMessage}>
|
||||
<GoalStatusMessage status={goalStatus} activateFooter={isLatest} />
|
||||
<GoalStatusMessage status={goalStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import type {
|
|||
MarkdownChartLabelOverrides,
|
||||
} from '@datafe-open/markdown-chart';
|
||||
import type { MarkdownChartReactErrorHandler } from '@datafe-open/markdown-chart-react';
|
||||
import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
|
||||
import type {
|
||||
DaemonInputAnnotation,
|
||||
GoalSnapshotV2,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk';
|
||||
import type { ACPToolCall } from './adapters/types';
|
||||
import type { WelcomeHeaderProps } from './components/WelcomeHeader';
|
||||
|
|
@ -457,6 +460,9 @@ export interface WebShellFooterRenderInfo {
|
|||
model: string;
|
||||
streamingState: DaemonStreamingState;
|
||||
contextUsageRatio: number;
|
||||
/** Canonical daemon-owned Goal state for the active session. */
|
||||
goalSnapshot: GoalSnapshotV2 | null;
|
||||
/** @deprecated Prefer goalSnapshot. */
|
||||
activeGoal: { condition: string; setAt: number } | null;
|
||||
tasks: readonly WebShellTaskInfo[];
|
||||
availableModes: readonly string[];
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ import {
|
|||
type DaemonWorkspaceVoiceStatus,
|
||||
type ExtensionActiveOperations,
|
||||
type ExtensionUpdateCheckResponse,
|
||||
type GoalControlRequest,
|
||||
type GoalSnapshotV2,
|
||||
type PermissionResponse,
|
||||
type PromptRequest,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
|
|
@ -90,6 +92,8 @@ export interface WebShellDaemonScenario {
|
|||
gitLog?: unknown;
|
||||
/** Response for `POST /session/:id/btw`. */
|
||||
btwAnswer?: string;
|
||||
goalSnapshot: GoalSnapshotV2;
|
||||
midTurnMessages: Array<{ messageId: string; text: string }>;
|
||||
/** Stateful response and replay used by historical branch E2E flows. */
|
||||
branch?: {
|
||||
sessionId: string;
|
||||
|
|
@ -380,6 +384,12 @@ export function createWebShellDaemonScenario(
|
|||
gitDiff: overrides.gitDiff,
|
||||
gitLog: overrides.gitLog,
|
||||
btwAnswer: overrides.btwAnswer,
|
||||
goalSnapshot: overrides.goalSnapshot ?? {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
},
|
||||
midTurnMessages: overrides.midTurnMessages ?? [],
|
||||
branch: overrides.branch,
|
||||
};
|
||||
}
|
||||
|
|
@ -614,6 +624,7 @@ function isDaemonPath(path: string): boolean {
|
|||
path === '/workspace/mcp' ||
|
||||
path === '/workspace/voice' ||
|
||||
/^\/workspaces\/[^/]+\/(voice|providers|settings)\/?$/.test(path) ||
|
||||
/^\/workspaces\/[^/]+\/skills\/?$/.test(path) ||
|
||||
/^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path) ||
|
||||
/^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) ||
|
||||
/^\/workspaces\/[^/]+\/channel-types\/?$/.test(path) ||
|
||||
|
|
@ -639,10 +650,15 @@ function isDaemonPath(path: string): boolean {
|
|||
/^\/workspaces\/.+\/github\/(prs\/create|default-branch)\/?$/.test(path) ||
|
||||
/^\/workspace\/github\/(prs\/create|default-branch)\/?$/.test(path) ||
|
||||
path === '/session' ||
|
||||
path === '/goals' ||
|
||||
/^\/file\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/artifacts\/?$/.test(path) ||
|
||||
/^\/permission\/[^/]+\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/goal\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/status\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/mid-turn-message\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/mid-turn-messages(?:\/[^/]+)?\/?$/.test(path) ||
|
||||
/^\/session\/[^/]+\/(load|resume|branch|prompt|permission\/[^/]+|context|supported-commands|events|model|config-option|approval-mode|heartbeat|cancel|detach|btw)\/?$/.test(
|
||||
path,
|
||||
)
|
||||
|
|
@ -689,6 +705,9 @@ function isDaemonRoute(method: string, path: string): boolean {
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
if (method === 'GET' && /^\/workspaces\/[^/]+\/skills\/?$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
if (method === 'GET' && /^\/workspace\/mcp\/[^/]+\/tools\/?$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -779,6 +798,25 @@ function isDaemonRoute(method: string, path: string): boolean {
|
|||
return true;
|
||||
}
|
||||
if (method === 'POST' && path === '/session') return true;
|
||||
if (method === 'GET' && path === '/goals') return true;
|
||||
if (
|
||||
(method === 'GET' || method === 'POST') &&
|
||||
/^\/session\/[^/]+\/goal\/?$/.test(path)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
method === 'POST' &&
|
||||
/^\/session\/[^/]+\/mid-turn-message\/?$/.test(path)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(method === 'GET' || method === 'DELETE') &&
|
||||
/^\/session\/[^/]+\/mid-turn-messages(?:\/[^/]+)?\/?$/.test(path)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (method === 'POST' && /^\/permission\/[^/]+\/?$/.test(path)) return true;
|
||||
if (
|
||||
(method === 'GET' || method === 'DELETE') &&
|
||||
|
|
@ -789,6 +827,9 @@ function isDaemonRoute(method: string, path: string): boolean {
|
|||
if (method === 'GET' && /^\/session\/[^/]+\/events\/?$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
if (method === 'GET' && /^\/session\/[^/]+\/status\/?$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
if (method === 'GET' && /^\/workspaces\/.+\/git\/?$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -833,6 +874,10 @@ async function handleDaemonRoute(
|
|||
await json(route, scenario.skills);
|
||||
return;
|
||||
}
|
||||
if (method === 'GET' && /^\/workspaces\/[^/]+\/skills\/?$/.test(path)) {
|
||||
await json(route, scenario.skills);
|
||||
return;
|
||||
}
|
||||
if (method === 'GET' && path === '/workspace/settings') {
|
||||
await json(route, scenario.settings);
|
||||
return;
|
||||
|
|
@ -1338,6 +1383,95 @@ async function handleDaemonRoute(
|
|||
await json(route, sessionEnvelope(scenario, { attached: false }));
|
||||
return;
|
||||
}
|
||||
if (method === 'GET' && path === '/goals') {
|
||||
const goal = scenario.goalSnapshot.goal;
|
||||
await json(route, {
|
||||
v: 1,
|
||||
goals:
|
||||
goal && goal.status !== 'complete'
|
||||
? [
|
||||
{
|
||||
sessionId: scenario.sessionId,
|
||||
displayName: scenario.displayName,
|
||||
condition: goal.objective,
|
||||
iterations: goal.turnCount,
|
||||
setAt: goal.createdAt,
|
||||
hasActivePrompt: scenario.goalSnapshot.activity !== 'idle',
|
||||
snapshot: scenario.goalSnapshot,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
droppedCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const goalMatch = path.match(/^\/session\/([^/]+)\/goal\/?$/);
|
||||
if (goalMatch) {
|
||||
if (method === 'GET') {
|
||||
await json(route, { snapshot: scenario.goalSnapshot });
|
||||
return;
|
||||
}
|
||||
scenario.goalSnapshot = reduceMockGoal(
|
||||
scenario.goalSnapshot,
|
||||
body as GoalControlRequest,
|
||||
);
|
||||
await json(route, { snapshot: scenario.goalSnapshot });
|
||||
return;
|
||||
}
|
||||
if (method === 'GET' && /^\/session\/[^/]+\/status\/?$/.test(path)) {
|
||||
// The real route answers with a flat `DaemonSessionSummary`; a `{v, state}`
|
||||
// envelope leaves every field the client reads (workspaceCwd, displayName,
|
||||
// worktree, branch) undefined, so session metadata restoration silently
|
||||
// does nothing in every scenario.
|
||||
await json(route, {
|
||||
sessionId: scenario.sessionId,
|
||||
workspaceCwd: scenario.workspaceCwd,
|
||||
displayName: scenario.displayName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const midTurnMatch = path.match(
|
||||
/^\/session\/([^/]+)\/mid-turn-messages(?:\/([^/]+))?\/?$/,
|
||||
);
|
||||
if (midTurnMatch) {
|
||||
const messageId = midTurnMatch[2]
|
||||
? decodeURIComponent(midTurnMatch[2])
|
||||
: undefined;
|
||||
if (method === 'DELETE' && messageId) {
|
||||
const before = scenario.midTurnMessages.length;
|
||||
scenario.midTurnMessages = scenario.midTurnMessages.filter(
|
||||
(message) => message.messageId !== messageId,
|
||||
);
|
||||
await json(route, {
|
||||
removed: scenario.midTurnMessages.length !== before,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await json(route, {
|
||||
messages: scenario.midTurnMessages,
|
||||
settledMessageIds: [],
|
||||
promotedMessageIds: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
method === 'POST' &&
|
||||
/^\/session\/[^/]+\/mid-turn-message\/?$/.test(path)
|
||||
) {
|
||||
const text = readStringField(body, 'message');
|
||||
const messageId =
|
||||
readStringField(body, 'messageId') ??
|
||||
`mid-turn-${scenario.midTurnMessages.length + 1}`;
|
||||
if (!text) {
|
||||
await badRequest(route, 'Invalid mid-turn message.');
|
||||
return;
|
||||
}
|
||||
scenario.midTurnMessages.push({ messageId, text });
|
||||
await json(route, { accepted: true, messageId });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(
|
||||
/^\/session\/([^/]+)\/([^/]+)(?:\/([^/]+))?\/?$/,
|
||||
|
|
@ -1550,6 +1684,74 @@ function promptIdFor(body: PromptRequest): string {
|
|||
return 'prompt-e2e';
|
||||
}
|
||||
|
||||
function reduceMockGoal(
|
||||
snapshot: GoalSnapshotV2,
|
||||
request: GoalControlRequest,
|
||||
): GoalSnapshotV2 {
|
||||
const current = snapshot.goal;
|
||||
const timestamp = Date.now();
|
||||
if (request.action === 'clear') {
|
||||
// The daemon attaches the tombstone on clear, and it is what stops a stale
|
||||
// frame resurrecting the goal client-side — without it the e2e clear step
|
||||
// cannot exercise that path at all.
|
||||
return {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
...(current
|
||||
? {
|
||||
clearedGoal: {
|
||||
goalId: current.goalId,
|
||||
revision: current.revision + 1,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (request.action === 'create' || request.action === 'replace') {
|
||||
return {
|
||||
v: 2,
|
||||
goal: {
|
||||
goalId: `goal-${timestamp}`,
|
||||
revision: 1,
|
||||
objective: request.objective.trim(),
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: null },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
activity: 'idle',
|
||||
};
|
||||
}
|
||||
if (!current) return snapshot;
|
||||
const activeTimeMs =
|
||||
current.activeTimeMs +
|
||||
(current.status === 'active'
|
||||
? Math.max(0, timestamp - current.updatedAt)
|
||||
: 0);
|
||||
return {
|
||||
v: 2,
|
||||
goal: {
|
||||
...current,
|
||||
revision: current.revision + 1,
|
||||
...(request.action === 'edit'
|
||||
? { objective: request.objective.trim() }
|
||||
: {}),
|
||||
...(request.action === 'pause'
|
||||
? { status: 'paused' as const }
|
||||
: request.action === 'resume'
|
||||
? { status: 'active' as const }
|
||||
: {}),
|
||||
activeTimeMs,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
activity: 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
function isPromptRequest(body: unknown): body is PromptRequest {
|
||||
if (!isRecord(body)) return false;
|
||||
const prompt = body['prompt'];
|
||||
|
|
|
|||
200
packages/web-shell/client/e2e/web-shell.goal.spec.ts
Normal file
200
packages/web-shell/client/e2e/web-shell.goal.spec.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
import {
|
||||
createWebShellDaemonScenario,
|
||||
assistantTextEvent,
|
||||
installMockDaemon,
|
||||
replayCompleteEvent,
|
||||
turnCompleteEvent,
|
||||
type MockDaemonController,
|
||||
type WebShellDaemonScenario,
|
||||
} from './utils/mockDaemon';
|
||||
|
||||
test('creates a Goal directly from a new task before any chat', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const scenario = createWebShellDaemonScenario();
|
||||
const daemon = await installScenario(page, scenario, testInfo);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
|
||||
|
||||
await submitComposer(page, '/goal start without a prior chat message');
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
daemon.requests.some(
|
||||
(request) => request.method === 'POST' && request.path === '/session',
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect.poll(() => goalControlRequests(daemon).length).toBe(1);
|
||||
|
||||
expect(goalControlRequests(daemon)[0]?.body).toEqual({
|
||||
action: 'create',
|
||||
objective: 'start without a prior chat message',
|
||||
});
|
||||
expect(daemon.promptRequests()).toHaveLength(0);
|
||||
await expect(page.getByTestId('goal-status-strip')).toContainText(
|
||||
'start without a prior chat message',
|
||||
);
|
||||
// Re-check after the strip renders: a regression that forwards the objective
|
||||
// as a prompt AFTER the create resolves would land its POST past the
|
||||
// synchronous check above and still ship green.
|
||||
await expect
|
||||
.poll(() => daemon.promptRequests().length, { timeout: 2_000 })
|
||||
.toBe(0);
|
||||
});
|
||||
|
||||
test('runs the canonical Goal and explicit queue interaction chain @smoke', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const scenario = createWebShellDaemonScenario({
|
||||
capabilities: {
|
||||
features: [
|
||||
'session_events',
|
||||
'session_mid_turn_message_mutation',
|
||||
'session_mid_turn_message_query',
|
||||
],
|
||||
},
|
||||
});
|
||||
const daemon = await installScenario(page, scenario, testInfo);
|
||||
await gotoSession(page, scenario, daemon);
|
||||
await expect(
|
||||
page.getByText('Load goal failed:', { exact: false }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await submitComposer(page, '/goal keep shipping until review passes');
|
||||
const strip = page.getByTestId('goal-status-strip');
|
||||
await expect(strip).toContainText('keep shipping until review passes');
|
||||
await expect.poll(() => goalControlRequests(daemon).length).toBe(1);
|
||||
expect(goalControlRequests(daemon).at(-1)?.body).toEqual({
|
||||
action: 'create',
|
||||
objective: 'keep shipping until review passes',
|
||||
});
|
||||
expect(daemon.promptRequests()).toHaveLength(0);
|
||||
await capture(page, testInfo, '01-goal-active.png');
|
||||
|
||||
await strip.getByRole('button', { name: 'Edit goal' }).click();
|
||||
const editDialog = page.getByRole('dialog', { name: 'Edit goal' });
|
||||
await editDialog.getByRole('textbox').fill('ship after complete review');
|
||||
await editDialog.getByRole('button', { name: 'Save' }).click();
|
||||
await expect(strip).toContainText('ship after complete review');
|
||||
expect(goalControlRequests(daemon).at(-1)?.body).toMatchObject({
|
||||
action: 'edit',
|
||||
objective: 'ship after complete review',
|
||||
expectedRevision: 1,
|
||||
});
|
||||
|
||||
await strip.getByRole('button', { name: 'Pause goal' }).click();
|
||||
await expect(strip).toContainText('Paused');
|
||||
await strip.getByRole('button', { name: 'Resume goal' }).click();
|
||||
await expect(strip).toContainText('In progress');
|
||||
|
||||
await submitComposer(page, 'stay queued until I choose');
|
||||
const queue = page.locator('[data-web-shell-queued-prompts]');
|
||||
await expect(queue).toContainText('stay queued until I choose');
|
||||
expect(daemon.promptRequests()).toHaveLength(0);
|
||||
expect(midTurnRequests(daemon)).toHaveLength(0);
|
||||
const [queueWidth, goalWidth] = await Promise.all([
|
||||
queue.evaluate((element) => element.getBoundingClientRect().width),
|
||||
strip.evaluate((element) => element.getBoundingClientRect().width),
|
||||
]);
|
||||
expect(Math.abs(queueWidth - goalWidth)).toBeLessThan(1);
|
||||
await capture(page, testInfo, '02-goal-with-local-queue.png');
|
||||
|
||||
await daemon.sendEvent(
|
||||
assistantTextEvent('Goal turn running', {
|
||||
id: 2,
|
||||
sessionId: scenario.sessionId,
|
||||
}),
|
||||
);
|
||||
await queue.getByRole('button', { name: 'Insert' }).click();
|
||||
await expect.poll(() => midTurnRequests(daemon).length).toBe(1);
|
||||
expect(midTurnRequests(daemon)[0]?.body).toMatchObject({
|
||||
message: 'stay queued until I choose',
|
||||
});
|
||||
await expect(queue).toContainText('Queued...');
|
||||
expect(daemon.promptRequests()).toHaveLength(0);
|
||||
await capture(page, testInfo, '03-explicitly-inserted.png');
|
||||
await daemon.sendEvent(
|
||||
turnCompleteEvent('goal-turn-1', {
|
||||
id: 3,
|
||||
sessionId: scenario.sessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
await submitComposer(page, 'run only after the goal pauses');
|
||||
await expect(queue).toContainText('run only after the goal pauses');
|
||||
expect(daemon.promptRequests()).toHaveLength(0);
|
||||
await strip.getByRole('button', { name: 'Pause goal' }).click();
|
||||
await expect.poll(() => daemon.promptRequests().length).toBe(1);
|
||||
|
||||
let confirmationOpened = false;
|
||||
page.on('dialog', async (dialog) => {
|
||||
confirmationOpened = true;
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await strip.getByRole('button', { name: 'Clear goal' }).click();
|
||||
await expect(strip).toHaveCount(0);
|
||||
expect(confirmationOpened).toBe(false);
|
||||
expect(goalControlRequests(daemon).at(-1)?.body).toMatchObject({
|
||||
action: 'clear',
|
||||
});
|
||||
await capture(page, testInfo, '04-goal-cleared.png');
|
||||
});
|
||||
|
||||
async function installScenario(
|
||||
page: Page,
|
||||
scenario: WebShellDaemonScenario,
|
||||
testInfo: TestInfo,
|
||||
): Promise<MockDaemonController> {
|
||||
return installMockDaemon(page, scenario, {
|
||||
baseURL: String(testInfo.project.use.baseURL),
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoSession(
|
||||
page: Page,
|
||||
scenario: WebShellDaemonScenario,
|
||||
daemon: MockDaemonController,
|
||||
): Promise<void> {
|
||||
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
|
||||
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
|
||||
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
|
||||
await daemon.sendEvent(
|
||||
replayCompleteEvent({ sessionId: connection.sessionId, replayedCount: 0 }),
|
||||
);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function submitComposer(page: Page, text: string): Promise<void> {
|
||||
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
|
||||
await editor.click();
|
||||
await page.keyboard.press(
|
||||
process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
|
||||
);
|
||||
await page.keyboard.insertText(text);
|
||||
await page.locator('[data-web-shell-composer-submit]').click();
|
||||
}
|
||||
|
||||
function goalControlRequests(daemon: MockDaemonController) {
|
||||
return daemon.requests.filter(
|
||||
(request) =>
|
||||
request.method === 'POST' &&
|
||||
/\/session\/[^/]+\/goal\/?$/.test(request.path),
|
||||
);
|
||||
}
|
||||
|
||||
function midTurnRequests(daemon: MockDaemonController) {
|
||||
return daemon.requests.filter((request) =>
|
||||
/\/session\/[^/]+\/mid-turn-message\/?$/.test(request.path),
|
||||
);
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
await page.screenshot({ path: testInfo.outputPath(name), fullPage: true });
|
||||
}
|
||||
|
|
@ -83,6 +83,10 @@ function mount(
|
|||
canMutateMidTurn = true,
|
||||
connected = false,
|
||||
writeBlocked = false,
|
||||
holdQueuedPromptsLocally = false,
|
||||
// `null` = the workspace has not resolved yet (an explicit `undefined`
|
||||
// argument would take the default).
|
||||
workspaceCwd: string | null = '/workspace',
|
||||
) {
|
||||
const editor = {
|
||||
getText: vi.fn(() => ''),
|
||||
|
|
@ -101,22 +105,27 @@ function mount(
|
|||
state,
|
||||
activeSessionId,
|
||||
blocked,
|
||||
hold,
|
||||
cwd,
|
||||
}: {
|
||||
state: typeof streamingState;
|
||||
activeSessionId: string;
|
||||
blocked: boolean;
|
||||
hold: boolean;
|
||||
cwd: string | null;
|
||||
}) {
|
||||
latest = useQueuedPrompts({
|
||||
connected,
|
||||
writeBlocked: blocked,
|
||||
sessionId: activeSessionId,
|
||||
workspaceCwd: '/workspace',
|
||||
workspaceCwd: cwd ?? undefined,
|
||||
clientId: 'client-1',
|
||||
canMutateMidTurn,
|
||||
// This suite pins the legacy local-fallback lifecycle.
|
||||
canQueryMidTurn: false,
|
||||
canInjectMidTurnMedia: false,
|
||||
streamingState: state,
|
||||
holdQueuedPromptsLocally: hold,
|
||||
sessionActions,
|
||||
store,
|
||||
editorRef: { current: editor as never },
|
||||
|
|
@ -128,21 +137,29 @@ function mount(
|
|||
|
||||
let activeSessionId = 'session-1';
|
||||
let blocked = writeBlocked;
|
||||
let held = holdQueuedPromptsLocally;
|
||||
let cwd = workspaceCwd;
|
||||
const render = (
|
||||
state: typeof streamingState,
|
||||
nextSessionId = activeSessionId,
|
||||
replaceOwner = false,
|
||||
nextWriteBlocked = blocked,
|
||||
nextHold = held,
|
||||
nextCwd: string | null = cwd,
|
||||
) => {
|
||||
if (replaceOwner) sdk.ownerVersion += 1;
|
||||
activeSessionId = nextSessionId;
|
||||
blocked = nextWriteBlocked;
|
||||
held = nextHold;
|
||||
cwd = nextCwd;
|
||||
act(() =>
|
||||
root.render(
|
||||
<Harness
|
||||
state={state}
|
||||
activeSessionId={activeSessionId}
|
||||
blocked={blocked}
|
||||
hold={held}
|
||||
cwd={cwd}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
|
@ -184,6 +201,697 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe('useQueuedPrompts default mid-turn insertion', () => {
|
||||
it('holds Goal follow-ups locally until an explicit insert', async () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
|
||||
accepted: true,
|
||||
messageId: 'inserted-1',
|
||||
});
|
||||
mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('wait for explicit insert'));
|
||||
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'wait for explicit insert' },
|
||||
]);
|
||||
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
// An explicit insert is deliberately uncancellable: it carries no abort
|
||||
// signal so an owner rotation cannot kill a send the user asked for.
|
||||
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledWith(
|
||||
'wait for explicit insert',
|
||||
expect.not.objectContaining({ signal: expect.anything() }),
|
||||
);
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: 'wait for explicit insert',
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: 'inserted-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not insert a held prompt between turns', async () => {
|
||||
const { actions } = createActions();
|
||||
mount('idle', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('wait for a running turn'));
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'wait for a running turn' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not insert a held prompt with input annotations', async () => {
|
||||
const { actions } = createActions();
|
||||
mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() =>
|
||||
latest.enqueuePrompt(
|
||||
'inspect this file',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
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',
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not insert a held prompt carrying images', async () => {
|
||||
// `enqueueMidTurnMessage` transmits text only, so inserting an image-bearing
|
||||
// row would silently drop the attachment. The display hides Insert for this
|
||||
// shape; the hook guard is the backstop on the public API.
|
||||
const { actions } = createActions();
|
||||
mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() =>
|
||||
latest.enqueuePrompt('look at this', [
|
||||
{ data: 'abc', media_type: 'image/png' },
|
||||
]),
|
||||
);
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'look at this', images: [{ media_type: 'image/png' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not insert a held slash command', async () => {
|
||||
// A command injected mid-turn arrives as literal text the daemon never
|
||||
// executes, so it must stay queued for the ordinary path.
|
||||
const { actions } = createActions();
|
||||
mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('/compact'));
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([{ text: '/compact' }]);
|
||||
});
|
||||
|
||||
it('does not insert a held prompt with file attachments', async () => {
|
||||
const { actions } = createActions();
|
||||
mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() =>
|
||||
latest.enqueuePrompt('inspect this file', undefined, [
|
||||
{
|
||||
name: 'a.ts',
|
||||
media_type: 'text/typescript',
|
||||
text: 'export {};',
|
||||
},
|
||||
]),
|
||||
);
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'inspect this file', files: [{ name: 'a.ts' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an accepted explicit insert across a session switch', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('stay with session one'));
|
||||
let insertPromise: Promise<void> | undefined;
|
||||
act(() => {
|
||||
insertPromise = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
render('idle', 'session-2', true, false, true);
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'mid-1' });
|
||||
await insertPromise;
|
||||
});
|
||||
render('responding', 'session-1', true, false, true);
|
||||
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: 'stay with session one',
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: 'mid-1',
|
||||
isInserting: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('locks edit and clear while an explicit insert is in flight', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { editor } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('in flight'));
|
||||
act(() => {
|
||||
void latest.insertQueuedPrompt(1);
|
||||
});
|
||||
await act(async () => latest.editQueuedPrompt(1));
|
||||
let consumed = false;
|
||||
let cleared = false;
|
||||
act(() => {
|
||||
consumed = latest.editLastQueuedPrompt();
|
||||
cleared = latest.clearQueuedPrompts();
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
expect(cleared).toBe(false);
|
||||
expect(editor.setText).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'in flight', isInserting: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an explicit insert in flight when the turn becomes idle', () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
|
||||
new Promise(() => undefined),
|
||||
);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('in flight'));
|
||||
act(() => {
|
||||
void latest.insertQueuedPrompt(1);
|
||||
});
|
||||
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
|
||||
?.signal;
|
||||
render('idle', 'session-1', false, false, true);
|
||||
|
||||
// Nothing can cancel an explicit insert: it is issued without a signal.
|
||||
expect(signal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not resubmit a legacy explicit insert accepted as the turn becomes idle', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render, reportError } = mount(
|
||||
'responding',
|
||||
actions,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
act(() => latest.enqueuePrompt('submit after settle'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
render('idle', 'session-1', false, false, false);
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'accepted-once' });
|
||||
await insertion;
|
||||
});
|
||||
|
||||
expect(reportError).not.toHaveBeenCalled();
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('resubmits a legacy explicit insert after an idle transport failure', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render, reportError } = mount(
|
||||
'responding',
|
||||
actions,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
act(() => latest.enqueuePrompt('recover after failure'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
render('idle', 'session-1', false, false, false);
|
||||
await act(async () => {
|
||||
admission.reject(new Error('connection lost'));
|
||||
await insertion;
|
||||
});
|
||||
|
||||
expect(reportError).toHaveBeenCalledOnce();
|
||||
expect(actions.submitPrompt).toHaveBeenCalledWith(
|
||||
'recover after failure',
|
||||
expect.objectContaining({ sessionId: 'session-1' }),
|
||||
);
|
||||
expect(actions.submitPrompt).toHaveBeenCalledOnce();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: 'recover after failure',
|
||||
serverState: 'submitting',
|
||||
isInserting: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('silently holds an explicit insert rejected while a Goal remains active', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render, reportError } = mount(
|
||||
'responding',
|
||||
actions,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
act(() => latest.enqueuePrompt('keep held'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
render('idle', 'session-1', false, false, true);
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: false });
|
||||
await insertion;
|
||||
});
|
||||
|
||||
expect(reportError).toHaveBeenCalledOnce();
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([{ text: 'keep held' }]);
|
||||
expect(latest.queuedPrompts[0]?.midTurnState).toBeUndefined();
|
||||
expect(latest.queuedPrompts[0]?.isInserting).toBe(false);
|
||||
});
|
||||
|
||||
it('lets an explicit insert settle into its source-session stash', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
|
||||
(_message, opts) => {
|
||||
opts?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => admission.reject(new DOMException('aborted', 'AbortError')),
|
||||
{ once: true },
|
||||
);
|
||||
return admission.promise;
|
||||
},
|
||||
);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('insert once'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
|
||||
?.signal;
|
||||
render('idle', 'session-2', true, false, true);
|
||||
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'inserted-once' });
|
||||
await insertion;
|
||||
});
|
||||
render('responding', 'session-1', true, false, true);
|
||||
|
||||
expect(signal).toBeUndefined();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: 'insert once',
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: 'inserted-once',
|
||||
},
|
||||
]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('settles an explicit insert into the stash a cwd relocation moved', async () => {
|
||||
// The workspace half of the owner key resolves mid-insert, which relocates
|
||||
// the whole stash onto the new key and DELETES the old one. Settling
|
||||
// through the key captured when the insert started would write nothing:
|
||||
// the row would come back from the stash still `isInserting`, and every
|
||||
// release/edit/delete/clear path skips such a row — bricked until reload.
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render } = mount(
|
||||
'responding',
|
||||
actions,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null,
|
||||
);
|
||||
|
||||
act(() => latest.enqueuePrompt('insert once'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
// cwd resolves for the SAME session: the stash relocates.
|
||||
render('responding', 'session-1', false, false, true, '/workspace');
|
||||
// Then the user leaves, so the settle lands with the row stashed.
|
||||
render('responding', 'session-2', false, false, true, '/workspace');
|
||||
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'inserted-once' });
|
||||
await insertion;
|
||||
});
|
||||
render('responding', 'session-1', false, false, true, '/workspace');
|
||||
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: 'insert once',
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: 'inserted-once',
|
||||
isInserting: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one explicit insert in flight across an A-to-B-to-A switch', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
|
||||
(_message, opts) => {
|
||||
opts?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => admission.reject(new DOMException('aborted', 'AbortError')),
|
||||
{ once: true },
|
||||
);
|
||||
return admission.promise;
|
||||
},
|
||||
);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('insert exactly once'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
const signal = vi.mocked(actions.enqueueMidTurnMessage).mock.calls[0]?.[1]
|
||||
?.signal;
|
||||
render('responding', 'session-2', true, false, true);
|
||||
render('responding', 'session-1', true, false, true);
|
||||
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'insert exactly once', isInserting: true },
|
||||
]);
|
||||
await act(async () => latest.insertQueuedPrompt(1));
|
||||
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
render('idle', 'session-1', false, false, true);
|
||||
expect(signal).toBeUndefined();
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'inserted-once' });
|
||||
await insertion;
|
||||
});
|
||||
expect(actions.enqueueMidTurnMessage).toHaveBeenCalledTimes(1);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('submits locally held Goal follow-ups after the Goal stops', () => {
|
||||
const { actions } = createActions();
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('run after goal'));
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
|
||||
expect(actions.submitPrompt).toHaveBeenCalledWith(
|
||||
'run after goal',
|
||||
expect.objectContaining({ optimisticUserMessage: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('releases held Goal follow-ups one at a time, in queue order', async () => {
|
||||
// A prompt carrying media waits for its uploads before its admission POST,
|
||||
// so releasing the whole batch at once lets a later plain prompt overtake
|
||||
// it and land in the daemon's queue first.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'second' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first with media'));
|
||||
act(() => latest.enqueuePrompt('second plain'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
});
|
||||
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media', 'second plain']);
|
||||
});
|
||||
|
||||
it('stops the release chain when the Goal re-activates mid-drain', async () => {
|
||||
// The chain is built synchronously when the hold lifts, but each link runs
|
||||
// only after the previous admission settles. Resuming the Goal inside that
|
||||
// window must stop the remaining links — otherwise the queue keeps draining
|
||||
// into an active Goal after the user changed their mind.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'second' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first'));
|
||||
act(() => latest.enqueuePrompt('second'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first']);
|
||||
|
||||
// Goal resumed while the first admission is still in flight.
|
||||
render('idle', 'session-1', false, false, true);
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
});
|
||||
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first']);
|
||||
// The unsent row goes back to held, so the next inactive transition
|
||||
// re-drains it rather than stranding it as 'submitting'.
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'second', serverState: undefined },
|
||||
]);
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first', 'second']);
|
||||
});
|
||||
|
||||
it('stashes the undrained release chain when the session changes mid-drain', async () => {
|
||||
// The chain marks the whole batch `submitting` up front and then releases
|
||||
// it serially, so a switch inside that window used to orphan every row it
|
||||
// had not reached: the stash only saved `serverState === undefined` rows,
|
||||
// and the remaining links POSTed against the wrong session and were
|
||||
// swallowed by the chain's own `.catch`. Both prompts were gone for good.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'second' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first with media'));
|
||||
act(() => latest.enqueuePrompt('second plain'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
// The user switches away while the first admission is still in flight.
|
||||
render('idle', 'session-2', false, false, false);
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
});
|
||||
|
||||
// The second link must not POST into session-2.
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
// Coming back, the row the chain never reached is in session-1's queue
|
||||
// again and the fresh drain releases it -- to session-1, in order.
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(latest.queuedPrompts).toMatchObject([{ text: 'second plain' }]);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media', 'second plain']);
|
||||
});
|
||||
|
||||
it('holds a prompt typed mid-drain behind the release chain', async () => {
|
||||
// The chain preserves order only inside the batch it drains. A prompt typed
|
||||
// while it is still in flight used to POST immediately -- overtaking the
|
||||
// older rows it was typed after, and, while link 1's uploads were still
|
||||
// running, even starting the turn ahead of link 1 itself.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'later' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first with media'));
|
||||
act(() => latest.enqueuePrompt('second plain'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
// Typed inside the drain window: it must queue behind the chain, not race
|
||||
// it. The row is still stamped `submitting` -- it is spoken for, just not
|
||||
// POSTed yet.
|
||||
act(() => latest.enqueuePrompt('typed during drain'));
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
for (let i = 0; i < 10; i += 1) await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media', 'second plain', 'typed during drain']);
|
||||
});
|
||||
|
||||
it('stashes a chain link that bails before the owner change commits', async () => {
|
||||
// The owner token is replaced in the render body while the stash is a
|
||||
// passive effect flushed after commit. A link firing in that window used
|
||||
// to delete its id from the unreleased set before the owner check, so the
|
||||
// stash -- which saves a stamped row only while its id is still there --
|
||||
// discarded a prompt the chain never POSTed.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'second' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first with media'));
|
||||
act(() => latest.enqueuePrompt('second plain'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
// Token replaced, stash not flushed yet -- link 2 runs inside the window.
|
||||
sdk.ownerVersion += 1;
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
// The commit that follows must still find the row stashable.
|
||||
render('idle', 'session-2', false, false, false);
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(latest.queuedPrompts).toMatchObject([{ text: 'second plain' }]);
|
||||
});
|
||||
|
||||
it('drops the undrained release chain when the queue is cleared mid-drain', async () => {
|
||||
// Before the serial chain, every `submitting` row had its abort controller
|
||||
// created synchronously with the stamp, so clearing the queue aborted it.
|
||||
// The chain defers submission past the stamp, so the links it has not
|
||||
// fired yet are reachable only through the row's absence from the queue.
|
||||
const { actions } = createActions();
|
||||
const firstAdmission = deferred<{ promptId: string }>();
|
||||
vi.mocked(actions.submitPrompt)
|
||||
.mockReturnValueOnce(firstAdmission.promise as never)
|
||||
.mockResolvedValue({ promptId: 'second' } as never);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('first with media'));
|
||||
act(() => latest.enqueuePrompt('second plain'));
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
|
||||
act(() => {
|
||||
latest.clearQueuedPrompts();
|
||||
});
|
||||
await act(async () => {
|
||||
firstAdmission.resolve({ promptId: 'first' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
vi.mocked(actions.submitPrompt).mock.calls.map((call) => call[0]),
|
||||
).toEqual(['first with media']);
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps locally held Goal follow-ups isolated across session switches', () => {
|
||||
const { actions } = createActions();
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('stay with session one'));
|
||||
render('responding', 'session-2', true, false, true);
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
|
||||
act(() => latest.enqueuePrompt('stay with session two'));
|
||||
render('responding', 'session-1', true, false, true);
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'stay with session one' },
|
||||
]);
|
||||
|
||||
render('responding', 'session-2', true, false, true);
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: 'stay with session two' },
|
||||
]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores an unaccepted mid-turn prompt when its owner is replaced', () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(
|
||||
|
|
@ -803,6 +1511,37 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
|
|||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not resend an explicit insert when its echo beats the admission ack', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('explicit early injection'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
sdk.batches = [
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
originatorClientId: 'client-1',
|
||||
messages: ['explicit early injection'],
|
||||
},
|
||||
];
|
||||
render('responding');
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'mid-early' });
|
||||
await insertion;
|
||||
});
|
||||
render('idle', 'session-1', false, false, false);
|
||||
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to one ordinary submission when mid-turn admission fails', async () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
|
||||
|
|
@ -825,6 +1564,70 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('holds an idle admission rejection while a Goal is active', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render } = mount('responding', actions);
|
||||
|
||||
act(() => latest.enqueuePrompt('wait for the Goal'));
|
||||
render('idle', 'session-1', false, false, true);
|
||||
await act(async () => admission.resolve({ accepted: false }));
|
||||
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([{ text: 'wait for the Goal' }]);
|
||||
expect(latest.queuedPrompts[0]).not.toHaveProperty('serverState');
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not resubmit a legacy explicit insert accepted after idle', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean; messageId?: string }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
const { render } = mount('responding', actions, true, false, false, true);
|
||||
|
||||
act(() => latest.enqueuePrompt('do not lose me'));
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = latest.insertQueuedPrompt(1);
|
||||
});
|
||||
render('idle', 'session-1', false, false, true);
|
||||
await act(async () => {
|
||||
admission.resolve({ accepted: true, messageId: 'legacy-accepted' });
|
||||
await insertion;
|
||||
});
|
||||
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
|
||||
render('idle', 'session-1', false, false, false);
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not hold a legacy insert accepted before Goal hold', async () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
|
||||
accepted: true,
|
||||
messageId: 'accepted-before-idle',
|
||||
});
|
||||
const { render } = mount('responding', actions);
|
||||
|
||||
act(() => latest.enqueuePrompt('already accepted'));
|
||||
await act(async () => {});
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ midTurnMessageId: 'accepted-before-idle', midTurnState: 'queued' },
|
||||
]);
|
||||
|
||||
render('idle', 'session-1', false, false, true);
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
render('idle', 'session-1', false, false, false);
|
||||
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('freezes mid-turn fallback while a session switch is preparing', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean }>();
|
||||
|
|
@ -838,14 +1641,14 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
|
|||
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: '留在当前会话', midTurnState: 'submitting' },
|
||||
{ text: '留在当前会话', midTurnState: undefined },
|
||||
]);
|
||||
|
||||
render('idle', 'session-1', false, false);
|
||||
expect(actions.submitPrompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('falls back once when the running turn ends before injection', async () => {
|
||||
it('does not resubmit an accepted legacy message when the running turn ends', async () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockResolvedValue({
|
||||
accepted: true,
|
||||
|
|
@ -860,34 +1663,52 @@ describe('useQueuedPrompts default mid-turn insertion', () => {
|
|||
render('idle');
|
||||
await act(async () => {});
|
||||
|
||||
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{ text: '继续处理', serverState: 'submitting' },
|
||||
]);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a late admission result after idle fallback claimed the prompt', async () => {
|
||||
it('does not resubmit a late accepted legacy admission at idle', async () => {
|
||||
const { actions } = createActions();
|
||||
const admission = deferred<{ accepted: boolean }>();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockReturnValue(admission.promise);
|
||||
let signal: AbortSignal | undefined;
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockImplementation(
|
||||
(_message, options) => {
|
||||
signal = options?.signal;
|
||||
return admission.promise;
|
||||
},
|
||||
);
|
||||
const { render } = mount('responding', actions);
|
||||
|
||||
act(() => {
|
||||
latest.enqueuePrompt('不要重复');
|
||||
});
|
||||
render('idle');
|
||||
render('responding');
|
||||
expect(signal?.aborted).toBe(false);
|
||||
await act(async () =>
|
||||
admission.resolve({ accepted: true, messageId: 'mid-late' }),
|
||||
);
|
||||
|
||||
expect(actions.submitPrompt).toHaveBeenCalledTimes(1);
|
||||
expect(actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(latest.queuedPrompts).toEqual([]);
|
||||
});
|
||||
|
||||
it('resubmits a legacy admission after a transport failure', async () => {
|
||||
const { actions } = createActions();
|
||||
vi.mocked(actions.enqueueMidTurnMessage).mockRejectedValue(
|
||||
new Error('connection lost'),
|
||||
);
|
||||
mount('responding', actions);
|
||||
|
||||
act(() => latest.enqueuePrompt('do not lose me'));
|
||||
await act(async () => {});
|
||||
|
||||
expect(actions.submitPrompt).toHaveBeenCalledWith(
|
||||
'do not lose me',
|
||||
expect.objectContaining({ sessionId: 'session-1' }),
|
||||
);
|
||||
expect(actions.submitPrompt).toHaveBeenCalledOnce();
|
||||
expect(latest.queuedPrompts).toMatchObject([
|
||||
{
|
||||
text: '不要重复',
|
||||
serverState: 'submitting',
|
||||
midTurnState: undefined,
|
||||
},
|
||||
{ text: 'do not lose me', serverState: 'submitting' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ interface HarnessOptions {
|
|||
canQueryMidTurn?: boolean;
|
||||
canInjectMidTurnMedia?: boolean;
|
||||
streamingState?: DaemonStreamingState;
|
||||
holdQueuedPromptsLocally?: boolean;
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
|
|
@ -154,6 +155,7 @@ function createHarness() {
|
|||
canInjectMidTurnMedia: opts.canInjectMidTurnMedia ?? true,
|
||||
workspaceFileActions: stableWorkspaceFileActions as never,
|
||||
streamingState: opts.streamingState ?? 'responding',
|
||||
holdQueuedPromptsLocally: opts.holdQueuedPromptsLocally ?? false,
|
||||
sessionActions: sdkMock.actions as never,
|
||||
store: stableStore as never,
|
||||
editorRef: stableEditorRef,
|
||||
|
|
@ -487,7 +489,7 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
}
|
||||
});
|
||||
|
||||
it('never queries when the daemon lacks the capability (degraded)', async () => {
|
||||
it('does not resubmit an accepted message without query capability', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
|
|
@ -501,18 +503,15 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
streamingState: 'idle',
|
||||
canQueryMidTurn: false,
|
||||
});
|
||||
// Legacy path: resend directly, no reconciliation round-trip.
|
||||
expect(sdkMock.actions.getMidTurnMessages).not.toHaveBeenCalled();
|
||||
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith(
|
||||
'note',
|
||||
expect.objectContaining({ sessionId: 'session-a' }),
|
||||
);
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back when a legacy admission response reaches an idle turn', async () => {
|
||||
it('does not resubmit when a legacy admission is accepted at idle', async () => {
|
||||
let resolveAdmission:
|
||||
| ((value: { accepted: boolean; messageId?: string }) => void)
|
||||
| undefined;
|
||||
|
|
@ -538,10 +537,40 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
resolveAdmission?.({ accepted: true, messageId: 'legacy-late' });
|
||||
});
|
||||
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back when a query admission is rejected after the turn settles', async () => {
|
||||
let resolveAdmission:
|
||||
| ((value: { accepted: boolean; messageId?: string }) => void)
|
||||
| undefined;
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
|
||||
(_message: string, opts?: { onAdmissionStarted?: () => void }) =>
|
||||
new Promise((resolve) => {
|
||||
opts?.onAdmissionStarted?.();
|
||||
resolveAdmission = resolve;
|
||||
}),
|
||||
);
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({ streamingState: 'responding' });
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('query late response');
|
||||
});
|
||||
await harness.render({ streamingState: 'idle' });
|
||||
await act(async () => {
|
||||
resolveAdmission?.({ accepted: false });
|
||||
});
|
||||
|
||||
expect(sdkMock.actions.submitPrompt).toHaveBeenCalledWith(
|
||||
'legacy late response',
|
||||
'query late response',
|
||||
expect.objectContaining({ sessionId: 'session-a' }),
|
||||
);
|
||||
expect(harness.reportError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
|
|
@ -1053,6 +1082,269 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
}
|
||||
});
|
||||
|
||||
it('does not explicitly insert a locally held Goal prompt while idle', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('insert into active Goal');
|
||||
});
|
||||
|
||||
const queuedPromptId = harness.result().queuedPrompts[0]?.id;
|
||||
await act(async () => {
|
||||
await harness.result().insertQueuedPrompt(queuedPromptId!);
|
||||
});
|
||||
|
||||
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconciles a committed explicit insert after its response is lost', async () => {
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockRejectedValueOnce(
|
||||
new Error('response lost'),
|
||||
);
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('explicitly inserted');
|
||||
});
|
||||
await harness.render({
|
||||
streamingState: 'responding',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
const queuedPromptId = harness.result().queuedPrompts[0]?.id;
|
||||
expect(queuedPromptId).toEqual(expect.any(Number));
|
||||
expect(sdkMock.actions.enqueueMidTurnMessage).not.toHaveBeenCalled();
|
||||
sdkMock.actions.getMidTurnMessages.mockImplementation(async () => {
|
||||
const messageId =
|
||||
sdkMock.actions.enqueueMidTurnMessage.mock.calls[0]?.[1]?.messageId;
|
||||
return {
|
||||
messages: [{ messageId, text: 'explicitly inserted' }],
|
||||
settledMessageIds: [],
|
||||
promotedMessageIds: [],
|
||||
};
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result().insertQueuedPrompt(queuedPromptId!);
|
||||
});
|
||||
|
||||
const messageId =
|
||||
sdkMock.actions.enqueueMidTurnMessage.mock.calls[0]?.[1]?.messageId;
|
||||
expect(messageId).toEqual(expect.any(String));
|
||||
expect(harness.result().queuedPrompts).toEqual([
|
||||
expect.objectContaining({
|
||||
text: 'explicitly inserted',
|
||||
midTurnMessageId: messageId,
|
||||
midTurnState: 'queued',
|
||||
}),
|
||||
]);
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(harness.reportError).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns an unreconciled explicit insert to the local hold', async () => {
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockRejectedValueOnce(
|
||||
new Error('response lost'),
|
||||
);
|
||||
sdkMock.actions.getMidTurnMessages.mockResolvedValue(undefined);
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('do not lose me');
|
||||
});
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
streamingState: 'responding',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
await harness.result().insertQueuedPrompt(1);
|
||||
});
|
||||
// The daemon could not confirm the insert, so the row goes back to the
|
||||
// local Goal hold instead of lingering as a half-owned mid-turn row.
|
||||
expect(harness.result().queuedPrompts).toEqual([
|
||||
expect.objectContaining({
|
||||
text: 'do not lose me',
|
||||
isInserting: false,
|
||||
}),
|
||||
]);
|
||||
expect(harness.result().queuedPrompts[0]?.midTurnState).toBeUndefined();
|
||||
expect(
|
||||
harness.result().queuedPrompts[0]?.midTurnMessageId,
|
||||
).toBeUndefined();
|
||||
expect(harness.reportError).toHaveBeenCalled();
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-b',
|
||||
streamingState: 'responding',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
streamingState: 'responding',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
|
||||
expect(harness.result().queuedPrompts).toEqual([
|
||||
expect.objectContaining({ text: 'do not lose me' }),
|
||||
]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains held prompts when a session learns its workspace while away', async () => {
|
||||
// The foreground variant below only covers a cwd that resolves while the
|
||||
// session is displayed. Resolving it while the user is on another session
|
||||
// leaves the stash under the old key, which nothing looks up again — the
|
||||
// typed text is gone for good, reload included.
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: undefined,
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('typed while away');
|
||||
});
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-b',
|
||||
workspaceCwd: '/workspace-b',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-a',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
|
||||
expect(harness.result().queuedPrompts).toEqual([
|
||||
expect.objectContaining({ text: 'typed while away' }),
|
||||
]);
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('hands a held prompt to the new owner key exactly once', async () => {
|
||||
// The relocation has to release the old key: if both keys keep the same
|
||||
// array, a later transition through the stale key re-transfers prompts that
|
||||
// were already handed off and the queue shows them twice.
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: undefined,
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('exactly once');
|
||||
});
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-b',
|
||||
workspaceCwd: '/workspace-b',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-a',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
expect(harness.result().queuedPrompts).toHaveLength(1);
|
||||
|
||||
// Stop the Goal: the held prompt drains through the ordinary path.
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-a',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: false,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().removeQueuedPrompt(1);
|
||||
});
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-b',
|
||||
workspaceCwd: '/workspace-b',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-a',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
|
||||
// The stash it came from must have been released, or the prompt the user
|
||||
// already dealt with comes back from the stale key.
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('retains held prompts when the same session learns a new workspace', async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-before',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('typed never-sent text');
|
||||
});
|
||||
|
||||
await harness.render({
|
||||
sessionId: 'session-a',
|
||||
workspaceCwd: '/workspace-after',
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
|
||||
expect(harness.result().queuedPrompts).toEqual([
|
||||
expect.objectContaining({ text: 'typed never-sent text' }),
|
||||
]);
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not restore an in-flight admission across owner replacement', async () => {
|
||||
let resolveAdmission:
|
||||
| ((value: { accepted: boolean; messageId?: string }) => void)
|
||||
|
|
@ -1600,6 +1892,57 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
}
|
||||
});
|
||||
|
||||
it('does not resubmit a query-capable insert accepted at turn settle', async () => {
|
||||
let resolveAdmission:
|
||||
| ((result: { accepted: boolean; messageId?: string }) => void)
|
||||
| undefined;
|
||||
let admissionSignal: AbortSignal | undefined;
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
|
||||
(_message: string, opts?: { messageId?: string; signal?: AbortSignal }) =>
|
||||
new Promise((resolve) => {
|
||||
resolveAdmission = resolve;
|
||||
admissionSignal = opts?.signal;
|
||||
}),
|
||||
);
|
||||
const harness = createHarness();
|
||||
try {
|
||||
await harness.render({
|
||||
streamingState: 'responding',
|
||||
holdQueuedPromptsLocally: true,
|
||||
});
|
||||
await act(async () => {
|
||||
harness.result().enqueuePrompt('query settle');
|
||||
});
|
||||
let insertion!: Promise<void>;
|
||||
act(() => {
|
||||
insertion = harness.result().insertQueuedPrompt(1);
|
||||
});
|
||||
await harness.render({
|
||||
streamingState: 'idle',
|
||||
holdQueuedPromptsLocally: false,
|
||||
});
|
||||
const messageId =
|
||||
sdkMock.actions.enqueueMidTurnMessage.mock.calls[0]?.[1]?.messageId;
|
||||
sdkMock.actions.getMidTurnMessages.mockResolvedValue({
|
||||
messages: [],
|
||||
settledMessageIds: [],
|
||||
promotedMessageIds: [messageId!],
|
||||
});
|
||||
await act(async () => {
|
||||
resolveAdmission?.({ accepted: true, messageId });
|
||||
await insertion;
|
||||
});
|
||||
|
||||
// An explicit insert is issued without an abort signal by design.
|
||||
expect(admissionSignal).toBeUndefined();
|
||||
expect(harness.reportError).not.toHaveBeenCalled();
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('settles a callback from the settled ring exactly once', async () => {
|
||||
const onComplete = vi.fn();
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
|
||||
|
|
@ -1836,15 +2179,23 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
}
|
||||
});
|
||||
|
||||
it('aborts a pending legacy enqueue at the idle transition', async () => {
|
||||
it('settles after a pending legacy enqueue is accepted at idle', async () => {
|
||||
let admissionSignal: AbortSignal | undefined;
|
||||
let resolveAdmission:
|
||||
| ((value: { accepted: boolean; messageId?: string }) => void)
|
||||
| undefined;
|
||||
const admission = new Promise<{ accepted: boolean; messageId?: string }>(
|
||||
(resolve) => {
|
||||
resolveAdmission = resolve;
|
||||
},
|
||||
);
|
||||
sdkMock.actions.enqueueMidTurnMessage.mockImplementation(
|
||||
(
|
||||
_message: string,
|
||||
opts?: { signal?: AbortSignal; messageId?: string },
|
||||
) => {
|
||||
admissionSignal = opts?.signal;
|
||||
return new Promise(() => {});
|
||||
return admission;
|
||||
},
|
||||
);
|
||||
const harness = createHarness();
|
||||
|
|
@ -1860,8 +2211,14 @@ describe('useQueuedPrompts mid-turn reconciliation (session_mid_turn_message_que
|
|||
expect(admissionSignal?.aborted).toBe(false);
|
||||
|
||||
await harness.render({ streamingState: 'idle', canQueryMidTurn: false });
|
||||
// Without the abort the in-flight admission would land in the next turn.
|
||||
expect(admissionSignal?.aborted).toBe(true);
|
||||
expect(admissionSignal?.aborted).toBe(false);
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolveAdmission?.({ accepted: true, messageId: 'mid-late' });
|
||||
});
|
||||
expect(sdkMock.actions.submitPrompt).not.toHaveBeenCalled();
|
||||
expect(harness.result().queuedPrompts).toEqual([]);
|
||||
} finally {
|
||||
await harness.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ interface UseQueuedPromptsArgs {
|
|||
canInjectMidTurnMedia: boolean;
|
||||
workspaceFileActions?: Pick<DaemonWorkspaceActions, 'readFileBytes' | 'stat'>;
|
||||
streamingState: DaemonStreamingState;
|
||||
/** Keep ordinary submissions local until the Goal is paused, cleared, or the
|
||||
* user explicitly inserts one into the current turn. */
|
||||
holdQueuedPromptsLocally?: boolean;
|
||||
sessionActions: DaemonSessionActions;
|
||||
store: DaemonTranscriptStore;
|
||||
editorRef: RefBox<EditorHandle | null>;
|
||||
|
|
@ -87,6 +90,68 @@ interface UseQueuedPromptsArgs {
|
|||
|
||||
const MAX_COMPLETED_PROMPT_IDS = 100;
|
||||
|
||||
function queueOwnerKey(
|
||||
workspaceCwd: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
return sessionId ? `${workspaceCwd ?? ''}\u0000${sessionId}` : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the stash key holding `sessionId`'s prompts as of NOW.
|
||||
*
|
||||
* The workspace half of an owner key can resolve — or change — at any time, and
|
||||
* the owner-change effect relocates the whole stash onto the new key and
|
||||
* deletes the old one. A key captured when an insert started can therefore be
|
||||
* gone by the time that insert settles; writing through it would silently drop
|
||||
* the update. Session ids are unique (the same invariant the relocation itself
|
||||
* relies on), so any stash whose session half matches belongs to this session.
|
||||
*/
|
||||
function resolveStashKey(
|
||||
stash: ReadonlyMap<string, QueuedPrompt[]>,
|
||||
capturedKey: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
if (capturedKey !== undefined && stash.has(capturedKey)) return capturedKey;
|
||||
if (!sessionId) return capturedKey;
|
||||
const suffix = `\u0000${sessionId}`;
|
||||
for (const key of stash.keys()) {
|
||||
if (key.endsWith(suffix)) return key;
|
||||
}
|
||||
return capturedKey;
|
||||
}
|
||||
|
||||
function isLocallyHeldPrompt(prompt: QueuedPrompt): boolean {
|
||||
return (
|
||||
prompt.serverPromptId === undefined &&
|
||||
prompt.serverState === undefined &&
|
||||
prompt.midTurnState === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a finished release chain from `ref` so later sends stop queueing behind
|
||||
* it. A prompt typed while the chain is draining appends itself to the tail,
|
||||
* so "the tail I awaited" and "the tail the chain has now" can differ: when
|
||||
* they do, re-arm on the newer tail instead of retiring a chain that still has
|
||||
* links to run.
|
||||
*/
|
||||
function retireChainWhenDrained<T extends { tail: Promise<void> }>(
|
||||
ref: { current: T | null },
|
||||
chain: T,
|
||||
): void {
|
||||
const tail = chain.tail;
|
||||
const settle = () => {
|
||||
if (ref.current !== chain) return;
|
||||
if (chain.tail !== tail) {
|
||||
retireChainWhenDrained(ref, chain);
|
||||
return;
|
||||
}
|
||||
ref.current = null;
|
||||
};
|
||||
void tail.then(settle, settle);
|
||||
}
|
||||
|
||||
interface AnnotatedFiles {
|
||||
displayText: string;
|
||||
paths: string[];
|
||||
|
|
@ -186,6 +251,7 @@ function areQueuedPromptsEqual(
|
|||
prompt.midTurnState === other.midTurnState &&
|
||||
prompt.midTurnMessageId === other.midTurnMessageId &&
|
||||
prompt.midTurnFailedAction === other.midTurnFailedAction &&
|
||||
prompt.isInserting === other.isInserting &&
|
||||
prompt.isEditing === other.isEditing &&
|
||||
prompt.isRemoving === other.isRemoving &&
|
||||
prompt.payloadCompleteness === other.payloadCompleteness &&
|
||||
|
|
@ -320,6 +386,7 @@ export interface UseQueuedPromptsResult {
|
|||
onAdmitted?: () => void,
|
||||
) => boolean;
|
||||
removeQueuedPrompt: (id: number) => void;
|
||||
insertQueuedPrompt: (id: number) => Promise<void>;
|
||||
editQueuedPrompt: (id: number) => Promise<void>;
|
||||
editLastQueuedPrompt: () => boolean;
|
||||
clearQueuedPrompts: () => boolean;
|
||||
|
|
@ -336,6 +403,7 @@ export function useQueuedPrompts({
|
|||
canInjectMidTurnMedia,
|
||||
workspaceFileActions,
|
||||
streamingState,
|
||||
holdQueuedPromptsLocally = false,
|
||||
sessionActions,
|
||||
store,
|
||||
editorRef,
|
||||
|
|
@ -369,10 +437,30 @@ export function useQueuedPrompts({
|
|||
ownerTokenRef.current === token && token.snapshot.isCurrent(),
|
||||
);
|
||||
const queuedPromptsOwnerRef = useRef(ownerToken);
|
||||
/**
|
||||
* Prompts the serial release chain has stamped `submitting` but not yet
|
||||
* handed to `submitPendingPrompt`. Each link drops its own id as it fires,
|
||||
* and the owner-change effect empties the set after reading it, so at any
|
||||
* owner change it holds exactly the rows the chain never got to POST.
|
||||
*/
|
||||
const unreleasedPromptIdsRef = useRef<Set<number>>(new Set());
|
||||
/**
|
||||
* The live serial release chain, if a drain is in flight. Published so that
|
||||
* `enqueuePrompt` can append to its tail: the chain exists to keep a prompt
|
||||
* carrying media from being overtaken, and a prompt typed inside that window
|
||||
* was typed AFTER the rows still waiting on it, so POSTing it immediately
|
||||
* would land it ahead of them.
|
||||
*/
|
||||
const releaseChainRef = useRef<{
|
||||
owner: typeof ownerToken;
|
||||
tail: Promise<void>;
|
||||
} | null>(null);
|
||||
const heldPromptsByOwnerRef = useRef<Map<string, QueuedPrompt[]>>(new Map());
|
||||
const nextQueuedPromptIdRef = useRef(1);
|
||||
const latestSessionIdRef = useRef(sessionId);
|
||||
const latestWorkspaceCwdRef = useRef(workspaceCwd);
|
||||
const midTurnEnqueueAbortRef = useRef<AbortController | null>(null);
|
||||
const explicitInsertGenerationsRef = useRef<Map<number, number>>(new Map());
|
||||
const submitAbortControllersRef = useRef<Set<AbortController>>(new Set());
|
||||
const removingServerPromptIdsRef = useRef<Set<string>>(new Set());
|
||||
const displayedServerPromptIdsRef = useRef<Set<string>>(new Set());
|
||||
|
|
@ -385,6 +473,7 @@ export function useQueuedPrompts({
|
|||
const appendedBeforeResponsePromptIdsRef = useRef<Set<string>>(new Set());
|
||||
const removedBeforeResponsePromptIdsRef = useRef<Set<string>>(new Set());
|
||||
const latestStreamingStateRef = useRef(streamingState);
|
||||
const holdQueuedPromptsLocallyRef = useRef(holdQueuedPromptsLocally);
|
||||
const refreshRequestSeqRef = useRef(0);
|
||||
/** Stale-response fence for `getMidTurnMessages` reconciliation calls. */
|
||||
const midTurnReconcileSeqRef = useRef(0);
|
||||
|
|
@ -406,6 +495,7 @@ export function useQueuedPrompts({
|
|||
|
||||
latestSessionIdRef.current = sessionId;
|
||||
latestWorkspaceCwdRef.current = workspaceCwd;
|
||||
holdQueuedPromptsLocallyRef.current = holdQueuedPromptsLocally;
|
||||
const streamingIdle = streamingState === 'idle';
|
||||
useLayoutEffect(() => {
|
||||
midTurnReconcileSeqRef.current += 1;
|
||||
|
|
@ -907,6 +997,41 @@ export function useQueuedPrompts({
|
|||
|
||||
useEffect(() => {
|
||||
restoredPromptIdsRef.current = new Set();
|
||||
const previousOwner = queuedPromptsOwnerRef.current;
|
||||
const previousOwnerKey = queueOwnerKey(
|
||||
previousOwner.workspaceCwd,
|
||||
previousOwner.sessionId,
|
||||
);
|
||||
if (previousOwnerKey) {
|
||||
const heldPrompts = queuedPromptsRef.current
|
||||
.filter(
|
||||
(prompt) =>
|
||||
(isLocallyHeldPrompt(prompt) ||
|
||||
// Rows the drain stamped `submitting` up front but never got to
|
||||
// POST. Nothing exists for them on the daemon, so unlike a real
|
||||
// in-flight admission (deliberately fenced and dropped here)
|
||||
// they can be stashed with no risk of a duplicate — and they
|
||||
// must be, or a mid-drain session switch loses the text.
|
||||
unreleasedPromptIdsRef.current.has(prompt.id)) &&
|
||||
(!prompt.midTurnMessageId ||
|
||||
!pendingMidTurnAdmissionsRef.current.has(
|
||||
prompt.midTurnMessageId,
|
||||
)),
|
||||
)
|
||||
// Drop the optimistic stamp on the way in: the row has to come back as
|
||||
// a plain held prompt, because `isLocallyHeldPrompt` is what both the
|
||||
// next drain and the next owner change look for.
|
||||
.map((prompt) =>
|
||||
prompt.serverState === undefined
|
||||
? prompt
|
||||
: { ...prompt, serverState: undefined },
|
||||
);
|
||||
if (heldPrompts.length > 0) {
|
||||
heldPromptsByOwnerRef.current.set(previousOwnerKey, heldPrompts);
|
||||
} else {
|
||||
heldPromptsByOwnerRef.current.delete(previousOwnerKey);
|
||||
}
|
||||
}
|
||||
const retainedAdmissions = [
|
||||
...pendingMidTurnAdmissionsRef.current.entries(),
|
||||
].filter(
|
||||
|
|
@ -932,8 +1057,37 @@ export function useQueuedPrompts({
|
|||
restoreQueuedPromptsToEditorRef.current(interruptedPrompts);
|
||||
}
|
||||
queuedPromptsOwnerRef.current = ownerToken;
|
||||
queuedPromptsRef.current = [];
|
||||
setQueuedPrompts([]);
|
||||
const nextOwnerKey = queueOwnerKey(workspaceCwd, sessionId);
|
||||
let heldPrompts = nextOwnerKey
|
||||
? (heldPromptsByOwnerRef.current.get(nextOwnerKey) ?? [])
|
||||
: [];
|
||||
if (nextOwnerKey && sessionId) {
|
||||
// The workspace half of the key can resolve at any time — including
|
||||
// while the user is on a different session — so a stash written under an
|
||||
// unresolved (or since-changed) cwd would be orphaned under a key nobody
|
||||
// looks up again, silently losing the text. Session ids are unique, so
|
||||
// any stash whose session half matches belongs to this owner: relocate
|
||||
// them all and restore in queue order.
|
||||
const suffix = `\u0000${sessionId}`;
|
||||
const relocated: QueuedPrompt[] = [];
|
||||
for (const [key, prompts] of [...heldPromptsByOwnerRef.current]) {
|
||||
if (key === nextOwnerKey || !key.endsWith(suffix)) continue;
|
||||
heldPromptsByOwnerRef.current.delete(key);
|
||||
relocated.push(...prompts);
|
||||
}
|
||||
if (relocated.length > 0) {
|
||||
const seen = new Set(heldPrompts.map((prompt) => prompt.id));
|
||||
heldPrompts = [
|
||||
...heldPrompts,
|
||||
...relocated.filter((prompt) => !seen.has(prompt.id)),
|
||||
].sort((a, b) => a.id - b.id);
|
||||
heldPromptsByOwnerRef.current.set(nextOwnerKey, heldPrompts);
|
||||
}
|
||||
}
|
||||
// Daemon-owned rows are re-rendered from the next queue snapshot; only the
|
||||
// locally held Goal queue survives an owner change.
|
||||
queuedPromptsRef.current = heldPrompts;
|
||||
setQueuedPrompts(heldPrompts);
|
||||
completionCallbacksRef.current = retainedCompletionCallbacks;
|
||||
completedPromptIdsRef.current = new Set();
|
||||
completedPromptIdOrderRef.current = [];
|
||||
|
|
@ -943,6 +1097,8 @@ export function useQueuedPrompts({
|
|||
controller.abort();
|
||||
}
|
||||
submitAbortControllersRef.current.clear();
|
||||
unreleasedPromptIdsRef.current = new Set();
|
||||
releaseChainRef.current = null;
|
||||
removingServerPromptIdsRef.current = new Set();
|
||||
displayedServerPromptIdsRef.current = new Set();
|
||||
pendingStartedByPromptIdRef.current = new Map();
|
||||
|
|
@ -1140,15 +1296,20 @@ export function useQueuedPrompts({
|
|||
rememberCompletedPromptId,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Submit one pending prompt. Returns the admission promise (already
|
||||
* error-handled) so callers releasing several prompts can chain them and
|
||||
* keep the daemon's queue in the order the user typed them.
|
||||
*/
|
||||
const submitPendingPrompt = useCallback(
|
||||
(prompt: QueuedPrompt) => {
|
||||
(prompt: QueuedPrompt): Promise<void> => {
|
||||
const { id: localId, sessionId: targetSessionId } = prompt;
|
||||
const ownerToken = ownerTokenRef.current;
|
||||
const submitAbort = new AbortController();
|
||||
submitAbortControllersRef.current.add(submitAbort);
|
||||
let admissionStarted = false;
|
||||
|
||||
sessionActions
|
||||
return sessionActions
|
||||
.submitPrompt(prompt.text, {
|
||||
images: prompt.images,
|
||||
files: prompt.files,
|
||||
|
|
@ -1310,9 +1471,62 @@ export function useQueuedPrompts({
|
|||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One link of the serial release chain: hand the daemon a prompt the drain
|
||||
* has already stamped `submitting`, but only while it is still ours to send.
|
||||
* Shared with `enqueuePrompt`, which appends to a live chain rather than
|
||||
* POSTing past it, so both paths carry the same guards.
|
||||
*/
|
||||
const releaseChainedPrompt = useCallback(
|
||||
(prompt: QueuedPrompt, chainOwner: typeof ownerToken): Promise<void> => {
|
||||
// Owner changed mid-drain: `submitPrompt` would throw on the session
|
||||
// mismatch before POSTing and the `.catch` below would swallow it,
|
||||
// dropping the prompt silently. Bail and leave the rows alone — the
|
||||
// owner-change effect has already stashed them for the session they
|
||||
// were typed in, and touching state here would fight it.
|
||||
//
|
||||
// The id must stay in `unreleasedPromptIdsRef` on this path. The
|
||||
// token is replaced in the render body while the stash is a passive
|
||||
// effect flushed after commit, so a link firing in that window would
|
||||
// otherwise leave a row that is neither locally held (it is stamped
|
||||
// `submitting`) nor unreleased — and the stash drops exactly those.
|
||||
if (!isCurrentOwnerTokenRef.current(chainOwner)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
unreleasedPromptIdsRef.current.delete(prompt.id);
|
||||
// Every path that removes a stamped row means cancellation: a queue
|
||||
// clear mid-drain aborts the in-flight link's controller, but the
|
||||
// links still pending have no controller yet, so only the row's
|
||||
// absence tells them the user cleared what they were about to POST.
|
||||
if (!queuedPromptsRef.current.some((item) => item.id === prompt.id)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
// Re-check the hold per link, not once for the whole batch: the chain
|
||||
// is built synchronously when the hold lifts, but each link runs only
|
||||
// after the previous admission settles. A Goal resumed inside that
|
||||
// window (or a write block) must stop the remaining links instead of
|
||||
// POSTing them against an active Goal — they return to held, and the
|
||||
// next inactive transition re-drains them in order.
|
||||
if (holdQueuedPromptsLocallyRef.current || writeBlockedRef.current) {
|
||||
// Inline rather than `setQueuedPromptFlags`: that callback is
|
||||
// declared below, so naming it here would read it before its
|
||||
// initializer.
|
||||
const reverted = queuedPromptsRef.current.map((item) =>
|
||||
item.id === prompt.id ? { ...item, serverState: undefined } : item,
|
||||
);
|
||||
queuedPromptsRef.current = reverted;
|
||||
setQueuedPrompts(reverted);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return submitPendingPrompt(prompt).catch(() => undefined);
|
||||
},
|
||||
[submitPendingPrompt],
|
||||
);
|
||||
|
||||
const fallbackToPendingPrompt = useCallback(
|
||||
(id: number) => {
|
||||
if (writeBlockedRef.current) return;
|
||||
const deferSubmission =
|
||||
writeBlockedRef.current || holdQueuedPromptsLocallyRef.current;
|
||||
const current = queuedPromptsRef.current;
|
||||
const index = current.findIndex(
|
||||
(prompt) => prompt.id === id && prompt.midTurnState !== undefined,
|
||||
|
|
@ -1323,7 +1537,7 @@ export function useQueuedPrompts({
|
|||
midTurnState: undefined,
|
||||
midTurnMessageId: undefined,
|
||||
midTurnFailedAction: undefined,
|
||||
serverState: 'submitting',
|
||||
...(deferSubmission ? {} : { serverState: 'submitting' as const }),
|
||||
isEditing: false,
|
||||
isRemoving: false,
|
||||
};
|
||||
|
|
@ -1331,7 +1545,7 @@ export function useQueuedPrompts({
|
|||
next[index] = prompt;
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
submitPendingPrompt(prompt);
|
||||
if (!deferSubmission) submitPendingPrompt(prompt);
|
||||
},
|
||||
[submitPendingPrompt],
|
||||
);
|
||||
|
|
@ -1376,6 +1590,7 @@ export function useQueuedPrompts({
|
|||
canInjectMidTurnMedia &&
|
||||
workspaceFileActions !== undefined;
|
||||
const shouldInsertMidTurn =
|
||||
!holdQueuedPromptsLocallyRef.current &&
|
||||
latestStreamingStateRef.current !== 'idle' &&
|
||||
(imageList.length === 0 || canSendMidTurnMedia) &&
|
||||
(fileList.length === 0 || canSendMidTurnFiles) &&
|
||||
|
|
@ -1575,6 +1790,32 @@ export function useQueuedPrompts({
|
|||
const next = queuedPromptsRef.current.filter(
|
||||
(prompt) => prompt.midTurnMessageId !== midTurnMessageId,
|
||||
);
|
||||
// The daemon rejected the insert outright, so nothing of it is
|
||||
// queued server-side. If the turn has meanwhile ended, send the
|
||||
// message through the ordinary path (or hold it while a Goal
|
||||
// runs) instead of dropping it.
|
||||
if (
|
||||
targetIsCurrent() &&
|
||||
latestStreamingStateRef.current === 'idle'
|
||||
) {
|
||||
const shouldHold =
|
||||
holdQueuedPromptsLocallyRef.current ||
|
||||
writeBlockedRef.current;
|
||||
const prompt: QueuedPrompt = {
|
||||
...pendingAdmission,
|
||||
midTurnState: undefined,
|
||||
midTurnMessageId: undefined,
|
||||
...(shouldHold ? {} : { serverState: 'submitting' as const }),
|
||||
onComplete,
|
||||
onAdmitted,
|
||||
};
|
||||
const requeued = [...next, prompt];
|
||||
queuedPromptsRef.current = requeued;
|
||||
setQueuedPrompts(requeued);
|
||||
if (shouldHold) return;
|
||||
submitPendingPrompt(prompt);
|
||||
return;
|
||||
}
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
if (!targetIsCurrent()) return;
|
||||
|
|
@ -1681,16 +1922,36 @@ export function useQueuedPrompts({
|
|||
onComplete,
|
||||
onAdmitted,
|
||||
payloadCompleteness: 'complete',
|
||||
...(shouldInsertMidTurn
|
||||
? {
|
||||
midTurnState: 'submitting',
|
||||
}
|
||||
: { serverState: 'submitting' }),
|
||||
...(holdQueuedPromptsLocallyRef.current
|
||||
? {}
|
||||
: shouldInsertMidTurn
|
||||
? {
|
||||
midTurnState: 'submitting',
|
||||
}
|
||||
: { serverState: 'submitting' }),
|
||||
};
|
||||
queuedPromptsRef.current = [...queuedPromptsRef.current, prompt];
|
||||
setQueuedPrompts(queuedPromptsRef.current);
|
||||
|
||||
if (holdQueuedPromptsLocallyRef.current) return true;
|
||||
|
||||
if (!shouldInsertMidTurn) {
|
||||
// A drain is still releasing older held prompts: append to its tail
|
||||
// rather than POSTing past it. The chain exists because the prompt at
|
||||
// its head may await media uploads for seconds; this prompt was typed
|
||||
// inside that window — i.e. AFTER the rows still waiting — so sending
|
||||
// it now would admit it ahead of them.
|
||||
const chain = releaseChainRef.current;
|
||||
if (chain && isCurrentOwnerTokenRef.current(chain.owner)) {
|
||||
// Stamped `submitting` above but not yet POSTed: the same state the
|
||||
// chain's own undrained rows are in, so record it as unreleased and
|
||||
// an owner change stashes the text instead of losing it.
|
||||
unreleasedPromptIdsRef.current.add(prompt.id);
|
||||
chain.tail = chain.tail.then(() =>
|
||||
releaseChainedPrompt(prompt, chain.owner),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
submitPendingPrompt(prompt);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1708,11 +1969,24 @@ export function useQueuedPrompts({
|
|||
if (index === -1) return;
|
||||
if (current[index]?.midTurnState === undefined) return;
|
||||
if (latestSessionIdRef.current !== targetSessionId) return;
|
||||
if (!result.accepted || latestStreamingStateRef.current === 'idle') {
|
||||
if (!result.accepted) {
|
||||
fallbackToPendingPrompt(prompt.id);
|
||||
return;
|
||||
}
|
||||
if (latestStreamingStateRef.current === 'idle') {
|
||||
const next = current.filter((item) => item.id !== prompt.id);
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
prompt.onAdmitted?.();
|
||||
if (prompt.onComplete && result.messageId) {
|
||||
settleCompletionCallback(result.messageId, prompt.onComplete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
prompt.onAdmitted?.();
|
||||
if (prompt.onComplete && result.messageId) {
|
||||
settleCompletionCallback(result.messageId, prompt.onComplete);
|
||||
}
|
||||
const next = [...current];
|
||||
next[index] = {
|
||||
...current[index]!,
|
||||
|
|
@ -1722,7 +1996,11 @@ export function useQueuedPrompts({
|
|||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {
|
||||
if (!isCurrentOwnerTokenRef.current(ownerToken)) return;
|
||||
if (latestSessionIdRef.current !== targetSessionId) return;
|
||||
fallbackToPendingPrompt(prompt.id);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[
|
||||
|
|
@ -1730,6 +2008,7 @@ export function useQueuedPrompts({
|
|||
canQueryMidTurn,
|
||||
fallbackToPendingPrompt,
|
||||
reconcileMidTurnMessages,
|
||||
releaseChainedPrompt,
|
||||
reportError,
|
||||
restoreQueuedPromptsToEditor,
|
||||
sessionActions,
|
||||
|
|
@ -1742,8 +2021,6 @@ export function useQueuedPrompts({
|
|||
|
||||
const { batches: midTurnInjectedBatches, consume: consumeMidTurnInjected } =
|
||||
useDaemonMidTurnInjected();
|
||||
// Keep injection echoes ahead of idle handling for legacy daemons, whose
|
||||
// local rows still fall back to the ordinary queue at the turn boundary.
|
||||
useEffect(() => {
|
||||
if (!sessionId || midTurnInjectedBatches.length === 0) return;
|
||||
const sessionBatches = midTurnInjectedBatches.filter(
|
||||
|
|
@ -1789,11 +2066,27 @@ export function useQueuedPrompts({
|
|||
|
||||
useEffect(() => {
|
||||
if (streamingState !== 'idle' || writeBlocked) return;
|
||||
const ctrl = midTurnEnqueueAbortRef.current;
|
||||
if (ctrl && !canQueryMidTurn) {
|
||||
ctrl.abort();
|
||||
midTurnEnqueueAbortRef.current = null;
|
||||
if (!canQueryMidTurn) {
|
||||
const acceptedIds = new Set(
|
||||
queuedPromptsRef.current
|
||||
.filter(
|
||||
(prompt) =>
|
||||
prompt.midTurnState === 'queued' &&
|
||||
!prompt.midTurnFailedAction &&
|
||||
!prompt.isEditing &&
|
||||
!prompt.isRemoving,
|
||||
)
|
||||
.map((prompt) => prompt.id),
|
||||
);
|
||||
if (acceptedIds.size > 0) {
|
||||
const next = queuedPromptsRef.current.filter(
|
||||
(prompt) => !acceptedIds.has(prompt.id),
|
||||
);
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
}
|
||||
}
|
||||
if (holdQueuedPromptsLocally) return;
|
||||
for (const prompt of queuedPromptsRef.current) {
|
||||
if (!prompt.midTurnFailedAction) continue;
|
||||
const next = queuedPromptsRef.current.filter(
|
||||
|
|
@ -1805,15 +2098,57 @@ export function useQueuedPrompts({
|
|||
restoreQueuedPromptsToEditor([prompt], prompt.sessionId);
|
||||
}
|
||||
}
|
||||
if (!canQueryMidTurn) {
|
||||
for (const prompt of queuedPromptsRef.current) {
|
||||
if (
|
||||
prompt.midTurnState &&
|
||||
!prompt.midTurnFailedAction &&
|
||||
!prompt.isEditing &&
|
||||
!prompt.isRemoving
|
||||
) {
|
||||
fallbackToPendingPrompt(prompt.id);
|
||||
const localPrompts = queuedPromptsRef.current.filter(
|
||||
(prompt) =>
|
||||
isLocallyHeldPrompt(prompt) &&
|
||||
!prompt.isEditing &&
|
||||
!prompt.isRemoving &&
|
||||
!prompt.isInserting,
|
||||
);
|
||||
if (localPrompts.length > 0) {
|
||||
const localIds = new Set(localPrompts.map((prompt) => prompt.id));
|
||||
const next = queuedPromptsRef.current.map((prompt) =>
|
||||
localIds.has(prompt.id)
|
||||
? { ...prompt, serverState: 'submitting' as const }
|
||||
: prompt,
|
||||
);
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
// Release serially: a prompt carrying media awaits its uploads before its
|
||||
// admission POST, so firing the whole batch at once lets a later plain
|
||||
// prompt overtake it and reach the daemon's queue out of order.
|
||||
//
|
||||
// The chain is built synchronously, but each link runs only after the
|
||||
// previous admission settles, so the session can change mid-drain.
|
||||
// Pinned here rather than read per link: the guard has to ask "is this
|
||||
// still the owner the chain was built for", not "is there an owner".
|
||||
const chainOwner = ownerTokenRef.current;
|
||||
// A chain for this owner may still be draining (a hold that flipped on
|
||||
// and back off re-drains the rows its links reverted). Extend it instead
|
||||
// of racing it, so every release for one owner stays on one chain.
|
||||
const liveChain = releaseChainRef.current;
|
||||
const liveTail =
|
||||
liveChain && isCurrentOwnerTokenRef.current(liveChain.owner)
|
||||
? liveChain.tail
|
||||
: undefined;
|
||||
let release: Promise<void> | undefined = liveTail;
|
||||
for (const id of localIds) unreleasedPromptIdsRef.current.add(id);
|
||||
for (const prompt of next) {
|
||||
if (!localIds.has(prompt.id)) continue;
|
||||
const submit = () => releaseChainedPrompt(prompt, chainOwner);
|
||||
// With no chain already draining, the first release stays synchronous,
|
||||
// so a single held prompt reaches the daemon exactly as it did before.
|
||||
release = release ? release.then(submit) : submit();
|
||||
}
|
||||
// Publish the tail so a prompt typed during the drain queues behind the
|
||||
// rows it was typed after instead of POSTing past them.
|
||||
if (release) {
|
||||
if (liveChain && liveTail !== undefined) {
|
||||
liveChain.tail = release;
|
||||
} else {
|
||||
const chain = { owner: chainOwner, tail: release };
|
||||
releaseChainRef.current = chain;
|
||||
retireChainWhenDrained(releaseChainRef, chain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1834,8 +2169,10 @@ export function useQueuedPrompts({
|
|||
}, [
|
||||
streamingState,
|
||||
writeBlocked,
|
||||
holdQueuedPromptsLocally,
|
||||
canQueryMidTurn,
|
||||
fallbackToPendingPrompt,
|
||||
releaseChainedPrompt,
|
||||
submitPendingPrompt,
|
||||
restoreQueuedPromptsToEditor,
|
||||
reconcileMidTurnMessages,
|
||||
]);
|
||||
|
|
@ -1863,7 +2200,16 @@ export function useQueuedPrompts({
|
|||
(
|
||||
id: number,
|
||||
flags: Partial<
|
||||
Pick<QueuedPrompt, 'isEditing' | 'isRemoving' | 'midTurnFailedAction'>
|
||||
Pick<
|
||||
QueuedPrompt,
|
||||
| 'isEditing'
|
||||
| 'isRemoving'
|
||||
| 'isInserting'
|
||||
| 'midTurnFailedAction'
|
||||
| 'midTurnState'
|
||||
| 'midTurnMessageId'
|
||||
| 'serverState'
|
||||
>
|
||||
>,
|
||||
) => {
|
||||
const next = queuedPromptsRef.current.map((prompt) =>
|
||||
|
|
@ -2092,6 +2438,7 @@ export function useQueuedPrompts({
|
|||
const removeQueuedPrompt = useCallback(
|
||||
(id: number) => {
|
||||
const target = queuedPromptsRef.current.find((p) => p.id === id);
|
||||
if (target?.isInserting) return;
|
||||
if (
|
||||
target?.serverState === 'submitting' ||
|
||||
target?.midTurnState === 'submitting'
|
||||
|
|
@ -2123,6 +2470,299 @@ export function useQueuedPrompts({
|
|||
[removeMidTurnPromptForAction, removeServerPromptForAction, t],
|
||||
);
|
||||
|
||||
const insertQueuedPrompt = useCallback(
|
||||
async (id: number) => {
|
||||
const prompt = queuedPromptsRef.current.find((item) => item.id === id);
|
||||
if (
|
||||
!canMutateMidTurn ||
|
||||
latestStreamingStateRef.current === 'idle' ||
|
||||
!prompt ||
|
||||
prompt.serverState !== undefined ||
|
||||
prompt.serverPromptId !== undefined ||
|
||||
prompt.midTurnState !== undefined ||
|
||||
prompt.isEditing ||
|
||||
prompt.isRemoving ||
|
||||
prompt.isInserting ||
|
||||
(prompt.images?.length ?? 0) > 0 ||
|
||||
(prompt.files?.length ?? 0) > 0 ||
|
||||
(prompt.inputAnnotations?.length ?? 0) > 0 ||
|
||||
isCommandPrompt(prompt.text)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = canQueryMidTurn
|
||||
? `webui_${
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
||||
}`
|
||||
: undefined;
|
||||
const targetSessionId = prompt.sessionId ?? latestSessionIdRef.current;
|
||||
const promptOwnerKey = queueOwnerKey(
|
||||
latestWorkspaceCwdRef.current,
|
||||
targetSessionId,
|
||||
);
|
||||
// Both resolved at USE time, not capture time: an owner change while the
|
||||
// insert is in flight relocates the stash onto a new key without the row
|
||||
// ever leaving the session it was started from.
|
||||
const currentStashKey = () =>
|
||||
resolveStashKey(
|
||||
heldPromptsByOwnerRef.current,
|
||||
promptOwnerKey,
|
||||
targetSessionId,
|
||||
);
|
||||
// Compare the session half only — the workspace half resolving is not an
|
||||
// owner change for a row already pinned to `targetSessionId`.
|
||||
const insertOwnerMatches = () =>
|
||||
targetSessionId === undefined
|
||||
? latestSessionIdRef.current === undefined
|
||||
: latestSessionIdRef.current === targetSessionId;
|
||||
const insertionOwnerToken = ownerTokenRef.current;
|
||||
const insertionGeneration =
|
||||
(explicitInsertGenerationsRef.current.get(prompt.id) ?? 0) + 1;
|
||||
explicitInsertGenerationsRef.current.set(prompt.id, insertionGeneration);
|
||||
const isCurrentInsertion = () =>
|
||||
explicitInsertGenerationsRef.current.get(prompt.id) ===
|
||||
insertionGeneration;
|
||||
const finishInsertion = () => {
|
||||
if (isCurrentInsertion()) {
|
||||
explicitInsertGenerationsRef.current.delete(prompt.id);
|
||||
}
|
||||
};
|
||||
const clearInsertionFlag = (
|
||||
flags: Partial<
|
||||
Pick<
|
||||
QueuedPrompt,
|
||||
'isInserting' | 'midTurnState' | 'midTurnMessageId' | 'serverState'
|
||||
>
|
||||
> = { isInserting: false },
|
||||
) => {
|
||||
setQueuedPromptFlags(prompt.id, flags);
|
||||
const stashKey = currentStashKey();
|
||||
if (!stashKey) return;
|
||||
const stashed = heldPromptsByOwnerRef.current.get(stashKey);
|
||||
if (!stashed) return;
|
||||
heldPromptsByOwnerRef.current.set(
|
||||
stashKey,
|
||||
stashed.map((item) =>
|
||||
item.id === prompt.id ? { ...item, ...flags } : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
const dropInsertedPrompt = () => {
|
||||
const next = queuedPromptsRef.current.filter(
|
||||
(item) => item.id !== prompt.id,
|
||||
);
|
||||
if (next.length !== queuedPromptsRef.current.length) {
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
}
|
||||
const stashKey = currentStashKey();
|
||||
if (!stashKey) return;
|
||||
const stashed = heldPromptsByOwnerRef.current.get(stashKey);
|
||||
if (!stashed) return;
|
||||
heldPromptsByOwnerRef.current.set(
|
||||
stashKey,
|
||||
stashed.filter((item) => item.id !== prompt.id),
|
||||
);
|
||||
};
|
||||
const recoverAfterSettledInsert = (
|
||||
flags: Partial<
|
||||
Pick<
|
||||
QueuedPrompt,
|
||||
'isInserting' | 'midTurnState' | 'midTurnMessageId' | 'serverState'
|
||||
>
|
||||
>,
|
||||
): boolean => {
|
||||
const submitAtIdle =
|
||||
isCurrentOwnerTokenRef.current(insertionOwnerToken) &&
|
||||
insertOwnerMatches() &&
|
||||
(latestStreamingStateRef.current as DaemonStreamingState) ===
|
||||
'idle' &&
|
||||
!writeBlockedRef.current &&
|
||||
!holdQueuedPromptsLocallyRef.current;
|
||||
const nextFlags = {
|
||||
...flags,
|
||||
...(submitAtIdle ? { serverState: 'submitting' as const } : {}),
|
||||
};
|
||||
clearInsertionFlag(nextFlags);
|
||||
finishInsertion();
|
||||
if (submitAtIdle) {
|
||||
const pendingPrompt = queuedPromptsRef.current.find(
|
||||
(item) => item.id === prompt.id,
|
||||
);
|
||||
if (pendingPrompt) submitPendingPrompt(pendingPrompt);
|
||||
}
|
||||
return submitAtIdle;
|
||||
};
|
||||
setQueuedPromptFlags(prompt.id, {
|
||||
isInserting: true,
|
||||
isRemoving: false,
|
||||
...(messageId ? { midTurnMessageId: messageId } : {}),
|
||||
});
|
||||
// Deliberately uncancellable: an explicit insert the user asked for
|
||||
// outlives an owner rotation and settles into the queue of the session it
|
||||
// was started from (pinned by the source-session stash tests), so it gets
|
||||
// no abort signal.
|
||||
let result: Awaited<
|
||||
ReturnType<typeof sessionActions.enqueueMidTurnMessage>
|
||||
>;
|
||||
try {
|
||||
result = await sessionActions.enqueueMidTurnMessage(prompt.text, {
|
||||
...(messageId ? { messageId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentInsertion()) return;
|
||||
if (messageId) {
|
||||
// The request was dispatched, so the daemon may already own this
|
||||
// message: its queue snapshot decides. A message the daemon reports
|
||||
// as waiting becomes a daemon-owned mid-turn row, one it reports as
|
||||
// settled or promoted has left the local queue, and anything it does
|
||||
// not know (or that it cannot be asked about) returns to the local
|
||||
// hold rather than being dropped.
|
||||
finishInsertion();
|
||||
const stillOwned =
|
||||
targetSessionId !== undefined && insertOwnerMatches();
|
||||
const snapshot = stillOwned
|
||||
? await reconcileMidTurnMessages(targetSessionId).catch(
|
||||
() => undefined,
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
snapshot?.messages.some(
|
||||
(message) => message.messageId === messageId,
|
||||
)
|
||||
) {
|
||||
clearInsertionFlag({
|
||||
isInserting: false,
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: messageId,
|
||||
});
|
||||
prompt.onAdmitted?.();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
snapshot?.settledMessageIds.includes(messageId) ||
|
||||
snapshot?.promotedMessageIds.includes(messageId)
|
||||
) {
|
||||
dropInsertedPrompt();
|
||||
prompt.onAdmitted?.();
|
||||
return;
|
||||
}
|
||||
clearInsertionFlag({
|
||||
isInserting: false,
|
||||
midTurnMessageId: undefined,
|
||||
});
|
||||
if (stillOwned) reportError(error, t('queue.insertFailed'));
|
||||
return;
|
||||
}
|
||||
recoverAfterSettledInsert({
|
||||
isInserting: false,
|
||||
midTurnMessageId: undefined,
|
||||
});
|
||||
if (insertOwnerMatches()) {
|
||||
reportError(error, t('queue.insertFailed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isCurrentInsertion()) return;
|
||||
if (!result.accepted) {
|
||||
const submitted = recoverAfterSettledInsert({
|
||||
isInserting: false,
|
||||
midTurnMessageId: undefined,
|
||||
});
|
||||
if (!submitted && insertOwnerMatches()) {
|
||||
reportError(
|
||||
new Error('Queued message was not accepted for insertion'),
|
||||
t('queue.insertFailed'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = queuedPromptsRef.current;
|
||||
const index = current.findIndex((item) => item.id === prompt.id);
|
||||
const acceptedAtLegacyIdle =
|
||||
insertOwnerMatches() &&
|
||||
(latestStreamingStateRef.current as DaemonStreamingState) === 'idle' &&
|
||||
!canQueryMidTurn;
|
||||
if (index === -1) {
|
||||
const stashKey = currentStashKey();
|
||||
if (stashKey) {
|
||||
const stashed = heldPromptsByOwnerRef.current.get(stashKey);
|
||||
if (stashed) {
|
||||
heldPromptsByOwnerRef.current.set(
|
||||
stashKey,
|
||||
acceptedAtLegacyIdle
|
||||
? stashed.filter((item) => item.id !== prompt.id)
|
||||
: stashed.map((item) =>
|
||||
item.id === prompt.id
|
||||
? {
|
||||
...item,
|
||||
midTurnState: 'queued' as const,
|
||||
midTurnMessageId: result.messageId ?? messageId,
|
||||
isInserting: false,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
finishInsertion();
|
||||
prompt.onAdmitted?.();
|
||||
if (canQueryMidTurn && targetSessionId) {
|
||||
await reconcileMidTurnMessages(targetSessionId).catch((error) => {
|
||||
reportError(error, t('queue.insertFailed'));
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (acceptedAtLegacyIdle) {
|
||||
const next = current.filter((item) => item.id !== prompt.id);
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
finishInsertion();
|
||||
prompt.onAdmitted?.();
|
||||
return;
|
||||
}
|
||||
if (!current[index]!.isInserting) {
|
||||
finishInsertion();
|
||||
return;
|
||||
}
|
||||
const next = [...current];
|
||||
next[index] = {
|
||||
...current[index]!,
|
||||
serverPromptId: undefined,
|
||||
serverState: undefined,
|
||||
midTurnState: 'queued',
|
||||
midTurnMessageId: result.messageId ?? messageId,
|
||||
isInserting: false,
|
||||
};
|
||||
queuedPromptsRef.current = next;
|
||||
setQueuedPrompts(next);
|
||||
finishInsertion();
|
||||
prompt.onAdmitted?.();
|
||||
|
||||
if (canQueryMidTurn && targetSessionId) {
|
||||
await reconcileMidTurnMessages(targetSessionId).catch((error) => {
|
||||
reportError(error, t('queue.insertFailed'));
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
canMutateMidTurn,
|
||||
canQueryMidTurn,
|
||||
reconcileMidTurnMessages,
|
||||
reportError,
|
||||
sessionActions,
|
||||
setQueuedPromptFlags,
|
||||
submitPendingPrompt,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const editQueuedPrompt = useCallback(
|
||||
async (id: number) => {
|
||||
const target = queuedPromptsRef.current.find((p) => p.id === id);
|
||||
|
|
@ -2130,7 +2770,7 @@ export function useQueuedPrompts({
|
|||
if (target.payloadCompleteness === 'summary-only') {
|
||||
return;
|
||||
}
|
||||
if (target.isEditing || target.isRemoving) return;
|
||||
if (target.isEditing || target.isRemoving || target.isInserting) return;
|
||||
if (target.midTurnState) {
|
||||
const removed = await removeMidTurnPromptForAction(
|
||||
target,
|
||||
|
|
@ -2176,6 +2816,7 @@ export function useQueuedPrompts({
|
|||
(target.midTurnState === 'queued' && !target.midTurnMessageId) ||
|
||||
target.isEditing ||
|
||||
target.isRemoving ||
|
||||
target.isInserting ||
|
||||
target.payloadCompleteness === 'summary-only'
|
||||
) {
|
||||
return true;
|
||||
|
|
@ -2229,7 +2870,8 @@ export function useQueuedPrompts({
|
|||
const clearablePrompts = queuedPromptsRef.current.filter(
|
||||
(prompt) =>
|
||||
prompt.midTurnState === undefined &&
|
||||
prompt.serverState !== 'submitting',
|
||||
prompt.serverState !== 'submitting' &&
|
||||
!prompt.isInserting,
|
||||
);
|
||||
if (submittingPrompts.length > 0) {
|
||||
const submittingIds = new Set(
|
||||
|
|
@ -2249,8 +2891,8 @@ export function useQueuedPrompts({
|
|||
);
|
||||
if (serverPrompts.length === 0) {
|
||||
const retainedIds = new Set(midTurnPrompts.map((prompt) => prompt.id));
|
||||
const retained = queuedPromptsRef.current.filter((prompt) =>
|
||||
retainedIds.has(prompt.id),
|
||||
const retained = queuedPromptsRef.current.filter(
|
||||
(prompt) => retainedIds.has(prompt.id) || prompt.isInserting,
|
||||
);
|
||||
queuedPromptsRef.current = retained;
|
||||
setQueuedPrompts(retained);
|
||||
|
|
@ -2337,6 +2979,7 @@ export function useQueuedPrompts({
|
|||
queuedTexts,
|
||||
enqueuePrompt,
|
||||
removeQueuedPrompt,
|
||||
insertQueuedPrompt,
|
||||
editQueuedPrompt,
|
||||
editLastQueuedPrompt,
|
||||
clearQueuedPrompts,
|
||||
|
|
|
|||
|
|
@ -1143,6 +1143,8 @@ const EN: Messages = {
|
|||
'scheduledTasks.error.emptyPrompt': 'Prompt is required',
|
||||
'scheduledTasks.error.promptTooLong': (v) =>
|
||||
`Prompt exceeds ${v?.max ?? 100_000}-character limit`,
|
||||
'scheduledTasks.error.goalActive':
|
||||
'Cannot start a scheduled task while a Goal is active.',
|
||||
'scheduledTasks.error.toggleFailed': 'Failed to update task',
|
||||
'scheduledTasks.error.deleteFailed': 'Failed to delete task',
|
||||
'scheduledTasks.edit': 'Edit',
|
||||
|
|
@ -1407,6 +1409,7 @@ const EN: Messages = {
|
|||
'Unsupported theme. Use /theme light or /theme dark.',
|
||||
'queue.delete': 'Delete',
|
||||
'queue.edit': 'Edit',
|
||||
'queue.insert': 'Insert',
|
||||
'queue.cleared': 'Queue cleared.',
|
||||
'queue.deleteTip': 'Remove from queue',
|
||||
'queue.editTip': 'Remove from queue and edit again',
|
||||
|
|
@ -1414,6 +1417,7 @@ const EN: Messages = {
|
|||
'queue.midTurnQueued': 'Queued...',
|
||||
'queue.serverQueued': 'Queued on server...',
|
||||
'queue.editing': 'Editing...',
|
||||
'queue.inserting': 'Inserting...',
|
||||
'queue.removing': 'Updating...',
|
||||
'queue.submittingDisabled': 'Submitting queued message...',
|
||||
'queue.summaryEditDisabled':
|
||||
|
|
@ -1438,6 +1442,9 @@ const EN: Messages = {
|
|||
'queue.queueFailed': 'Failed to queue message',
|
||||
'queue.deleteFailed': 'Failed to move message out of queue',
|
||||
'queue.editFailed': 'Failed to edit queued message',
|
||||
'queue.insertFailed': 'Failed to insert queued message',
|
||||
'queue.insertTip': 'Insert into the current turn',
|
||||
'queue.insertCommandDisabled': 'Commands cannot be inserted mid-turn',
|
||||
'queue.footer':
|
||||
'Press ↑ to edit the latest queued message · Esc to clear queue',
|
||||
'queue.imageCount': (v) => `(+${v?.count ?? 0} images)`,
|
||||
|
|
@ -2051,6 +2058,17 @@ const EN: Messages = {
|
|||
'goal.notYetMet': 'not yet met',
|
||||
'goal.set': 'Goal set',
|
||||
'goal.statusActive': '/goal active',
|
||||
'goal.status.active': 'In progress',
|
||||
'goal.status.paused': 'Paused',
|
||||
'goal.status.blocked': 'Blocked',
|
||||
'goal.status.usage_limited': 'Usage limited',
|
||||
'goal.status.complete': 'Complete',
|
||||
'goal.activity.idle': 'Waiting',
|
||||
'goal.activity.running': 'Working',
|
||||
'goal.activity.verifying': 'Verifying',
|
||||
'goal.edit': 'Edit goal',
|
||||
'goal.pause': 'Pause goal',
|
||||
'goal.resume': 'Resume goal',
|
||||
'goal.turn': (v) => `${v?.count ?? 0} turn`,
|
||||
'goal.turnLabel': (v) => `turn ${v?.count ?? 0}`,
|
||||
'goal.turns': (v) => `${v?.count ?? 0} turns`,
|
||||
|
|
@ -2070,6 +2088,10 @@ const EN: Messages = {
|
|||
'goals.cancel': 'Cancel',
|
||||
'goals.create': 'Set goal',
|
||||
'goals.creating': 'Starting…',
|
||||
'goals.saving': 'Saving…',
|
||||
'goals.save': 'Save',
|
||||
'goals.edit': 'Edit goal',
|
||||
'goals.objective': 'Objective',
|
||||
'goals.clear': 'Clear goal',
|
||||
'goals.clearConfirm': (v) => `Clear the goal "${v?.condition ?? ''}"?`,
|
||||
'goals.running': 'Working',
|
||||
|
|
@ -2082,7 +2104,21 @@ const EN: Messages = {
|
|||
'goals.error.clearKeyword': (v) =>
|
||||
`"${v?.word ?? ''}" clears a goal rather than setting one. Describe the condition to work toward.`,
|
||||
'goals.error.createFailed': 'Failed to start the goal',
|
||||
'goals.error.saveFailed': 'Failed to save the goal',
|
||||
'goals.error.goalUnavailable': 'The goal is no longer available.',
|
||||
'goals.error.requiresObjective': (v) =>
|
||||
`/goal ${v?.keyword ?? 'set'} requires an objective.`,
|
||||
'goals.error.invalidCommand': 'Invalid /goal command',
|
||||
'goals.error.goalsUnavailable':
|
||||
'The goals view is not available on this surface.',
|
||||
'goals.error.attachmentsUnsupported':
|
||||
'Remove attachments before using /goal.',
|
||||
'goals.error.editFailed': 'Failed to edit the goal',
|
||||
'goals.error.pauseFailed': 'Failed to pause the goal',
|
||||
'goals.error.resumeFailed': 'Failed to resume the goal',
|
||||
'goals.error.clearFailed': 'Failed to clear the goal',
|
||||
'goals.error.controlBusy':
|
||||
'Another goal control is still running. Try again once it finishes.',
|
||||
'memory.add': 'Add',
|
||||
'memory.add.desc': 'Write a durable memory',
|
||||
'memory.autoDream': (v) =>
|
||||
|
|
@ -4120,6 +4156,7 @@ const ZH: Messages = {
|
|||
'scheduledTasks.error.emptyPrompt': '提示词不能为空',
|
||||
'scheduledTasks.error.promptTooLong': (v) =>
|
||||
`提示词超过 ${v?.max ?? 100_000} 字符限制`,
|
||||
'scheduledTasks.error.goalActive': '目标运行期间无法启动定时任务。',
|
||||
'scheduledTasks.error.toggleFailed': '更新任务失败',
|
||||
'scheduledTasks.error.deleteFailed': '删除任务失败',
|
||||
'scheduledTasks.edit': '编辑',
|
||||
|
|
@ -4370,6 +4407,7 @@ const ZH: Messages = {
|
|||
'不支持该主题。请使用 /theme light 或 /theme dark。',
|
||||
'queue.delete': '删除',
|
||||
'queue.edit': '编辑',
|
||||
'queue.insert': '插入',
|
||||
'queue.cleared': '队列已清空。',
|
||||
'queue.deleteTip': '移出队列',
|
||||
'queue.editTip': '移出队列并将内容放回输入框',
|
||||
|
|
@ -4377,6 +4415,7 @@ const ZH: Messages = {
|
|||
'queue.midTurnQueued': '排队中...',
|
||||
'queue.serverQueued': '服务器排队中...',
|
||||
'queue.editing': '编辑中...',
|
||||
'queue.inserting': '插入中...',
|
||||
'queue.removing': '处理中...',
|
||||
'queue.submittingDisabled': '排队消息正在提交中...',
|
||||
'queue.summaryEditDisabled': '恢复的队列摘要无法还原原始附件。',
|
||||
|
|
@ -4393,6 +4432,9 @@ const ZH: Messages = {
|
|||
'queue.queueFailed': '排队消息失败',
|
||||
'queue.deleteFailed': '移出队列失败',
|
||||
'queue.editFailed': '编辑排队消息失败',
|
||||
'queue.insertFailed': '插入排队消息失败',
|
||||
'queue.insertTip': '插入当前回合',
|
||||
'queue.insertCommandDisabled': '命令不能插入当前回合',
|
||||
'queue.footer': '按 ↑ 编辑最后一条排队消息 · Esc 清空队列',
|
||||
'queue.imageCount': (v) => `(+${v?.count ?? 0} 张图片)`,
|
||||
'queue.fileCount': (v) => `(+${v?.count ?? 0} 个文件)`,
|
||||
|
|
@ -4949,6 +4991,17 @@ const ZH: Messages = {
|
|||
'goal.notYetMet': '尚未满足',
|
||||
'goal.set': '目标已设置',
|
||||
'goal.statusActive': '/goal 运行中',
|
||||
'goal.status.active': '进行中',
|
||||
'goal.status.paused': '已暂停',
|
||||
'goal.status.blocked': '已阻塞',
|
||||
'goal.status.usage_limited': '用量受限',
|
||||
'goal.status.complete': '已完成',
|
||||
'goal.activity.idle': '等待中',
|
||||
'goal.activity.running': '执行中',
|
||||
'goal.activity.verifying': '验证中',
|
||||
'goal.edit': '编辑目标',
|
||||
'goal.pause': '暂停目标',
|
||||
'goal.resume': '继续目标',
|
||||
'goal.turn': (v) => `${v?.count ?? 0} 轮`,
|
||||
'goal.turnLabel': (v) => `第 ${v?.count ?? 0} 轮`,
|
||||
'goal.turns': (v) => `${v?.count ?? 0} 轮`,
|
||||
|
|
@ -4966,6 +5019,10 @@ const ZH: Messages = {
|
|||
'goals.cancel': '取消',
|
||||
'goals.create': '设置目标',
|
||||
'goals.creating': '正在启动…',
|
||||
'goals.saving': '正在保存…',
|
||||
'goals.save': '保存',
|
||||
'goals.edit': '编辑目标',
|
||||
'goals.objective': '目标',
|
||||
'goals.clear': '清除目标',
|
||||
'goals.clearConfirm': (v) => `确定清除目标“${v?.condition ?? ''}”?`,
|
||||
'goals.running': '工作中',
|
||||
|
|
@ -4978,7 +5035,18 @@ const ZH: Messages = {
|
|||
'goals.error.clearKeyword': (v) =>
|
||||
`“${v?.word ?? ''}”是清除目标的指令,不是一个条件。请描述要达成的条件。`,
|
||||
'goals.error.createFailed': '启动目标失败',
|
||||
'goals.error.saveFailed': '保存目标失败',
|
||||
'goals.error.goalUnavailable': '该目标已不可用。',
|
||||
'goals.error.requiresObjective': (v) =>
|
||||
`/goal ${v?.keyword ?? 'set'} 需要提供目标内容。`,
|
||||
'goals.error.invalidCommand': '无效的 /goal 命令',
|
||||
'goals.error.goalsUnavailable': '当前界面不支持打开目标视图。',
|
||||
'goals.error.attachmentsUnsupported': '使用 /goal 前请先移除附件。',
|
||||
'goals.error.editFailed': '编辑目标失败',
|
||||
'goals.error.pauseFailed': '暂停目标失败',
|
||||
'goals.error.resumeFailed': '继续目标失败',
|
||||
'goals.error.clearFailed': '清除目标失败',
|
||||
'goals.error.controlBusy': '还有一个目标操作正在进行,请等它完成后再试。',
|
||||
'memory.add': '添加',
|
||||
'memory.add.desc': '写入一条持久 memory',
|
||||
'memory.autoDream': (v) =>
|
||||
|
|
|
|||
|
|
@ -114,6 +114,23 @@ describe('removeInjectedFromQueue', () => {
|
|||
expect(next?.map((p) => p.text)).toEqual(['keep']);
|
||||
});
|
||||
|
||||
it('removes an in-flight explicit insert on a strict id match', () => {
|
||||
// An explicitly inserted row carries no `midTurnState` until its admission
|
||||
// response lands, so the strict-id pass must reach it through
|
||||
// `isInserting` — otherwise the injection echo leaves it queued twice.
|
||||
const inserting = {
|
||||
...q('explicit insert', [{ data: 'x' }]),
|
||||
midTurnState: undefined,
|
||||
isInserting: true,
|
||||
};
|
||||
const next = removeInjectedFromQueue(
|
||||
[inserting, q('keep')],
|
||||
[batchWithIds('s', ['explicit insert'], [inserting.midTurnMessageId!])],
|
||||
's',
|
||||
);
|
||||
expect(next?.map((p) => p.text)).toEqual(['keep']);
|
||||
});
|
||||
|
||||
it('never matches a file-bearing entry (files are not pushed mid-turn)', () => {
|
||||
const withFile = { ...q('with file'), files: [{ name: 'app.log' }] };
|
||||
const prompts = [withFile, q('with file')];
|
||||
|
|
@ -164,6 +181,19 @@ describe('removeInjectedFromQueue', () => {
|
|||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches an explicit insert before its admission response arrives', () => {
|
||||
const inserting = {
|
||||
text: 'early explicit insert',
|
||||
isInserting: true,
|
||||
};
|
||||
const next = removeInjectedFromQueue(
|
||||
[inserting],
|
||||
[batch('s', 'early explicit insert')],
|
||||
's',
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the id-matched row, not an earlier same-text row still submitting', () => {
|
||||
// Two same-text sends: the first is still awaiting its admission id, the
|
||||
// second was admitted and queued with an id. The injection frame names the
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface MidTurnQueueItem {
|
|||
files?: unknown[];
|
||||
midTurnState?: 'submitting' | 'queued';
|
||||
midTurnMessageId?: string;
|
||||
isInserting?: boolean;
|
||||
}
|
||||
|
||||
export interface MidTurnInjectedBatch {
|
||||
|
|
@ -81,7 +82,7 @@ export function removeInjectedFromQueue<T extends MidTurnQueueItem>(
|
|||
messageId !== undefined
|
||||
? remaining.findIndex(
|
||||
(prompt) =>
|
||||
prompt.midTurnState !== undefined &&
|
||||
(prompt.midTurnState !== undefined || prompt.isInserting) &&
|
||||
prompt.midTurnMessageId === messageId &&
|
||||
hasNoFiles(prompt),
|
||||
)
|
||||
|
|
@ -89,9 +90,9 @@ export function removeInjectedFromQueue<T extends MidTurnQueueItem>(
|
|||
if (index < 0 && originatorMatches) {
|
||||
index = remaining.findIndex(
|
||||
(prompt) =>
|
||||
prompt.midTurnState !== undefined &&
|
||||
(prompt.midTurnState !== undefined || prompt.isInserting) &&
|
||||
(messageId === undefined ||
|
||||
(prompt.midTurnState === 'submitting' &&
|
||||
((prompt.midTurnState === 'submitting' || prompt.isInserting) &&
|
||||
(!strictMessageIds ||
|
||||
prompt.midTurnMessageId === undefined))) &&
|
||||
prompt.text === message &&
|
||||
|
|
|
|||
|
|
@ -13,8 +13,29 @@ import {
|
|||
goalArgOf,
|
||||
isGoalClearCommand,
|
||||
isGoalClearKeyword,
|
||||
parseWebShellGoalCommand,
|
||||
} from './goalCondition';
|
||||
|
||||
describe('parseWebShellGoalCommand', () => {
|
||||
it.each([
|
||||
['/goal', { kind: 'status' }],
|
||||
['/goal ship it', { kind: 'set', objective: 'ship it' }],
|
||||
['/goal set ship it', { kind: 'set', objective: 'ship it' }],
|
||||
['/goal edit safer', { kind: 'edit', objective: 'safer' }],
|
||||
['/goal pause', { kind: 'pause' }],
|
||||
['/goal resume', { kind: 'resume' }],
|
||||
['/goal clear', { kind: 'clear' }],
|
||||
['/goal stop', { kind: 'clear' }],
|
||||
])('parses %s', (input, expected) => {
|
||||
expect(parseWebShellGoalCommand(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('rejects set and edit without an objective', () => {
|
||||
expect(parseWebShellGoalCommand('/goal set').kind).toBe('error');
|
||||
expect(parseWebShellGoalCommand('/goal edit').kind).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('goalArgOf', () => {
|
||||
it('returns an empty string for a bare /goal', () => {
|
||||
expect(goalArgOf('/goal')).toBe('');
|
||||
|
|
|
|||
|
|
@ -20,11 +20,42 @@ export const GOAL_CLEAR_KEYWORDS: ReadonlySet<string> = new Set([
|
|||
'cancel',
|
||||
]);
|
||||
|
||||
export type ParsedWebShellGoalCommand =
|
||||
| { kind: 'status' }
|
||||
| { kind: 'set' | 'edit'; objective: string }
|
||||
| { kind: 'pause' | 'resume' | 'clear' }
|
||||
| { kind: 'error'; keyword: 'set' | 'edit' };
|
||||
|
||||
/** The argument of a `/goal …` command; `''` for a bare `/goal`. */
|
||||
export function goalArgOf(text: string): string {
|
||||
return text.replace(/^\/goal\b/i, '').trim();
|
||||
}
|
||||
|
||||
/** Browser-side mirror of the CLI's Goal v3 command grammar. */
|
||||
export function parseWebShellGoalCommand(
|
||||
text: string,
|
||||
): ParsedWebShellGoalCommand {
|
||||
const input = goalArgOf(text);
|
||||
if (!input) return { kind: 'status' };
|
||||
|
||||
const [head = '', ...tail] = input.split(/\s+/);
|
||||
const keyword = head.toLowerCase();
|
||||
const objective = tail.join(' ').trim();
|
||||
if (keyword === 'set' || keyword === 'edit') {
|
||||
// The message is the caller's to render: this module is imported by both
|
||||
// composers and must not bake in an untranslated English string.
|
||||
return objective
|
||||
? { kind: keyword, objective }
|
||||
: { kind: 'error', keyword };
|
||||
}
|
||||
if (tail.length === 0) {
|
||||
if (keyword === 'pause') return { kind: 'pause' };
|
||||
if (keyword === 'resume') return { kind: 'resume' };
|
||||
if (GOAL_CLEAR_KEYWORDS.has(keyword)) return { kind: 'clear' };
|
||||
}
|
||||
return { kind: 'set', objective: input };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `text` is a `/goal <clear-keyword>` invocation.
|
||||
*
|
||||
|
|
|
|||
52
packages/web-shell/client/utils/goalControlRequest.ts
Normal file
52
packages/web-shell/client/utils/goalControlRequest.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { GoalControlRequest, GoalRecord } from '@qwen-code/sdk/daemon';
|
||||
|
||||
export type GoalControlAction =
|
||||
| 'create'
|
||||
| 'replace'
|
||||
| 'edit'
|
||||
| 'pause'
|
||||
| 'resume'
|
||||
| 'clear';
|
||||
|
||||
/**
|
||||
* Build the versioned control request for `action` against the goal the daemon
|
||||
* just reported.
|
||||
*
|
||||
* Shared by the main composer and the split-view pane: both read a fresh
|
||||
* snapshot, then stamp `expectedGoalId`/`expectedRevision` from it. Keeping the
|
||||
* construction in one place is what stops the two surfaces from drifting apart
|
||||
* on the optimistic-concurrency contract.
|
||||
*/
|
||||
export function buildGoalControlRequest(
|
||||
action: GoalControlAction,
|
||||
goal: GoalRecord | null | undefined,
|
||||
objective: string | undefined,
|
||||
errors: { emptyObjective: string; goalUnavailable: string },
|
||||
): GoalControlRequest {
|
||||
if (action === 'create' || action === 'replace') {
|
||||
if (!objective) throw new Error(errors.emptyObjective);
|
||||
// Replacing nothing is a create; the daemon rejects a replace that names a
|
||||
// goal it no longer holds.
|
||||
return goal
|
||||
? {
|
||||
action: 'replace',
|
||||
objective,
|
||||
expectedGoalId: goal.goalId,
|
||||
expectedRevision: goal.revision,
|
||||
}
|
||||
: { action: 'create', objective };
|
||||
}
|
||||
if (!goal) throw new Error(errors.goalUnavailable);
|
||||
return {
|
||||
action,
|
||||
...(action === 'edit' ? { objective: objective ?? goal.objective } : {}),
|
||||
expectedGoalId: goal.goalId,
|
||||
expectedRevision: goal.revision,
|
||||
} as GoalControlRequest;
|
||||
}
|
||||
175
packages/web-shell/client/utils/goalGate.test.ts
Normal file
175
packages/web-shell/client/utils/goalGate.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GoalRecord } from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
GOAL_EVIDENCE_LIMIT_REASONS,
|
||||
canResumeGoal,
|
||||
isGoalEvidenceLimited,
|
||||
isGoalGateBlocked,
|
||||
} from './goalGate';
|
||||
|
||||
const [CATALOG_EXHAUSTED, CHECKPOINT_TOO_LARGE] = GOAL_EVIDENCE_LIMIT_REASONS;
|
||||
|
||||
const goal = (over: Partial<GoalRecord> = {}): GoalRecord => ({
|
||||
goalId: 'g1',
|
||||
revision: 1,
|
||||
objective: 'ship it',
|
||||
status: 'usage_limited',
|
||||
evidenceCursor: { recordId: null },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('isGoalGateBlocked', () => {
|
||||
it('fails closed on an unhydrated goal state and opens once it is known', () => {
|
||||
expect(isGoalGateBlocked({ sessionId: 's1' })).toBe(true);
|
||||
expect(
|
||||
isGoalGateBlocked({
|
||||
sessionId: 's1',
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGoalGateBlocked({
|
||||
sessionId: 's1',
|
||||
goalState: { v: 2, goal: goal({ status: 'active' }), activity: 'idle' },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isGoalGateBlocked({ goalState: undefined })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGoalEvidenceLimited', () => {
|
||||
it('reads `limitKind` as the field of record', () => {
|
||||
expect(isGoalEvidenceLimited(goal({ limitKind: 'evidence_catalog' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isGoalEvidenceLimited(goal({ limitKind: 'checkpoint_request' })),
|
||||
).toBe(true);
|
||||
expect(isGoalEvidenceLimited(goal())).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the sentinel prose for Goals persisted before `limitKind`', () => {
|
||||
// The whole point of the fallback: these records carry no `limitKind` at
|
||||
// all, so a gate that keys off that field alone reads them as resumable.
|
||||
expect(isGoalEvidenceLimited(goal({ lastReason: CATALOG_EXHAUSTED }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isGoalEvidenceLimited(goal({ lastReason: CHECKPOINT_TOO_LARGE })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves every other stop reason alone', () => {
|
||||
// Operational stops carry prose too, and they ARE resumable -- a fallback
|
||||
// that matched loosely would strand them with no Resume control.
|
||||
expect(
|
||||
isGoalEvidenceLimited(
|
||||
goal({ lastReason: 'The provider rate-limited us' }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGoalEvidenceLimited(
|
||||
goal({ lastReason: CATALOG_EXHAUSTED.slice(0, -1) }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canResumeGoal', () => {
|
||||
it('mirrors the reducer on the statuses that refuse resume outright', () => {
|
||||
expect(canResumeGoal(goal({ status: 'complete' }))).toBe(false);
|
||||
expect(canResumeGoal(goal({ status: 'active' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('offers resume for every stopped Goal the reducer accepts', () => {
|
||||
expect(canResumeGoal(goal({ status: 'paused' }))).toBe(true);
|
||||
expect(canResumeGoal(goal({ status: 'blocked' }))).toBe(true);
|
||||
expect(canResumeGoal(goal({ status: 'usage_limited' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('withholds resume from an evidence-limited Goal, by field or by sentinel', () => {
|
||||
expect(
|
||||
canResumeGoal(
|
||||
goal({ status: 'usage_limited', limitKind: 'evidence_catalog' }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canResumeGoal(
|
||||
goal({ status: 'usage_limited', lastReason: CATALOG_EXHAUSTED }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canResumeGoal(
|
||||
goal({ status: 'usage_limited', lastReason: CHECKPOINT_TOO_LARGE }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('scopes the evidence check to `usage_limited`, as the reducer does', () => {
|
||||
// The reducer only consults `isEvidenceLimited` under `usage_limited`; a
|
||||
// paused Goal carrying stale sentinel prose is still resumable there, and
|
||||
// hiding its Resume button would strand it with no way forward.
|
||||
expect(
|
||||
canResumeGoal(goal({ status: 'paused', lastReason: CATALOG_EXHAUSTED })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canResumeGoal(goal({ status: 'blocked', limitKind: 'evidence_catalog' })),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('core is the authority on the evidence-limit sentinels', () => {
|
||||
// The Web Shell client bundles for the browser and does not depend on
|
||||
// `@qwen-code/qwen-code-core`, so these strings cannot simply be imported
|
||||
// from the package that writes them. They are duplicated, and a comment
|
||||
// asking the next person to "keep in sync" is not a mechanism. Drift here is
|
||||
// silent and user-visible: the UI would offer a Resume button on a Goal the
|
||||
// reducer is guaranteed to reject with an invalid-transition 409.
|
||||
const repoRoot = fileURLToPath(new URL('../../../..', import.meta.url));
|
||||
const source = readFileSync(
|
||||
join(repoRoot, 'packages/core/src/goals/goal-protocol.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const literal = (name: string): string => {
|
||||
const match = new RegExp(
|
||||
`export const ${name} =\\s*\\n?\\s*'([^']*)';`,
|
||||
).exec(source);
|
||||
expect(
|
||||
match,
|
||||
`${name} literal not found in goal-protocol.ts`,
|
||||
).not.toBeNull();
|
||||
return match![1];
|
||||
};
|
||||
|
||||
it('agrees with goal-protocol.ts on both sentinel reasons', () => {
|
||||
expect(GOAL_EVIDENCE_LIMIT_REASONS).toEqual([
|
||||
literal('GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON'),
|
||||
literal('GOAL_CHECKPOINT_REQUEST_TOO_LARGE_REASON'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('agrees with goal-reducer.ts that both are still the only fallback', () => {
|
||||
// If core ever grows a third sentinel, `goalLimitKindForReason` gains a
|
||||
// third branch -- and this copy would silently keep offering Resume on it.
|
||||
const reducer = readFileSync(
|
||||
join(repoRoot, 'packages/core/src/goals/goal-protocol.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const branches = reducer.match(/if \(reason === GOAL_[A-Z_]+\) \{/g) ?? [];
|
||||
expect(branches).toHaveLength(GOAL_EVIDENCE_LIMIT_REASONS.length);
|
||||
});
|
||||
});
|
||||
91
packages/web-shell/client/utils/goalGate.ts
Normal file
91
packages/web-shell/client/utils/goalGate.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
GoalLimitKind,
|
||||
GoalSnapshotV2,
|
||||
GoalStatus,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
|
||||
/** The slice of the daemon connection the Goal gate reads. */
|
||||
export interface GoalGateConnection {
|
||||
sessionId?: string | undefined;
|
||||
goalState?: GoalSnapshotV2 | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a local action must be held back because a Goal owns the session.
|
||||
*
|
||||
* Fails CLOSED while `goalState` is still hydrating: the session load clears
|
||||
* `loadingTranscript` (making the composer writable) before its `goal()` fetch
|
||||
* resolves, so an unknown Goal state on a real session has to read as "a Goal
|
||||
* may be active". The daemon has no server-side prompt gate for an active Goal,
|
||||
* so a submit inside that window would bypass the Goal queue outright.
|
||||
*
|
||||
* Every Goal gate in the client goes through here — the composer submit path,
|
||||
* the local queue hold, and the manual/bound run guards — so none of them can
|
||||
* drift into failing open on its own.
|
||||
*/
|
||||
export function isGoalGateBlocked(connection: GoalGateConnection): boolean {
|
||||
return (
|
||||
connection.sessionId !== undefined &&
|
||||
(connection.goalState === undefined ||
|
||||
connection.goalState.goal?.status === 'active')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two `lastReason` sentinels that marked an evidence-limited stop before
|
||||
* `limitKind` existed.
|
||||
*
|
||||
* Duplicated from `packages/core/src/goals/goal-protocol.ts` because the Web
|
||||
* Shell client bundles for the browser and does not depend on
|
||||
* `@qwen-code/qwen-code-core`. `goalGate.drift.test.ts` reads that file and
|
||||
* fails if either string moves, so this copy cannot go stale silently.
|
||||
*/
|
||||
export const GOAL_EVIDENCE_LIMIT_REASONS: readonly string[] = [
|
||||
'The current Goal revision exceeded the bounded evidence catalog. Automatic retries cannot recover. Edit or replace the Goal before resuming it.',
|
||||
'The current Goal revision exceeded the checkpoint verifier request limit. Automatic retries cannot recover. Edit or replace the Goal before resuming it.',
|
||||
];
|
||||
|
||||
/** The slice of a Goal record the resume gate reads. */
|
||||
export interface GoalResumeGateRecord {
|
||||
status: GoalStatus;
|
||||
lastReason?: string | undefined;
|
||||
limitKind?: GoalLimitKind | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stopped Goal was stopped by one of the evidence bounds.
|
||||
*
|
||||
* Mirrors core's private `isEvidenceLimited` (`goal-reducer.ts`): `limitKind`
|
||||
* is the field of record, and the `lastReason` comparison behind it reads
|
||||
* Goals persisted before `limitKind` existed, where the sentinel prose was the
|
||||
* only marker a transition could key off.
|
||||
*/
|
||||
export function isGoalEvidenceLimited(goal: GoalResumeGateRecord): boolean {
|
||||
return (
|
||||
goal.limitKind !== undefined ||
|
||||
(goal.lastReason !== undefined &&
|
||||
GOAL_EVIDENCE_LIMIT_REASONS.includes(goal.lastReason))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `resume` is a transition the reducer will accept for this Goal.
|
||||
*
|
||||
* Stated as the reducer states it (`reduceGoalControl`), not as an
|
||||
* approximation of it: every Goal resume affordance in the client goes through
|
||||
* here, so an offered Resume button cannot dead-end in an invalid-transition
|
||||
* 409 that the UI itself promised would not happen.
|
||||
*/
|
||||
export function canResumeGoal(goal: GoalResumeGateRecord): boolean {
|
||||
if (goal.status === 'complete' || goal.status === 'active') return false;
|
||||
if (goal.status === 'usage_limited' && isGoalEvidenceLimited(goal)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
DaemonTranscriptStore,
|
||||
DaemonUiSessionActions,
|
||||
DaemonUnrecognizedDiagnostic,
|
||||
GoalStateResponse,
|
||||
PromptResult,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
|
|
@ -82,6 +83,8 @@ interface MockSession {
|
|||
setModel: (modelId: string) => Promise<{ modelId: string }>;
|
||||
heartbeat: () => Promise<{ ok: boolean }>;
|
||||
shellCommand: (command: string, signal?: AbortSignal) => Promise<unknown>;
|
||||
goal: () => Promise<GoalStateResponse>;
|
||||
controlGoal: (request: unknown) => Promise<GoalStateResponse>;
|
||||
context: () => Promise<{
|
||||
v: 1;
|
||||
sessionId: string;
|
||||
|
|
@ -1454,6 +1457,21 @@ describe('DaemonSessionProvider', () => {
|
|||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: '' },
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-sync',
|
||||
revision: 1,
|
||||
objective: 'ship goal sync',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1234,
|
||||
updatedAt: 1234,
|
||||
},
|
||||
},
|
||||
goalStatus: {
|
||||
kind: 'set',
|
||||
condition: 'ship goal sync',
|
||||
|
|
@ -1485,9 +1503,11 @@ describe('DaemonSessionProvider', () => {
|
|||
});
|
||||
sdkMocks.sessions.push(session);
|
||||
let blocks: readonly DaemonTranscriptBlock[] = [];
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
|
||||
function Harness() {
|
||||
blocks = useDaemonTranscriptBlocks();
|
||||
connection = useDaemonConnection();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1521,6 +1541,290 @@ describe('DaemonSessionProvider', () => {
|
|||
},
|
||||
}),
|
||||
]);
|
||||
expect(connection?.goalState).toMatchObject({
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-sync',
|
||||
revision: 1,
|
||||
objective: 'ship goal sync',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite a streamed goal update with the session-load snapshot', async () => {
|
||||
const pendingGoal = createDeferred<GoalStateResponse>();
|
||||
const streamedGoal: GoalStateResponse['snapshot'] = {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-sync',
|
||||
revision: 2,
|
||||
objective: 'newer objective',
|
||||
status: 'paused',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 1,
|
||||
activeTimeMs: 10,
|
||||
createdAt: 1234,
|
||||
updatedAt: 2345,
|
||||
},
|
||||
};
|
||||
sdkMocks.sessions.push(
|
||||
createMockSession({
|
||||
goal: vi.fn(() => pendingGoal.promise),
|
||||
controlGoal: vi.fn(async () => ({ snapshot: streamedGoal })),
|
||||
}),
|
||||
);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
let actions: DaemonSessionActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
actions = useDaemonActions();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
autoReconnect: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await actions?.controlGoal({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-sync',
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
expect(connection?.goalState).toBe(streamedGoal);
|
||||
|
||||
pendingGoal.resolve({
|
||||
snapshot: {
|
||||
...streamedGoal,
|
||||
activity: 'running',
|
||||
goal: { ...streamedGoal.goal!, revision: 1, status: 'active' },
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(connection?.goalState).toBe(streamedGoal);
|
||||
});
|
||||
|
||||
it('applies a cleared session-load Goal snapshot over a stale active one', async () => {
|
||||
// The load-time `goal()` is issued before the state below is installed, so
|
||||
// a reference-equality guard would discard its authoritative cleared
|
||||
// snapshot — and install no tombstone, leaving the stale goal to come back.
|
||||
const pendingGoal = createDeferred<GoalStateResponse>();
|
||||
const staleActive: GoalStateResponse['snapshot'] = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-stale',
|
||||
revision: 1,
|
||||
objective: 'stale objective',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 1,
|
||||
activeTimeMs: 10,
|
||||
createdAt: 1234,
|
||||
updatedAt: 2345,
|
||||
},
|
||||
};
|
||||
sdkMocks.sessions.push(
|
||||
createMockSession({
|
||||
goal: vi.fn(() => pendingGoal.promise),
|
||||
controlGoal: vi.fn(async () => ({ snapshot: staleActive })),
|
||||
}),
|
||||
);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
let actions: DaemonSessionActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
actions = useDaemonActions();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
autoReconnect: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await actions?.controlGoal({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-stale',
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
expect(connection?.goalState).toBe(staleActive);
|
||||
|
||||
pendingGoal.resolve({
|
||||
snapshot: {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
clearedGoal: { goalId: 'goal-stale', revision: 3, updatedAt: 4567 },
|
||||
},
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(connection?.goalState?.goal).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let a stale bare-null session-load Goal read wipe a Goal created meanwhile', async () => {
|
||||
// Mirrors actions.test.ts's `getGoal` case for the OTHER `goal()` reader.
|
||||
// The load issues its read while the session is goal-less, so the response
|
||||
// carries no `clearedGoal` tombstone; a goal created inside the load window
|
||||
// (Web Shell allocates the session, then creates the goal on it) would
|
||||
// otherwise be accepted as the clear target — wiping it AND tombstoning its
|
||||
// identity, after which its own frames at the same revision are rejected as
|
||||
// superseded and the composer stops holding prompts for the Goal queue.
|
||||
const pendingGoal = createDeferred<GoalStateResponse>();
|
||||
const created: GoalStateResponse['snapshot'] = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-new',
|
||||
revision: 1,
|
||||
objective: 'ship safely',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1234,
|
||||
updatedAt: 2345,
|
||||
},
|
||||
};
|
||||
sdkMocks.sessions.push(
|
||||
createMockSession({ goal: vi.fn(() => pendingGoal.promise) }),
|
||||
);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
let actions: DaemonSessionActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
actions = useDaemonActions();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
autoReconnect: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const sessionId = connection?.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
await act(async () => {
|
||||
actions?.applyGoalSnapshot(sessionId!, created);
|
||||
});
|
||||
expect(connection?.goalState).toBe(created);
|
||||
|
||||
pendingGoal.resolve({ snapshot: { v: 2, goal: null, activity: 'idle' } });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(connection?.goalState).toBe(created);
|
||||
});
|
||||
|
||||
it('keeps a known Goal state when the session-load Goal request fails', async () => {
|
||||
// Only the fresh-connection branch synthesizes an idle snapshot; once a
|
||||
// state is known, a transient `goal()` failure must not replace a live goal
|
||||
// with idle — that would drop the strip and the composer gating while the
|
||||
// daemon still considers the goal live.
|
||||
const pendingGoal = createDeferred<GoalStateResponse>();
|
||||
const knownGoal: GoalStateResponse['snapshot'] = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-known',
|
||||
revision: 2,
|
||||
objective: 'keep me',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 1,
|
||||
activeTimeMs: 10,
|
||||
createdAt: 1234,
|
||||
updatedAt: 2345,
|
||||
},
|
||||
};
|
||||
sdkMocks.sessions.push(
|
||||
createMockSession({
|
||||
goal: vi.fn(() => pendingGoal.promise),
|
||||
controlGoal: vi.fn(async () => ({ snapshot: knownGoal })),
|
||||
}),
|
||||
);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
let actions: DaemonSessionActions | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
actions = useDaemonActions();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
autoReconnect: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await actions?.controlGoal({
|
||||
action: 'resume',
|
||||
expectedGoalId: 'goal-known',
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
expect(connection?.goalState).toBe(knownGoal);
|
||||
|
||||
pendingGoal.reject(new Error('goal route unavailable'));
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(connection?.goalState).toBe(knownGoal);
|
||||
});
|
||||
|
||||
it('releases unknown Goal state when the session-load Goal request fails', async () => {
|
||||
sdkMocks.sessions.push(
|
||||
createMockSession({
|
||||
goal: vi.fn().mockRejectedValue(new Error('goal route unavailable')),
|
||||
}),
|
||||
);
|
||||
let connection: DaemonConnectionState | undefined;
|
||||
|
||||
function Harness() {
|
||||
connection = useDaemonConnection();
|
||||
return null;
|
||||
}
|
||||
|
||||
await renderWithProvider(<Harness />, {
|
||||
autoConnect: true,
|
||||
autoReconnect: false,
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(connection?.goalState).toEqual({
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes mid_turn_message_injected frames to the sidechannel and transcript', async () => {
|
||||
|
|
@ -11587,6 +11891,33 @@ describe('DaemonSessionProvider', () => {
|
|||
yield {
|
||||
id: 1,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-before-close',
|
||||
revision: 1,
|
||||
objective: 'must disappear',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'goal-record' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
id: 2,
|
||||
v: 1,
|
||||
type: 'session_closed',
|
||||
data: { reason: 'client_close' },
|
||||
};
|
||||
|
|
@ -11634,6 +11965,7 @@ describe('DaemonSessionProvider', () => {
|
|||
expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1);
|
||||
expect(connection?.status).toBe('disconnected');
|
||||
expect(connection?.sessionId).toBeUndefined();
|
||||
expect(connection?.goalState).toBeUndefined();
|
||||
// Teardown set promptStatus to 'idle' — without the explicit
|
||||
// setPromptStatus('idle') in the userDeletedSession block, this
|
||||
// would remain 'waiting' (sendPrompt's own handler is blocked
|
||||
|
|
@ -12927,6 +13259,16 @@ function createMockSession(opts: Partial<MockSession> = {}): MockSession {
|
|||
})),
|
||||
heartbeat: opts.heartbeat ?? vi.fn(async () => ({ ok: true })),
|
||||
shellCommand: opts.shellCommand ?? vi.fn(async () => undefined),
|
||||
goal:
|
||||
opts.goal ??
|
||||
vi.fn(async () => ({
|
||||
snapshot: { v: 2 as const, goal: null, activity: 'idle' as const },
|
||||
})),
|
||||
controlGoal:
|
||||
opts.controlGoal ??
|
||||
vi.fn(async () => ({
|
||||
snapshot: { v: 2 as const, goal: null, activity: 'idle' as const },
|
||||
})),
|
||||
context:
|
||||
opts.context ??
|
||||
vi.fn(async () => ({
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
type DaemonTurnCompleteData,
|
||||
type DaemonUiEvent,
|
||||
type DaemonUnrecognizedDiagnostic,
|
||||
type GoalSnapshotV2,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
createDaemonSessionActions,
|
||||
|
|
@ -66,6 +67,7 @@ import {
|
|||
mapSessionContextReasoning,
|
||||
mapSupportedCommands,
|
||||
mapWorkspaceSkills,
|
||||
selectGoalStateFromRead,
|
||||
updateConnectionFromDaemonEvent,
|
||||
} from './mappers.js';
|
||||
import {
|
||||
|
|
@ -1818,6 +1820,10 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
: current.sessionId === activeSession.sessionId
|
||||
? (current.tokenCount ?? 0)
|
||||
: 0,
|
||||
goalState:
|
||||
current.sessionId === activeSession.sessionId
|
||||
? current.goalState
|
||||
: undefined,
|
||||
loadingTranscript: undefined,
|
||||
catchingUp: replayInjected
|
||||
? current.catchingUp
|
||||
|
|
@ -1848,24 +1854,34 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
connectionRef.current.context !== undefined);
|
||||
const configGeneration =
|
||||
sessionConfigGenerationRef.current.get(activeSession) ?? 0;
|
||||
const goalStateAtLoadStart =
|
||||
connectionRef.current.sessionId === activeSession.sessionId
|
||||
? connectionRef.current.goalState
|
||||
: undefined;
|
||||
const gitPromise = skipMetadataRefreshThisIteration
|
||||
? Promise.resolve({ branch: connectionRef.current.gitBranch })
|
||||
: activeSession.workspaceCwd
|
||||
? client.workspaceByCwd(activeSession.workspaceCwd).workspaceGit()
|
||||
: client.workspaceGit();
|
||||
const [providerResult, commandResult, contextResult, gitResult] =
|
||||
await Promise.allSettled([
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: client.workspaceProviders(),
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: activeSession.supportedCommands(),
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: activeSession.context(),
|
||||
gitPromise,
|
||||
]);
|
||||
const [
|
||||
providerResult,
|
||||
commandResult,
|
||||
contextResult,
|
||||
gitResult,
|
||||
goalResult,
|
||||
] = await Promise.allSettled([
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: client.workspaceProviders(),
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: activeSession.supportedCommands(),
|
||||
canReuseSessionMetadata
|
||||
? Promise.resolve(undefined)
|
||||
: activeSession.context(),
|
||||
gitPromise,
|
||||
activeSession.goal(),
|
||||
]);
|
||||
if (
|
||||
disposed ||
|
||||
abort.signal.aborted ||
|
||||
|
|
@ -1889,6 +1905,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
gitResult?.status === 'fulfilled'
|
||||
? (gitResult.value.branch ?? undefined)
|
||||
: undefined;
|
||||
const goalState =
|
||||
goalResult.status === 'fulfilled'
|
||||
? goalResult.value.snapshot
|
||||
: undefined;
|
||||
// A failed goal fetch on a session with no known state still needs a
|
||||
// snapshot so consumers stop treating the state as hydrating; it must
|
||||
// never reconcile against a state a frame installed meanwhile.
|
||||
const goalStateFallback =
|
||||
goalResult.status === 'fulfilled' ||
|
||||
goalStateAtLoadStart !== undefined
|
||||
? undefined
|
||||
: ({
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
} satisfies GoalSnapshotV2);
|
||||
const loadWarningTexts = [
|
||||
providerResult?.status === 'rejected'
|
||||
? loadWarningsRef.current?.models
|
||||
|
|
@ -1978,6 +2010,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
context: configSnapshotCurrent
|
||||
? (context ?? current.context)
|
||||
: current.context,
|
||||
// Reconcile rather than reference-compare: the load response and
|
||||
// any frame that arrived during the load window share a revision
|
||||
// domain, and routing through `selectGoalState` is what registers
|
||||
// the cleared-goal tombstone that keeps a later stale frame from
|
||||
// resurrecting a cleared goal. The read is stamped with the goal
|
||||
// observed when it was issued (`goalStateAtLoadStart`) — a create
|
||||
// that lands inside the load window must not be wiped, and
|
||||
// tombstoned, by a bare-null answer that predates it.
|
||||
goalState: goalState
|
||||
? selectGoalStateFromRead(
|
||||
current.goalState,
|
||||
goalState,
|
||||
goalStateAtLoadStart?.goal?.goalId,
|
||||
)
|
||||
: (current.goalState ?? goalStateFallback),
|
||||
gitBranch:
|
||||
gitResult.status === 'fulfilled'
|
||||
? gitBranch
|
||||
|
|
@ -2463,6 +2510,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
...current,
|
||||
status: 'disconnected',
|
||||
sessionId: undefined,
|
||||
goalState: undefined,
|
||||
error: undefined,
|
||||
errorStatus: undefined,
|
||||
missingSession: false,
|
||||
|
|
@ -2608,6 +2656,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
...current,
|
||||
status: 'error',
|
||||
sessionId: undefined,
|
||||
goalState: undefined,
|
||||
error: message,
|
||||
errorStatus: resolveConnectionErrorStatus(
|
||||
errorStatus,
|
||||
|
|
@ -2633,6 +2682,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
...current,
|
||||
status: 'disconnected',
|
||||
sessionId: undefined,
|
||||
goalState: undefined,
|
||||
error: message,
|
||||
errorStatus: resolveConnectionErrorStatus(
|
||||
errorStatus,
|
||||
|
|
@ -2961,6 +3011,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
|
|||
...(authFailure || missingSession
|
||||
? {
|
||||
sessionId: undefined,
|
||||
goalState: undefined,
|
||||
loadingTranscript: undefined,
|
||||
catchingUp: undefined,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
DaemonPendingPromptLimitError,
|
||||
type DaemonCapabilities,
|
||||
type DaemonSessionClient,
|
||||
type GoalSnapshotV2,
|
||||
} from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
createDaemonSessionActions,
|
||||
|
|
@ -27,6 +28,7 @@ describe('getConnectionAfterSessionClear', () => {
|
|||
clientId: 'client-a',
|
||||
displayName: 'Session A',
|
||||
tokenCount: 42,
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
commands: [commandInfo('old-command')],
|
||||
skills: ['old-skill'],
|
||||
supportedCommands: supportedCommandsStatus('session-a'),
|
||||
|
|
@ -53,6 +55,7 @@ describe('getConnectionAfterSessionClear', () => {
|
|||
expect(next).not.toHaveProperty('clientId');
|
||||
expect(next).not.toHaveProperty('displayName');
|
||||
expect(next).not.toHaveProperty('tokenCount');
|
||||
expect(next).not.toHaveProperty('goalState');
|
||||
expect(next).not.toHaveProperty('supportedCommands');
|
||||
expect(next).not.toHaveProperty('context');
|
||||
// Workspace-scoped slash commands and skills survive a clear so skill-backed
|
||||
|
|
@ -169,6 +172,20 @@ describe('resolveSessionRestoreTimeouts', () => {
|
|||
});
|
||||
|
||||
describe('createDaemonSessionActions', () => {
|
||||
it('clears the previous Goal before starting a fresh session', async () => {
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
connection: {
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
},
|
||||
});
|
||||
|
||||
await actions.newSession();
|
||||
|
||||
expect(getConnection().goalState).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a concurrent source-bound branch request', async () => {
|
||||
const source = createMockSession('session-a', 'client-a');
|
||||
const first = createDeferred<{
|
||||
|
|
@ -399,7 +416,11 @@ describe('createDaemonSessionActions', () => {
|
|||
const existingSession = createMockSession('session-a');
|
||||
const { actions, getConnection, pendingSessionLoadRef, sessionRef } =
|
||||
createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-a' },
|
||||
connection: {
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
},
|
||||
session: existingSession,
|
||||
});
|
||||
|
||||
|
|
@ -419,6 +440,7 @@ describe('createDaemonSessionActions', () => {
|
|||
sessionId: 'session-b',
|
||||
requestTimeoutMs: 70_000,
|
||||
});
|
||||
expect(getConnection().goalState).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries the daemon-advertised restore budget into the load request', async () => {
|
||||
|
|
@ -1038,6 +1060,189 @@ describe('createDaemonSessionActions', () => {
|
|||
expect(session.submitPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads and controls the authoritative Goal through the session client', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const snapshot = {
|
||||
v: 2 as const,
|
||||
activity: 'idle' as const,
|
||||
goal: null,
|
||||
};
|
||||
session.goal.mockResolvedValue({ snapshot });
|
||||
session.controlGoal.mockResolvedValue({ snapshot });
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-a' },
|
||||
session,
|
||||
});
|
||||
const request = { action: 'create' as const, objective: 'ship safely' };
|
||||
|
||||
await expect(actions.getGoal()).resolves.toEqual({ snapshot });
|
||||
await expect(actions.controlGoal(request)).resolves.toEqual({ snapshot });
|
||||
|
||||
expect(session.goal).toHaveBeenCalledOnce();
|
||||
expect(session.controlGoal).toHaveBeenCalledWith(request);
|
||||
expect(getConnection().goalState).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('does not let delayed Goal responses regress the current revision', async () => {
|
||||
const session = createMockSession('session-a');
|
||||
const current = {
|
||||
v: 2 as const,
|
||||
activity: 'idle' as const,
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'newer objective',
|
||||
status: 'paused' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const stale = {
|
||||
...current,
|
||||
activity: 'running' as const,
|
||||
goal: { ...current.goal, revision: 6, status: 'active' as const },
|
||||
};
|
||||
session.goal.mockResolvedValue({ snapshot: stale });
|
||||
session.controlGoal.mockResolvedValue({ snapshot: stale });
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
connection: {
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
goalState: current,
|
||||
},
|
||||
session,
|
||||
});
|
||||
|
||||
await actions.getGoal();
|
||||
await actions.controlGoal({
|
||||
action: 'pause',
|
||||
expectedGoalId: 'goal-1',
|
||||
expectedRevision: 7,
|
||||
});
|
||||
|
||||
expect(getConnection().goalState).toBe(current);
|
||||
});
|
||||
|
||||
it('installs an out-of-band Goal snapshot for the attached session only', () => {
|
||||
const session = createMockSession('session-a');
|
||||
const active: GoalSnapshotV2 = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 3,
|
||||
objective: 'ship safely',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
},
|
||||
};
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-a' },
|
||||
session,
|
||||
});
|
||||
|
||||
actions.applyGoalSnapshot('session-b', active);
|
||||
expect(getConnection().goalState).toBeUndefined();
|
||||
|
||||
actions.applyGoalSnapshot('session-a', active);
|
||||
expect(getConnection().goalState).toBe(active);
|
||||
|
||||
// Reconciled like any other snapshot, so a stale one cannot regress it.
|
||||
actions.applyGoalSnapshot('session-a', {
|
||||
...active,
|
||||
goal: { ...active.goal!, revision: 2 },
|
||||
});
|
||||
expect(getConnection().goalState).toBe(active);
|
||||
});
|
||||
|
||||
it('does not let a stale bare-null Goal read wipe a Goal created meanwhile', async () => {
|
||||
// The daemon answered the read while the session was goal-less, so the
|
||||
// response carries no `clearedGoal` tombstone. Reconciling it against the
|
||||
// goal created while it was in flight would clear that goal outright.
|
||||
const session = createMockSession('session-a');
|
||||
let resolveRead:
|
||||
| ((value: { snapshot: GoalSnapshotV2 }) => void)
|
||||
| undefined;
|
||||
session.goal.mockReturnValue(
|
||||
new Promise<{ snapshot: GoalSnapshotV2 }>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
}),
|
||||
);
|
||||
const { actions, getConnection, replaceConnection } = createActionsHarness({
|
||||
connection: { status: 'connected', sessionId: 'session-a' },
|
||||
session,
|
||||
});
|
||||
|
||||
const read = actions.getGoal();
|
||||
const created: GoalSnapshotV2 = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-new',
|
||||
revision: 1,
|
||||
objective: 'ship safely',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
},
|
||||
};
|
||||
replaceConnection({
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
goalState: created,
|
||||
});
|
||||
resolveRead?.({ snapshot: { v: 2, goal: null, activity: 'idle' } });
|
||||
await read;
|
||||
|
||||
expect(getConnection().goalState).toBe(created);
|
||||
});
|
||||
|
||||
it('applies a bare-null Goal read to the Goal it observed', async () => {
|
||||
// Same shape, but nothing changed while the read was in flight: an older
|
||||
// daemon that clears without a tombstone must still clear the UI.
|
||||
const session = createMockSession('session-a');
|
||||
const active: GoalSnapshotV2 = {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'ship safely',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
session.goal.mockResolvedValue({
|
||||
snapshot: { v: 2, goal: null, activity: 'idle' },
|
||||
});
|
||||
const { actions, getConnection } = createActionsHarness({
|
||||
connection: {
|
||||
status: 'connected',
|
||||
sessionId: 'session-a',
|
||||
goalState: active,
|
||||
},
|
||||
session,
|
||||
});
|
||||
|
||||
await actions.getGoal();
|
||||
|
||||
expect(getConnection().goalState?.goal).toBeNull();
|
||||
});
|
||||
|
||||
it('uploads prompt images and submits attachment references instead of base64', async () => {
|
||||
const onAdmissionStarted = vi.fn();
|
||||
const session = createMockSession('session-a');
|
||||
|
|
@ -2348,6 +2553,8 @@ function createMockSession(
|
|||
submitPrompt: vi.fn(async () => ({ promptId: 'prompt-1' })),
|
||||
supportedCommands: vi.fn(async () => supportedCommandsStatus(sessionId)),
|
||||
tasks: vi.fn(async () => ({ v: 1 as const, sessionId, tasks: [] })),
|
||||
goal: vi.fn(),
|
||||
controlGoal: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import type {
|
|||
DaemonSessionArtifactsEnvelope,
|
||||
DaemonTranscriptStore,
|
||||
DaemonCapabilities,
|
||||
GoalControlRequest,
|
||||
GoalSnapshotV2,
|
||||
DaemonBranchSessionResult,
|
||||
DaemonBranchedSession,
|
||||
DaemonSessionAttachmentReference,
|
||||
|
|
@ -42,6 +44,8 @@ import {
|
|||
mapReasoningControls,
|
||||
mapSessionContextReasoning,
|
||||
mapSupportedCommands,
|
||||
selectGoalState,
|
||||
selectGoalStateFromRead,
|
||||
} from './mappers.js';
|
||||
import {
|
||||
attachmentUriForName,
|
||||
|
|
@ -200,6 +204,7 @@ export function getConnectionAfterSessionClear(
|
|||
delete next.displayName;
|
||||
delete next.tokenUsage;
|
||||
delete next.tokenCount;
|
||||
delete next.goalState;
|
||||
// Drop the session-scoped raw snapshots (both carry the cleared
|
||||
// sessionId), which also makes the effect's canReuseSessionMetadata
|
||||
// check refetch fresh data for the next session.
|
||||
|
|
@ -605,6 +610,7 @@ export function createDaemonSessionActions({
|
|||
workspaceCwd: targetWorkspaceCwd,
|
||||
clientId: undefined,
|
||||
displayName: undefined,
|
||||
goalState: undefined,
|
||||
error: undefined,
|
||||
errorStatus: undefined,
|
||||
missingSession: false,
|
||||
|
|
@ -1364,6 +1370,7 @@ export function createDaemonSessionActions({
|
|||
...current,
|
||||
status: 'connected',
|
||||
sessionId: nextSession.sessionId,
|
||||
goalState: undefined,
|
||||
...(nextSession.clientId ? { clientId: nextSession.clientId } : {}),
|
||||
workspaceCwd: nextSession.workspaceCwd,
|
||||
error: undefined,
|
||||
|
|
@ -1418,6 +1425,7 @@ export function createDaemonSessionActions({
|
|||
clearActiveSessionState();
|
||||
setConnection((current) => ({
|
||||
...current,
|
||||
goalState: undefined,
|
||||
missingSession: false,
|
||||
error: undefined,
|
||||
errorStatus: undefined,
|
||||
|
|
@ -1985,6 +1993,90 @@ export function createDaemonSessionActions({
|
|||
}
|
||||
},
|
||||
|
||||
async getGoal() {
|
||||
const session = requireSessionForAction(
|
||||
addNotice,
|
||||
sessionRef.current,
|
||||
'Load goal failed',
|
||||
'load_goal',
|
||||
);
|
||||
// A read the daemon answered while goal-less can resolve after a
|
||||
// concurrent create; its bare-null snapshot carries no `clearedGoal`
|
||||
// tombstone, so reconciling it would wipe the new goal. Stamp the read
|
||||
// with the goal observed at issue time: a bare-null response may only
|
||||
// clear the goal it actually observed.
|
||||
const observedGoalId = getConnection().goalState?.goal?.goalId;
|
||||
try {
|
||||
const response = await withActionTimeout(
|
||||
session.goal(),
|
||||
'Load goal timed out',
|
||||
);
|
||||
setConnection((current) => {
|
||||
if (current.sessionId !== session.sessionId) return current;
|
||||
const goalState = selectGoalStateFromRead(
|
||||
current.goalState,
|
||||
response.snapshot,
|
||||
observedGoalId,
|
||||
);
|
||||
if (goalState === current.goalState) return current;
|
||||
return { ...current, goalState };
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw dispatchActionError(
|
||||
addNotice,
|
||||
'Load goal failed',
|
||||
error,
|
||||
'load_goal',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
applyGoalSnapshot(sessionId: string, snapshot: GoalSnapshotV2) {
|
||||
setConnection((current) =>
|
||||
current.sessionId === sessionId
|
||||
? {
|
||||
...current,
|
||||
goalState: selectGoalState(current.goalState, snapshot),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
},
|
||||
|
||||
async controlGoal(request: GoalControlRequest) {
|
||||
const session = requireSessionForAction(
|
||||
addNotice,
|
||||
sessionRef.current,
|
||||
'Control goal failed',
|
||||
'control_goal',
|
||||
);
|
||||
try {
|
||||
const response = await withActionTimeout(
|
||||
session.controlGoal(request),
|
||||
'Control goal timed out',
|
||||
);
|
||||
setConnection((current) =>
|
||||
current.sessionId === session.sessionId
|
||||
? {
|
||||
...current,
|
||||
goalState: selectGoalState(
|
||||
current.goalState,
|
||||
response.snapshot,
|
||||
),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw dispatchActionError(
|
||||
addNotice,
|
||||
'Control goal failed',
|
||||
error,
|
||||
'control_goal',
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async getStats() {
|
||||
const session = requireSessionForAction(
|
||||
addNotice,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
getReplayTokenUsage,
|
||||
mapReasoningControls,
|
||||
mapWorkspaceSkills,
|
||||
selectGoalState,
|
||||
selectGoalStateFromRead,
|
||||
updateConnectionFromDaemonEvent,
|
||||
} from './mappers.js';
|
||||
import type { DaemonConnectionState } from './types.js';
|
||||
|
|
@ -272,6 +274,486 @@ describe('mapWorkspaceSkills', () => {
|
|||
});
|
||||
|
||||
describe('updateConnectionFromDaemonEvent', () => {
|
||||
it('updates and clears the authoritative Goal snapshot', () => {
|
||||
const goal = {
|
||||
goalId: 'goal-1',
|
||||
revision: 2,
|
||||
objective: 'ship safely',
|
||||
status: 'active',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
};
|
||||
const active = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace' },
|
||||
{
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: { goalState: { v: 2, goal, activity: 'running' } },
|
||||
},
|
||||
},
|
||||
} as DaemonEvent,
|
||||
);
|
||||
expect(active.goalState).toEqual({
|
||||
v: 2,
|
||||
goal,
|
||||
activity: 'running',
|
||||
});
|
||||
|
||||
const cleared = applyEvent(active, {
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: { goalState: { v: 2, goal: null, activity: 'idle' } },
|
||||
},
|
||||
},
|
||||
} as DaemonEvent);
|
||||
expect(cleared.goalState).toEqual({
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not regress the same Goal to an older revision', () => {
|
||||
const goal = {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'newer objective',
|
||||
status: 'paused' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
};
|
||||
const current: DaemonConnectionState = {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
goalState: { v: 2, goal, activity: 'idle' },
|
||||
};
|
||||
const next = applyEvent(current, {
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
activity: 'running',
|
||||
goal: { ...goal, revision: 6, status: 'active', updatedAt: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DaemonEvent);
|
||||
|
||||
expect(next.goalState).toBe(current.goalState);
|
||||
});
|
||||
|
||||
it('orders equal-revision Goal snapshots by updatedAt', () => {
|
||||
const current = {
|
||||
v: 2 as const,
|
||||
activity: 'idle' as const,
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'ship safely',
|
||||
status: 'paused' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const stale = {
|
||||
...current,
|
||||
activity: 'running' as const,
|
||||
goal: { ...current.goal, status: 'active' as const, updatedAt: 20 },
|
||||
};
|
||||
|
||||
expect(selectGoalState(current, stale)).toBe(current);
|
||||
});
|
||||
|
||||
it('holds a bare-null Goal read back from the Goal it never observed', () => {
|
||||
// The stamp is what separates "the daemon cleared the goal I read" from
|
||||
// "the daemon answered before the goal I now hold existed".
|
||||
const created = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-new',
|
||||
revision: 1,
|
||||
objective: 'ship safely',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 10,
|
||||
updatedAt: 20,
|
||||
},
|
||||
};
|
||||
const bareNull = { v: 2 as const, goal: null, activity: 'idle' as const };
|
||||
|
||||
// Issued while goal-less: cannot clear the goal that landed meanwhile...
|
||||
expect(selectGoalStateFromRead(created, bareNull, undefined)).toBe(created);
|
||||
// ...and leaves no tombstone, so the goal's own later frames still apply.
|
||||
expect(selectGoalState(created, { ...created, activity: 'idle' })).toEqual({
|
||||
...created,
|
||||
activity: 'idle',
|
||||
});
|
||||
// Issued while holding this goal: the clear is authoritative.
|
||||
expect(
|
||||
selectGoalStateFromRead(created, bareNull, 'goal-new').goal,
|
||||
).toBeNull();
|
||||
// A tombstoned clear is authoritative whatever the read observed.
|
||||
expect(
|
||||
selectGoalStateFromRead(
|
||||
created,
|
||||
{
|
||||
...bareNull,
|
||||
clearedGoal: { goalId: 'goal-new', revision: 2, updatedAt: 30 },
|
||||
},
|
||||
undefined,
|
||||
).goal,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resurrect a cleared Goal from a stale snapshot', () => {
|
||||
const active = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'ship safely',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const cleared = selectGoalState(active, {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
});
|
||||
|
||||
expect(selectGoalState(cleared, active)).toBe(cleared);
|
||||
});
|
||||
|
||||
it('does not resurrect a replaced Goal from a stale snapshot', () => {
|
||||
// A replacement mints a new goalId and `goal-runtime` sends no
|
||||
// `clearedGoal` tombstone for it, so the replaced goal's ordering identity
|
||||
// is the only thing that can reject its late frames.
|
||||
const replaced = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-a',
|
||||
revision: 4,
|
||||
objective: 'first objective',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-a' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 2_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const replacement = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-b',
|
||||
revision: 1,
|
||||
objective: 'replacement objective',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-b' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 40,
|
||||
updatedAt: 50,
|
||||
},
|
||||
};
|
||||
|
||||
const current = selectGoalState(replaced, replacement);
|
||||
expect(current).toBe(replacement);
|
||||
expect(selectGoalState(current, replaced)).toBe(current);
|
||||
});
|
||||
|
||||
it('keeps rejecting a replaced Goal across further replacements', () => {
|
||||
const first = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-a',
|
||||
revision: 4,
|
||||
objective: 'first objective',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-a' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 2_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
goal: {
|
||||
...first.goal,
|
||||
goalId: 'goal-b',
|
||||
revision: 1,
|
||||
objective: 'second objective',
|
||||
updatedAt: 50,
|
||||
},
|
||||
};
|
||||
const third = {
|
||||
...first,
|
||||
goal: {
|
||||
...first.goal,
|
||||
goalId: 'goal-c',
|
||||
revision: 1,
|
||||
objective: 'third objective',
|
||||
updatedAt: 70,
|
||||
},
|
||||
};
|
||||
|
||||
const current = selectGoalState(selectGoalState(first, second), third);
|
||||
expect(current).toBe(third);
|
||||
expect(selectGoalState(current, first)).toBe(current);
|
||||
expect(selectGoalState(current, second)).toBe(current);
|
||||
});
|
||||
|
||||
it('does not resurrect a cleared Goal after a new Goal starts', () => {
|
||||
const active = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 7,
|
||||
objective: 'ship safely',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 3,
|
||||
activeTimeMs: 4_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const cleared = selectGoalState(active, {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
});
|
||||
const next = selectGoalState(cleared, {
|
||||
...active,
|
||||
goal: {
|
||||
...active.goal,
|
||||
goalId: 'goal-2',
|
||||
revision: 1,
|
||||
objective: 'next objective',
|
||||
updatedAt: 60,
|
||||
},
|
||||
});
|
||||
|
||||
// The cleared goal's identity survives the new goal, so a frame the daemon
|
||||
// emitted before the clear cannot come back over it.
|
||||
expect(selectGoalState(next, active)).toBe(next);
|
||||
});
|
||||
|
||||
it('accepts a superseded Goal again when the daemon advances its revision', () => {
|
||||
const first = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-a',
|
||||
revision: 4,
|
||||
objective: 'first objective',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-a' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 2_000,
|
||||
createdAt: 10,
|
||||
updatedAt: 30,
|
||||
},
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
goal: {
|
||||
...first.goal,
|
||||
goalId: 'goal-b',
|
||||
revision: 1,
|
||||
objective: 'second objective',
|
||||
updatedAt: 50,
|
||||
},
|
||||
};
|
||||
const revived = {
|
||||
...first,
|
||||
goal: { ...first.goal, revision: 5, updatedAt: 80 },
|
||||
};
|
||||
|
||||
const current = selectGoalState(first, second);
|
||||
expect(selectGoalState(current, revived)).toBe(revived);
|
||||
});
|
||||
|
||||
it('does not apply a delayed clear tombstone to a replacement Goal', () => {
|
||||
const replacement = {
|
||||
v: 2 as const,
|
||||
activity: 'running' as const,
|
||||
goal: {
|
||||
goalId: 'goal-h',
|
||||
revision: 1,
|
||||
objective: 'replacement',
|
||||
status: 'active' as const,
|
||||
evidenceCursor: { recordId: 'record-h' },
|
||||
turnCount: 0,
|
||||
activeTimeMs: 0,
|
||||
createdAt: 40,
|
||||
updatedAt: 50,
|
||||
},
|
||||
};
|
||||
const current: DaemonConnectionState = {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
goalState: replacement,
|
||||
};
|
||||
const next = applyEvent(current, {
|
||||
id: 2,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
goal: null,
|
||||
activity: 'idle',
|
||||
clearedGoal: {
|
||||
goalId: 'goal-g',
|
||||
revision: 4,
|
||||
updatedAt: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DaemonEvent);
|
||||
|
||||
expect(next.goalState).toBe(replacement);
|
||||
});
|
||||
|
||||
it('carries limitKind through from the wire', () => {
|
||||
// The client gates Resume on it: an evidence-limited Goal cannot be
|
||||
// resumed, and dropping the field here leaves the UI offering a control the
|
||||
// daemon always rejects.
|
||||
const next = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace' },
|
||||
{
|
||||
id: 1,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 3,
|
||||
objective: 'ship it',
|
||||
status: 'usage_limited',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 10,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
lastReason: 'evidence catalog exhausted',
|
||||
limitKind: 'evidence_catalog',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DaemonEvent,
|
||||
);
|
||||
|
||||
expect(next.goalState?.goal).toMatchObject({
|
||||
status: 'usage_limited',
|
||||
limitKind: 'evidence_catalog',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops an unknown limitKind rather than passing it through', () => {
|
||||
const next = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace' },
|
||||
{
|
||||
id: 1,
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: {
|
||||
goalState: {
|
||||
v: 2,
|
||||
activity: 'idle',
|
||||
goal: {
|
||||
goalId: 'goal-1',
|
||||
revision: 3,
|
||||
objective: 'ship it',
|
||||
status: 'paused',
|
||||
evidenceCursor: { recordId: 'record-1' },
|
||||
turnCount: 2,
|
||||
activeTimeMs: 10,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
limitKind: 'not-a-kind',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as DaemonEvent,
|
||||
);
|
||||
|
||||
expect(next.goalState?.goal?.limitKind).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores malformed Goal snapshots', () => {
|
||||
const current: DaemonConnectionState = {
|
||||
status: 'connected',
|
||||
workspaceCwd: '/workspace',
|
||||
goalState: { v: 2, goal: null, activity: 'idle' },
|
||||
};
|
||||
const next = applyEvent(current, {
|
||||
v: 1,
|
||||
type: 'session_update',
|
||||
data: {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
_meta: { goalState: { v: 2, goal: {}, activity: 'running' } },
|
||||
},
|
||||
},
|
||||
} as DaemonEvent);
|
||||
|
||||
expect(next.goalState).toBe(current.goalState);
|
||||
});
|
||||
|
||||
it('updates and clears the current git branch', () => {
|
||||
const changed = applyEvent(
|
||||
{ status: 'connected', workspaceCwd: '/workspace', gitBranch: 'main' },
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue