Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
6f2f9d0
feat(web-shell): adopt canonical Goal v3 controls
qqqys Aug 18, 2026
0434f9a
test(serve): count the two Goal routes in the telemetry guards
qqqys Aug 18, 2026
7e234d8
Merge upstream/main into feat/goal-v3-web-shell
qqqys Aug 19, 2026
42926fb
fix(webui): keep the Goal snapshot authoritative across replace, stal…
qqqys Aug 19, 2026
55ead3f
test(webui): pin the known-Goal-state branch of a failed session-load…
qqqys Aug 19, 2026
c58cd62
fix(web-shell): keep Goal controls and drafts owned by the surface th…
qqqys Aug 19, 2026
36c4a14
fix(web-shell): fail Goal gates closed while hydrating, and stop losi…
qqqys Aug 19, 2026
ea98b0b
Merge upstream/main into feat/goal-v3-web-shell
qqqys Aug 19, 2026
5657cbd
fix(acp): publish turn_complete when a Goal turn ends
qqqys Aug 19, 2026
4363bab
fix(web-shell): serialize held-prompt release, drop the dead insert a…
qqqys Aug 19, 2026
b89174e
fix(web-shell): localize Goal command errors and stop Goal surfaces s…
qqqys Aug 19, 2026
ed422f9
refactor(web-shell): retire the dead Goal footer paths and restore th…
qqqys Aug 19, 2026
011d65e
fix(sdk): expose limitKind, drop the unused expected-version type, co…
qqqys Aug 19, 2026
78758d9
test(web-shell): close the Goal coverage gaps the review probes found
qqqys Aug 19, 2026
c75a0ce
fix(web-shell): drop the imports the retired Goal paths stranded
qqqys Aug 19, 2026
c9597f6
fix(acp): surface Goal-turn activity in hasActivePrompt summaries
qqqys Aug 19, 2026
c3b81a6
fix(acp): let a mid-turn insert reach a running Goal turn
qqqys Aug 19, 2026
94ab2ca
fix(webui): stamp the session-load Goal read the way `getGoal` does
qqqys Aug 19, 2026
902504d
fix(web-shell): close two Goal-queue leaks in the composer and queue
qqqys Aug 19, 2026
2f124a4
fix(web-shell): re-check the Goal hold on every release-chain link
qqqys Aug 19, 2026
af13a6b
refactor(web-shell): stamp GoalsDialog's CAS fields through the share…
qqqys Aug 19, 2026
1d2cabe
merge: bring upstream/main into feat/goal-v3-web-shell
qqqys Aug 19, 2026
aa1d913
fix(web-shell): stop offering Goal controls the reducer will reject
qqqys Aug 19, 2026
3d885b1
fix(web-shell): stop Goal controls and the release chain racing each …
qqqys Aug 20, 2026
b74bab2
Merge remote-tracking branch 'upstream/main' into work/9393
qqqys Aug 20, 2026
a6e5ffd
merge(main): resolve conflicts and preserve goal replay
qqqys Aug 20, 2026
c52a223
test(cli): align merged replay expectations
qqqys Aug 20, 2026
03a3f49
Merge upstream/main into feat/goal-v3-web-shell
qqqys Aug 20, 2026
bf66d84
fix(web-shell): keep a prompt typed mid-drain behind the release chain
qqqys Aug 20, 2026
a39b824
Merge branch 'main' into feat/goal-v3-web-shell
qqqys Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 169 additions & 5 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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;
Expand All @@ -30712,6 +30846,7 @@ describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessa
expect.objectContaining({
promptId: admission.messageId,
text: 'leftover',
originatorClientId: session.clientId,
}),
]);
releases[1]!();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading
Loading