Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d184486
feat(managed-agent): Serve durable permission Actions (D6b)
wenshao Sep 30, 2026
373b93a
test(managed-agent): Avoid concurrent Action mock stubbing
wenshao Sep 30, 2026
c11c1bd
chore: Merge main into D6b Actions
wenshao Sep 30, 2026
93329a4
feat(web-shell): show and answer Hosted tool approvals in the Managed…
Sep 30, 2026
badf94c
feat(web-shell): export the pending Action type for custom Managed pr…
Sep 30, 2026
d93a144
Merge branch 'main' into codex/12867-d6b-actions
yiliang114 Sep 30, 2026
57b9960
fix(managed-agent): renumber the Actions migration to V24 and bump th…
Sep 30, 2026
358212a
Merge branch 'main' into codex/12867-d6b-actions
wenshao Sep 30, 2026
551a5b8
Merge branch 'main' into feat/12867-webshell-approvals
yiliang114 Sep 30, 2026
cecf0d8
chore: Merge D6b Actions head into the WebShell approval UI
Sep 30, 2026
cd7d6c6
Merge remote-tracking branch 'origin/feat/12867-webshell-approvals' i…
Sep 30, 2026
89a14fd
fix(web-shell): memoize pending actions to keep the respond callback …
yiliang114 Sep 30, 2026
b784842
style(web-shell): format the Managed approvals page test with Prettier
yiliang114 Sep 30, 2026
4fbe502
fix(web-shell): retry failed approval reads and name them as such
Sep 30, 2026
4dc1472
fix(web-shell): recover failed approval answers and bound expiry reads
yiliang114 Sep 30, 2026
756e8d9
Merge remote-tracking branch 'origin/main' into feat/12867-webshell-a…
Sep 30, 2026
105b272
fix(web-shell): Show available managed approval arguments
yiliang114 Sep 30, 2026
e572cd7
fix(web-shell): keep the approval card when an answer was not applied
Sep 30, 2026
b48fd0b
fix(web-shell): keep Managed approvals stable across reloads and ende…
Oct 1, 2026
5751b86
fix(web-shell): land only the Critical R1-1 fix for Managed approvals
Oct 1, 2026
9522974
Merge remote-tracking branch 'origin/main' into feat/12867-webshell-a…
Oct 1, 2026
24b26f1
chore(web-shell): merge main's durable tool results into Managed appr…
qwen-code-dev-bot Oct 1, 2026
19c606b
fix(web-shell): match Managed approvals to itemId-keyed tool rows
yiliang114 Oct 1, 2026
7e4b802
test(web-shell): pin the page half of the Managed approval reload fix
yiliang114 Oct 1, 2026
4fb5571
Merge branch 'main' into feat/12867-webshell-approvals
yiliang114 Oct 1, 2026
b1a01a0
test(web-shell): spell canCancel in the Managed approvals fixtures
yiliang114 Oct 1, 2026
191bc8f
fix(web-shell): keep the Harness call title on Managed approval cards
yiliang114 Oct 1, 2026
95fbc3c
fix(web-shell): stop Managed approval state outliving its Action
yiliang114 Oct 1, 2026
acfcc1a
fix(web-shell): stop retrying approval reads the service already answ…
yiliang114 Oct 1, 2026
b7ef590
test(web-shell): pin four Managed approval paths that no test observed
yiliang114 Oct 1, 2026
a90b796
fix(web-shell): isolate late Managed approval replies
yiliang114 Oct 1, 2026
9fedb26
fix(web-shell): drop a Managed approval the service reports as ended
Oct 1, 2026
72608f2
fix(web-shell): preserve late-reply isolation for ended approvals
yiliang114 Oct 1, 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
2 changes: 2 additions & 0 deletions packages/web-shell/client/adapters/messageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export interface DaemonMessageToolCall {
backgroundResultPending?: boolean;
status: DaemonMessageToolCallStatus;
parentToolCallId?: string;
/** The producer's own call ID when `callId` is keyed by something else. */
toolCallId?: string;
title?: string;
content?: readonly DaemonMessageToolCallContent[];
rawOutput?: unknown;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,24 +28,33 @@ const mocks = vi.hoisted(() => ({

vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspace: mocks.useWorkspace,
// The shared approval card asks whether a tool is an agent launch.
isAgentTool: () => false,
}));
vi.mock('../MessageList', () => ({
MessageList: ({
messages,
hasOlderHistory,
onLoadOlderHistory,
onToolResultOpen,
pendingApproval,
}: {
messages: unknown[];
hasOlderHistory: boolean;
onLoadOlderHistory: () => Promise<void>;
onToolResultOpen?: (itemId: string) => void;
pendingApproval?: unknown;
}) => (
<>
<button onClick={() => onToolResultOpen?.('item-1')}>
Open tool output
</button>
<pre data-testid="messages">{JSON.stringify(messages)}</pre>
{/* The real MessageList keys its folding off this prop, so the mock has
to expose it for the join between the two to be observed. */}
<pre data-testid="message-list-pending-approval">
{JSON.stringify(pendingApproval ?? null)}
</pre>
{hasOlderHistory && (
<button onClick={() => void onLoadOlderHistory()}>Older history</button>
)}
Expand Down Expand Up @@ -91,6 +100,21 @@ async function flush() {
for (let i = 0; i < 8; i++) await Promise.resolve();
}

const pendingAction = {
actionId: 'tool_approval_1',
sessionId: 's1',
turnId: 'p1',
functionCallId: 'call-1',
toolName: 'write_file',
inputRevision: 1,
policyRevision: 'hosted-tool-approval/1',
expiresAt: Date.now() + 600_000,
options: [
{ id: 'allow', label: 'Allow' },
{ id: 'deny', label: 'Deny' },
],
};

describe('ManagedSessionsPage', () => {
let container: HTMLDivElement;
let root: Root;
Expand Down Expand Up @@ -180,6 +204,314 @@ describe('ManagedSessionsPage', () => {
});
}

it('keeps the shown approval while a reload has not returned the Session yet', async () => {
let hold = false;
let release: (() => void) | undefined;
mocks.client.getSession.mockImplementation(async (id: string) => {
if (hold) await new Promise<void>((resolve) => (release = resolve));
return summary(id, {
phase: 'agent_running',
capabilities: { canSend: false, canCancel: true, actions: true },
});
});
const listPending = vi.fn().mockResolvedValue([pendingAction]);
provider = { ...provider, actions: { listPending, respond: vi.fn() } };

await render('s1');
await act(async () => flush());
expect(
container.querySelector('[data-testid="managed-approval"]'),
).not.toBeNull();

hold = true;
const refresh = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Refresh',
);
await act(async () => {
refresh!.click();
await flush();
});
// The reload has not returned the Session summary, so the capability is
// unknown: the card stays and nothing is read yet.
expect(
container.querySelector('[data-testid="managed-approval"]'),
).not.toBeNull();
expect(listPending).toHaveBeenCalledTimes(1);

hold = false;
await act(async () => {
release?.();
await flush();
});
});

it('shows a pending Hosted approval and answers it with the chosen option', async () => {
mocks.client.getSession.mockImplementation(async (id: string) =>
summary(id, {
phase: 'agent_running',
capabilities: { canSend: false, canCancel: true, actions: true },
}),
);
const action = pendingAction;
const listPending = vi
.fn()
.mockResolvedValueOnce([action])
.mockResolvedValue([]);
const respond = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValue(undefined);
provider = { ...provider, actions: { listPending, respond } };

await render('s1');
await act(async () => flush());

const card = container.querySelector('[data-testid="managed-approval"]');
expect(card).not.toBeNull();
expect(card!.textContent).toContain('Tool arguments are unavailable');
// The caveat sits beside the panel, so the panel has to be told about it:
// the description a screen-reader user hears must reach it.
const dialog = card!.querySelector('[role="alertdialog"]')!;
const caveatId = card!
.querySelector('p[role="status"]')!
.getAttribute('id') as string;
expect(caveatId).toBeTruthy();
expect(dialog.getAttribute('aria-describedby')).toContain(caveatId);
// The transcript row that carries the tool call keeps the approval card
// reachable: MessageList folds turns by this prop.
expect(
container.querySelector('[data-testid="message-list-pending-approval"]')
?.textContent,
).toContain('tool_approval_1');
const allow = Array.from(card!.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Yes, allow once'),
);
expect(allow).toBeDefined();
await act(async () => {
allow!.click();
await flush();
});

expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'Retry the same option',
);
const retry = Array.from(
container.querySelectorAll('[data-testid="managed-approval"] button'),
).find((button) => button.textContent?.includes('Yes, allow once'));
await act(async () => {
(retry as HTMLButtonElement).click();
await flush();
});
expect(respond).toHaveBeenCalledTimes(2);
expect(respond).toHaveBeenCalledWith(action, 'allow', {
clientId: expect.any(String),
idempotencyKey: 'tool_approval_1:allow',
});
expect(
container.querySelector('[data-testid="managed-approval"]'),
).toBeNull();
// The answered Action left, so the warning that described it leaves too.
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(
container.querySelector('[data-testid="message-list-pending-approval"]')
?.textContent,
).toBe('null');
});

it.each([
['write_file', { file_path: 'notes.md', content: 'approval-write-body' }],
[
'edit',
{
file_path: 'notes.md',
old_string: 'approval-old-body',
new_string: 'approval-new-body',
},
],
])(
'shows available %s arguments inside the approval card',
async (toolName, input) => {
mocks.client.getSession.mockResolvedValue(
summary('s1', {
capabilities: { canSend: false, canCancel: false, actions: true },
}),
);
Comment thread
yiliang114 marked this conversation as resolved.
mocks.client.getTranscript.mockResolvedValue({
events: [
{
...event(1, ''),
type: 'tool_requested',
data: { toolCallId: 'call-1', toolName, input },
},
],
lastEventId: 1,
});
provider = {
...provider,
actions: {
listPending: vi
.fn()
.mockResolvedValue([{ ...pendingAction, toolName }]),
respond: vi.fn(),
},
};

await render('s1');
const card = container.querySelector('[data-testid="managed-approval"]')!;
const shownInput = card.querySelector('pre')?.textContent ?? '';
for (const value of Object.values(input)) {
expect(shownInput).toContain(value);
}
expect(card.textContent).not.toContain('Tool arguments are unavailable');
// Without the caveat there is no extra description to point at, and no
// ARIA IDREF is left dangling.
const describedBy =
card
.querySelector('[role="alertdialog"]')!
.getAttribute('aria-describedby') ?? '';
const referenced = describedBy.split(' ').filter(Boolean);
expect(referenced.length).toBeGreaterThan(0);
for (const id of referenced) {
expect(document.getElementById(id)).not.toBeNull();
}
},
);

it('offers a direct retry when pending approvals could not be loaded', async () => {
mocks.client.getSession.mockResolvedValue(
summary('s1', {
capabilities: { canSend: false, canCancel: false, actions: true },
}),
);
const listPending = vi
.fn()
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(503, 'unavailable', 'Busy'),
)
.mockResolvedValue([pendingAction]);
const respond = vi.fn();
provider = { ...provider, actions: { listPending, respond } };
await render('s1');
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'Pending approvals could not be loaded',
);
expect(
container.querySelector('[data-testid="managed-approval"]'),
).toBeNull();
const retry = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Retry loading approvals',
);
expect(retry).toBeDefined();
await act(async () => {
retry!.click();
await flush();
});
expect(listPending).toHaveBeenCalledTimes(2);
expect(
container.querySelector('[data-testid="managed-approval"]'),
).not.toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(respond).not.toHaveBeenCalled();
});

it('names a failed background re-read as a refresh while the loaded card stays', async () => {
mocks.client.getSession.mockResolvedValue(
summary('s1', {
capabilities: { canSend: false, canCancel: false, actions: true },
}),
);
mocks.client.getTranscript.mockResolvedValue({
events: [event(1, 'Persisted answer')],
lastEventId: 1,
});
// The transcript reports an approval change, which re-reads the list. The
// report is held back until the first read has landed, so the re-read is
// what is being observed rather than the initial load.
let report: (() => void) | undefined;
mocks.client.subscribeEvents.mockImplementationOnce(async function* () {
await new Promise<void>((resolve) => (report = resolve));
yield {
...event(2, ''),
type: 'action_updated',
data: { actionId: 'tool_approval_1', state: 'requested' },
};
});
const listPending = vi
.fn()
.mockResolvedValueOnce([pendingAction])
// A Session deleted while the tab is open: the re-read can never succeed.
.mockRejectedValue(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
);
provider = { ...provider, actions: { listPending, respond: vi.fn() } };

await render('s1');
await act(async () => flush());
expect(
container.querySelector('[data-testid="managed-approval"]'),
).not.toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(listPending).toHaveBeenCalledTimes(1);

await act(async () => {
report?.();
await flush();
});
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
// The approvals were loaded and one is on screen; only the refresh failed.
expect(
container.querySelector('[data-testid="managed-approval"]'),
).not.toBeNull();
const alert = container.querySelector('[role="alert"]')?.textContent ?? '';
expect(alert).toContain('Pending approvals could not be refreshed');
expect(alert).not.toContain('could not be loaded');
});

it('explains that a reader cannot answer a creator-only approval', async () => {
mocks.client.getSession.mockResolvedValue(
summary('s1', {
capabilities: { canSend: false, canCancel: false, actions: true },
}),
);
provider = {
...provider,
actions: {
listPending: vi.fn().mockResolvedValue([pendingAction]),
respond: vi
.fn()
.mockRejectedValue(
new JavaManagedAgentHttpError(403, 'action_forbidden', 'Forbidden'),
),
},
};
await render('s1');
const allow = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent?.includes('Yes, allow once'),
);
await act(async () => {
allow!.click();
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Only the Session creator can answer this approval.',
);
});

it('does not read approvals for a Session without the actions capability', async () => {
const listPending = vi.fn().mockResolvedValue([]);
provider = {
...provider,
actions: { listPending, respond: vi.fn() },
};

await render('s1');

expect(listPending).not.toHaveBeenCalled();
expect(
container.querySelector('[data-testid="managed-approval"]'),
).toBeNull();
});

it('gates result transport on the server capability and can discover output without its event', async () => {
const listArtifacts = vi.fn().mockResolvedValue({
data: [{ artifact, access: { can_read_content: false } }],
Expand Down
Loading
Loading