mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
Connect Hosted question actions, structured answers, durable continuation, and the WebShell question form. Cover canonical response replay and pending-question recovery across Harness and Java context restarts. Co-authored-by: Qwen-Coder <[email protected]>
980 lines
37 KiB
TypeScript
980 lines
37 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import type {
|
|
ManagedAgentPendingAction,
|
|
ManagedAgentProvider,
|
|
ManagedAgentSessionEvent,
|
|
} from './managed-agent-provider';
|
|
import { JavaManagedAgentHttpError } from './java-managed-agent-client';
|
|
import { useManagedActions } from './use-managed-actions';
|
|
import { webcrypto } from 'node:crypto';
|
|
import { pendingQuestion } from './managed-question.test-fixtures';
|
|
|
|
const pending: ManagedAgentPendingAction = {
|
|
actionId: 'tool_approval_1',
|
|
sessionId: 'session-1',
|
|
turnId: 'turn-1',
|
|
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' },
|
|
],
|
|
};
|
|
|
|
function update(id: number): ManagedAgentSessionEvent {
|
|
return {
|
|
id,
|
|
at: id,
|
|
type: 'action_updated',
|
|
sessionId: 'session-1',
|
|
turnId: '',
|
|
data: { actionId: 'tool_approval_1', state: 'requested' },
|
|
};
|
|
}
|
|
|
|
function gap(id: number): ManagedAgentSessionEvent {
|
|
return { id, at: id, type: 'stream_gap', sessionId: 'session-1', turnId: '' };
|
|
}
|
|
|
|
describe('useManagedActions', () => {
|
|
let root: Root | undefined;
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
root = undefined;
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function mount(
|
|
provider: ManagedAgentProvider,
|
|
initial: {
|
|
enabled: boolean | undefined;
|
|
events: ManagedAgentSessionEvent[];
|
|
sessionId?: string;
|
|
includeChildren?: boolean;
|
|
clientId?: string;
|
|
},
|
|
) {
|
|
let latest: ReturnType<typeof useManagedActions> | undefined;
|
|
let props = initial;
|
|
function Probe(current: typeof initial) {
|
|
latest = useManagedActions(
|
|
provider,
|
|
current.sessionId ?? 'session-1',
|
|
current.clientId ?? 'client-1',
|
|
current.enabled,
|
|
current.events,
|
|
current.includeChildren,
|
|
);
|
|
return null;
|
|
}
|
|
root = createRoot(document.createElement('div'));
|
|
act(() => root!.render(<Probe {...props} />));
|
|
return {
|
|
get latest() {
|
|
return latest;
|
|
},
|
|
rerender(next: Partial<typeof initial>) {
|
|
props = { ...props, ...next };
|
|
act(() => root!.render(<Probe {...props} />));
|
|
},
|
|
};
|
|
}
|
|
|
|
it('polls aggregate approvals serially, keeps retrying, and answers the child owner', async () => {
|
|
vi.useFakeTimers();
|
|
const child = { ...pending, sessionId: 'child-1' };
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockResolvedValue([child]);
|
|
const respond = vi.fn().mockResolvedValue(undefined);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, {
|
|
enabled: true,
|
|
events: [],
|
|
includeChildren: true,
|
|
});
|
|
for (const delay of [0, 2_000, 5_000, 10_000, 10_000]) {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(delay);
|
|
});
|
|
}
|
|
expect(hook.latest?.action).toEqual(child);
|
|
expect(listPending).toHaveBeenLastCalledWith(
|
|
'session-1',
|
|
expect.objectContaining({ includeChildren: true }),
|
|
);
|
|
await act(async () => {
|
|
await hook.latest!.respond(child.actionId, 'allow');
|
|
});
|
|
expect(respond).toHaveBeenCalledWith(child, 'allow', expect.anything());
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
});
|
|
|
|
it('keeps a question visible while answering and reuses the canonical response key on retry and reload', async () => {
|
|
vi.stubGlobal('crypto', webcrypto);
|
|
const question = pendingQuestion();
|
|
const listPending = vi.fn().mockResolvedValue([question]);
|
|
let reject!: (error: Error) => void;
|
|
const respond = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
() => new Promise<void>((_resolve, fail) => (reject = fail)),
|
|
)
|
|
.mockRejectedValue(new Error('unconfirmed'));
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
|
|
const response = {
|
|
kind: 'question' as const,
|
|
answers: [
|
|
{
|
|
questionId: 'q2',
|
|
optionIds: ['q2o2', 'q2o1'],
|
|
text: 'custom, value',
|
|
},
|
|
{ questionId: 'q1', optionIds: ['q1o2'] },
|
|
],
|
|
};
|
|
let first!: Promise<void>;
|
|
await act(async () => {
|
|
first = hook.latest!.respond(question.actionId, response);
|
|
await hook.latest!.respond(question.actionId, response);
|
|
});
|
|
await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1));
|
|
expect(hook.latest?.action).toEqual(question);
|
|
const key = respond.mock.calls[0][2].idempotencyKey;
|
|
expect(key).toMatch(/^question:[0-9a-f]{64}$/);
|
|
await act(async () => {
|
|
reject(new Error('unconfirmed'));
|
|
await expect(first).rejects.toThrow('unconfirmed');
|
|
});
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond(question.actionId, {
|
|
...response,
|
|
answers: [...response.answers].reverse().map((answer) => ({
|
|
...answer,
|
|
optionIds: [...answer.optionIds].reverse(),
|
|
})),
|
|
}),
|
|
).rejects.toThrow('unconfirmed');
|
|
});
|
|
expect(respond.mock.calls[1][2].idempotencyKey).toBe(key);
|
|
act(() => root!.unmount());
|
|
root = undefined;
|
|
const reloaded = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(reloaded.latest?.action).toEqual(question));
|
|
respond.mockResolvedValueOnce(undefined);
|
|
await act(async () =>
|
|
reloaded.latest!.respond(question.actionId, response),
|
|
);
|
|
expect(respond.mock.calls[2][2].idempotencyKey).toBe(key);
|
|
expect(reloaded.latest?.action).toBeUndefined();
|
|
});
|
|
|
|
it('reconciles a question answered by another client and refreshes after a stream gap', async () => {
|
|
vi.stubGlobal('crypto', webcrypto);
|
|
const question = pendingQuestion();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([question])
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValue([question]);
|
|
const respond = vi
|
|
.fn()
|
|
.mockRejectedValue({ code: 'action_already_resolved' });
|
|
const hook = mount(
|
|
{ actions: { listPending, respond } } as unknown as ManagedAgentProvider,
|
|
{ enabled: true, events: [] },
|
|
);
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(question));
|
|
await act(async () =>
|
|
hook.latest!.respond(question.actionId, {
|
|
kind: 'question',
|
|
answers: [
|
|
{ questionId: 'q1', text: 'custom' },
|
|
{ questionId: 'q2', optionIds: ['q2o1'] },
|
|
],
|
|
}),
|
|
);
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
hook.rerender({ events: [gap(9)] });
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(3));
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
});
|
|
|
|
it('drops an aggregate response after changing credentials even if the provider ignores abort', async () => {
|
|
let resolve!: (actions: ManagedAgentPendingAction[]) => void;
|
|
const listPending = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<ManagedAgentPendingAction[]>((done) => {
|
|
resolve = done;
|
|
}),
|
|
)
|
|
.mockResolvedValue([]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, {
|
|
enabled: true,
|
|
events: [],
|
|
includeChildren: true,
|
|
});
|
|
hook.rerender({ clientId: 'client-2' });
|
|
await act(async () => {
|
|
resolve([pending]);
|
|
});
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
});
|
|
|
|
it('reads nothing when the Session cannot serve Actions', () => {
|
|
const listPending = vi.fn();
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: false, events: [] });
|
|
expect(listPending).not.toHaveBeenCalled();
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
});
|
|
|
|
it('re-reads pending approvals when the stream reports a change', async () => {
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(1));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('hides an answered approval and brings it back if the answer fails', async () => {
|
|
const respond = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('offline'))
|
|
.mockResolvedValueOnce(undefined);
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValue([]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond('tool_approval_1', 'deny'),
|
|
).rejects.toThrow('offline');
|
|
});
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(hook.latest?.answerError).toEqual(new Error('offline'));
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'allow'));
|
|
expect(respond).toHaveBeenLastCalledWith(pending, 'allow', {
|
|
clientId: 'client-1',
|
|
idempotencyKey: 'tool_approval_1:allow',
|
|
});
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
hook.rerender({ events: [update(8)] });
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(3));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
});
|
|
|
|
it('retries a failed read so a transient failure does not hide an approval', async () => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(hook.latest?.loadError).toEqual(new Error('unavailable'));
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
});
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
});
|
|
|
|
it.each([
|
|
['Session switch', 'fails'],
|
|
['Session switch', 'succeeds'],
|
|
['Session switch', 'ends'],
|
|
['Action replacement', 'fails'],
|
|
['Action replacement', 'succeeds'],
|
|
['Action replacement', 'ends'],
|
|
])(
|
|
'keeps the current warning after %s when an old answer %s',
|
|
async (change, outcome) => {
|
|
let resolveAnswer!: () => void;
|
|
let rejectAnswer!: (failure: Error) => void;
|
|
const respond = vi
|
|
.fn()
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((resolve, reject) => {
|
|
resolveAnswer = resolve;
|
|
rejectAnswer = reject;
|
|
}),
|
|
)
|
|
.mockRejectedValueOnce(new Error('current-offline'));
|
|
const next = {
|
|
...pending,
|
|
actionId: 'tool_approval_2',
|
|
sessionId: change === 'Session switch' ? 'session-2' : 'session-1',
|
|
};
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValue([next]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
let oldAnswer!: Promise<unknown>;
|
|
await act(async () => {
|
|
oldAnswer = hook
|
|
.latest!.respond(pending.actionId, 'allow')
|
|
.catch((failure: unknown) => failure);
|
|
});
|
|
hook.rerender(
|
|
change === 'Session switch'
|
|
? { sessionId: next.sessionId }
|
|
: { events: [update(7)] },
|
|
);
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(next));
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond(next.actionId, 'allow'),
|
|
).rejects.toThrow('current-offline');
|
|
});
|
|
expect(hook.latest?.answerError).toEqual(new Error('current-offline'));
|
|
|
|
await act(async () => {
|
|
if (outcome === 'fails') rejectAnswer(new Error('old-offline'));
|
|
else if (outcome === 'ends') {
|
|
rejectAnswer(
|
|
new JavaManagedAgentHttpError(409, 'action_expired', 'Expired'),
|
|
);
|
|
} else resolveAnswer();
|
|
await oldAnswer;
|
|
});
|
|
expect(hook.latest?.action).toEqual(next);
|
|
expect(hook.latest?.answerError).toEqual(new Error('current-offline'));
|
|
},
|
|
);
|
|
|
|
it('stops retrying after a bound and reads again on demand', async () => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
for (const delay of [0, 2_000, 5_000, 10_000, 60_000]) {
|
|
await act(async () => vi.advanceTimersByTimeAsync(delay));
|
|
}
|
|
// The first read and three retries; no fifth read without a request.
|
|
expect(listPending).toHaveBeenCalledTimes(4);
|
|
expect(hook.latest?.loadError).toEqual(new Error('unavailable'));
|
|
|
|
await act(async () => {
|
|
hook.latest!.retry();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(listPending).toHaveBeenCalledTimes(5);
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
});
|
|
|
|
it('drops the unconfirmed-answer warning once that Action leaves the list', async () => {
|
|
const respond = vi.fn().mockRejectedValueOnce(new Error('offline'));
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValueOnce([]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond('tool_approval_1', 'allow'),
|
|
).rejects.toThrow('offline');
|
|
});
|
|
expect(hook.latest?.answerError).toEqual(new Error('offline'));
|
|
|
|
// The Action is still pending, so the warning is still about something.
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
expect(hook.latest?.answerError).toEqual(new Error('offline'));
|
|
|
|
// The Harness ended it: there is no card left to retry.
|
|
hook.rerender({ events: [update(8)] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toBeUndefined());
|
|
expect(listPending).toHaveBeenCalledTimes(3);
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the role-based refusal for the Session after that Action leaves the list', async () => {
|
|
const next = { ...pending, actionId: 'tool_approval_2' };
|
|
const respond = vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new JavaManagedAgentHttpError(403, 'action_forbidden', 'Forbidden'),
|
|
);
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValue([next]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond('tool_approval_1', 'allow'),
|
|
).rejects.toThrow('Forbidden');
|
|
});
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
|
|
// The refused Action is gone and its per-Action warning with it, but the
|
|
// refusal is a fact about the viewer and the Session, so it still covers
|
|
// the Action that replaced it.
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(next));
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
|
|
// A different Session may be the viewer's own: the refusal does not
|
|
// follow the selection.
|
|
hook.rerender({ sessionId: 'session-2' });
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
|
|
// Coming back does not re-admit the 403 either: the refusal is remembered
|
|
// for the Session, not for one uninterrupted visit to it.
|
|
hook.rerender({ sessionId: 'session-1' });
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
});
|
|
|
|
it('latches a role-based refusal that lands after the Action left the list', async () => {
|
|
let rejectAnswer!: (failure: Error) => void;
|
|
const respond = vi.fn().mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
rejectAnswer = reject;
|
|
}),
|
|
);
|
|
const next = { ...pending, actionId: 'tool_approval_2' };
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValue([next]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
|
|
let oldAnswer!: Promise<unknown>;
|
|
await act(async () => {
|
|
oldAnswer = hook
|
|
.latest!.respond(pending.actionId, 'allow')
|
|
.catch((failure: unknown) => failure);
|
|
});
|
|
|
|
// The refused Action leaves the pending list while its answer is in flight.
|
|
// The service checks the creator before it checks that the Action still
|
|
// exists, so the refusal arrives anyway and still has to latch: the next
|
|
// approval of this Session is refused identically.
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(next));
|
|
await act(async () => {
|
|
rejectAnswer(
|
|
new JavaManagedAgentHttpError(403, 'action_forbidden', 'Forbidden'),
|
|
);
|
|
await oldAnswer;
|
|
});
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
// The warning is still scoped to the Action that failed, and that Action is
|
|
// gone, so the latch is what carries the reason from here on.
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
});
|
|
|
|
it('re-probes a role-based refusal on retry instead of latching for the mount', async () => {
|
|
const respond = vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new JavaManagedAgentHttpError(403, 'action_forbidden', 'Forbidden'),
|
|
);
|
|
const listPending = vi.fn().mockResolvedValue([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(async () => {
|
|
await hook
|
|
.latest!.respond(pending.actionId, 'allow')
|
|
.catch(() => undefined);
|
|
});
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
|
|
// The refusal came from the viewer's Workspace role row, which an operator
|
|
// can raise while the page stays open — an explicit retry lets a lifted
|
|
// grant answer where a permanent latch would keep refusing.
|
|
await act(async () => {
|
|
hook.latest!.retry();
|
|
});
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
});
|
|
|
|
it('latches the Session the answer was aimed at, not the one now selected', async () => {
|
|
let rejectAnswer!: (failure: Error) => void;
|
|
const respond = vi.fn().mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
rejectAnswer = reject;
|
|
}),
|
|
);
|
|
const listPending = vi.fn().mockResolvedValue([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
let refusedAnswer!: Promise<unknown>;
|
|
await act(async () => {
|
|
refusedAnswer = hook
|
|
.latest!.respond(pending.actionId, 'allow')
|
|
.catch((failure: unknown) => failure);
|
|
});
|
|
|
|
// The Session nav has no in-flight guard, so the viewer can already be
|
|
// looking at their own Session when the 403 lands. Latching that one would
|
|
// disable a card they may well be allowed to answer, while the Session that
|
|
// actually refused keeps offering one guaranteed 403 per click.
|
|
hook.rerender({ sessionId: 'session-2' });
|
|
await act(async () => {
|
|
rejectAnswer(
|
|
new JavaManagedAgentHttpError(403, 'action_forbidden', 'Forbidden'),
|
|
);
|
|
await refusedAnswer;
|
|
});
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
|
|
hook.rerender({ sessionId: 'session-1' });
|
|
expect(hook.latest?.respondForbidden).toBe(true);
|
|
|
|
// An explicit retry re-probes the latched Session too — it is not
|
|
// selected right now, yet its role row can have been raised meanwhile.
|
|
await act(async () => {
|
|
hook.latest!.retry();
|
|
});
|
|
hook.rerender({ sessionId: 'session-2' });
|
|
hook.rerender({ sessionId: 'session-1' });
|
|
expect(hook.latest?.respondForbidden).toBe(false);
|
|
});
|
|
|
|
it('restores the retry budget when the reader is withdrawn and back', async () => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
for (const delay of [0, 2_000, 5_000, 10_000, 60_000]) {
|
|
await act(async () => vi.advanceTimersByTimeAsync(delay));
|
|
}
|
|
// The first read and three retries; the ladder is exhausted.
|
|
expect(listPending).toHaveBeenCalledTimes(4);
|
|
|
|
hook.rerender({ enabled: false });
|
|
hook.rerender({ enabled: true });
|
|
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
expect(listPending).toHaveBeenCalledTimes(5);
|
|
// The restored read failed, and it is retried instead of being stranded.
|
|
await act(async () => vi.advanceTimersByTimeAsync(2_000));
|
|
expect(listPending).toHaveBeenCalledTimes(6);
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
});
|
|
|
|
it('restores the retry budget after a reload of the Session summary', async () => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockRejectedValueOnce(new Error('unavailable'))
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
for (const delay of [0, 2_000, 5_000, 10_000, 60_000]) {
|
|
await act(async () => vi.advanceTimersByTimeAsync(delay));
|
|
}
|
|
expect(listPending).toHaveBeenCalledTimes(4);
|
|
|
|
// Refresh reloads the summary: the capability is unknown, then known again.
|
|
hook.rerender({ enabled: undefined });
|
|
hook.rerender({ enabled: true });
|
|
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
expect(listPending).toHaveBeenCalledTimes(5);
|
|
await act(async () => vi.advanceTimersByTimeAsync(2_000));
|
|
expect(listPending).toHaveBeenCalledTimes(6);
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the shown approval while the capability is unknown', async () => {
|
|
const listPending = vi.fn().mockResolvedValue([pending]);
|
|
const respond = vi.fn().mockResolvedValue(undefined);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
hook.rerender({ enabled: undefined });
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
expect(listPending).toHaveBeenCalledTimes(1);
|
|
|
|
hook.rerender({ enabled: false });
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
expect(listPending).toHaveBeenCalledTimes(1);
|
|
|
|
// An answer given while the capability is unknown still reaches the
|
|
// service, and the list is read once the capability is known again.
|
|
hook.rerender({ enabled: true });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
hook.rerender({ enabled: undefined });
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'allow'));
|
|
expect(respond).toHaveBeenCalledTimes(1);
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([5_000, -5_000])(
|
|
're-reads once for an expiry %i ms from the browser clock',
|
|
async (delay) => {
|
|
vi.useFakeTimers();
|
|
const action = { ...pending, expiresAt: Date.now() + delay };
|
|
const listPending = vi
|
|
.fn()
|
|
.mockImplementation(async () => [{ ...action }]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await act(async () => Promise.resolve());
|
|
await act(async () =>
|
|
vi.advanceTimersByTimeAsync(Math.max(0, delay) + 1_000),
|
|
);
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
expect(hook.latest?.action).toEqual(action);
|
|
await act(async () => vi.advanceTimersByTimeAsync(10_000));
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
},
|
|
);
|
|
|
|
it('re-reads pending approvals when the transcript reports a stream gap', async () => {
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce([pending]);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(1));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
|
|
// A gap arrives through the durable transcript: the live stream is broken
|
|
// on before merging, so the snapshot's `stream.reconciled` row is the only
|
|
// thing that reports one here.
|
|
hook.rerender({ events: [gap(7)] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('drops the previous Session card and its warnings when the selection changes', async () => {
|
|
const respond = vi.fn().mockRejectedValue(new Error('offline'));
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
// The new Session's read never returns, so nothing but the switch itself
|
|
// can clear what the previous Session left behind.
|
|
.mockImplementation(() => new Promise(() => {}));
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond('tool_approval_1', 'allow'),
|
|
).rejects.toThrow('offline');
|
|
});
|
|
expect(hook.latest?.answerError).toEqual(new Error('offline'));
|
|
|
|
hook.rerender({ sessionId: 'session-2' });
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
expect(hook.latest?.loaded).toBe(false);
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
// The previous Session's Action is not answerable from the new one.
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'allow'));
|
|
expect(respond).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('reports whether the read landed so a failure can name what it broke', async () => {
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockRejectedValueOnce(
|
|
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
|
|
);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.loaded).toBe(true));
|
|
expect(hook.latest?.loadError).toBeUndefined();
|
|
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() => expect(hook.latest?.loadError).toBeDefined());
|
|
// The card that was loaded is still the one on screen.
|
|
expect(hook.latest?.loaded).toBe(true);
|
|
expect(hook.latest?.action).toEqual(pending);
|
|
});
|
|
|
|
it('drops an approval the service reports as ended and reads again', async () => {
|
|
const ended = Object.assign(new Error('Action expired'), {
|
|
status: 409,
|
|
code: 'action_expired',
|
|
});
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValueOnce([]);
|
|
const respond = vi.fn().mockRejectedValue(ended);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'allow'));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
});
|
|
|
|
it('keeps the reason on screen when the answered delivery outlived the Action', async () => {
|
|
// The terminal a click returns once an answered delivery outlived its
|
|
// Action before the decision projected. The service records it only for
|
|
// an Action that is past its deadline and still requested, and its list
|
|
// keeps returning such an Action, so the fixture is what the service
|
|
// really sends. Hiding the card would bring it back on the next read
|
|
// without the reason; the answer rejects and the reason stays instead
|
|
// (review round 1, R1-2 and R1-9).
|
|
const expired = { ...pending, expiresAt: Date.now() - 1_000 };
|
|
const ended = Object.assign(new Error('Action decision expired'), {
|
|
code: 'action_response_decision_expired',
|
|
});
|
|
const listPending = vi.fn().mockResolvedValue([expired]);
|
|
const respond = vi.fn().mockRejectedValue(ended);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(expired));
|
|
|
|
await act(async () => {
|
|
await expect(
|
|
hook.latest!.respond('tool_approval_1', 'allow'),
|
|
).rejects.toBe(ended);
|
|
});
|
|
expect(hook.latest?.action).toEqual(expired);
|
|
expect(hook.latest?.answerError).toBe(ended);
|
|
|
|
// A later read still lists the Action, and the reason survives it.
|
|
const reads = listPending.mock.calls.length;
|
|
hook.rerender({ events: [update(7)] });
|
|
await vi.waitFor(() =>
|
|
expect(listPending.mock.calls.length).toBeGreaterThan(reads),
|
|
);
|
|
expect(hook.latest?.action).toEqual(expired);
|
|
expect(hook.latest?.answerError).toBe(ended);
|
|
});
|
|
|
|
it('drops an approval another client already answered', async () => {
|
|
// The code Java returns when a stale tab answers an Action that another
|
|
// tab or the REST API already decided.
|
|
const ended = Object.assign(new Error('Action already resolved'), {
|
|
status: 409,
|
|
code: 'action_already_resolved',
|
|
});
|
|
const listPending = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([pending])
|
|
.mockResolvedValueOnce([]);
|
|
const respond = vi.fn().mockRejectedValue(ended);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'deny'));
|
|
expect(hook.latest?.action).toBeUndefined();
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('shows an ended approval again when the next read still lists it', async () => {
|
|
const ended = Object.assign(new Error('Action cancelled'), {
|
|
status: 409,
|
|
code: 'action_cancelled',
|
|
});
|
|
const listPending = vi.fn().mockResolvedValue([pending]);
|
|
const respond = vi.fn().mockRejectedValue(ended);
|
|
const provider = {
|
|
actions: { listPending, respond },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
|
|
await act(() => hook.latest!.respond('tool_approval_1', 'allow'));
|
|
await vi.waitFor(() => expect(listPending).toHaveBeenCalledTimes(2));
|
|
await vi.waitFor(() => expect(hook.latest?.action).toEqual(pending));
|
|
expect(hook.latest?.answerError).toBeUndefined();
|
|
});
|
|
|
|
it('does not retry a read the service answered definitively', async () => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
|
|
);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(listPending).toHaveBeenCalledTimes(1);
|
|
expect(hook.latest?.loadError).toBeInstanceOf(JavaManagedAgentHttpError);
|
|
|
|
for (const delay of [2_000, 5_000, 10_000, 60_000]) {
|
|
await act(async () => vi.advanceTimersByTimeAsync(delay));
|
|
}
|
|
// A deleted Session answers the same way forever, so the ladder would only
|
|
// spend four guaranteed-failing requests; the user's own retry is the only
|
|
// thing that starts another.
|
|
expect(listPending).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
hook.latest!.retry();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each([408, 429, 503])(
|
|
'still retries a %i read, which can be transient',
|
|
async (status) => {
|
|
vi.useFakeTimers();
|
|
const listPending = vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new JavaManagedAgentHttpError(status, 'unavailable', 'Busy'),
|
|
);
|
|
const provider = {
|
|
actions: { listPending, respond: vi.fn() },
|
|
} as unknown as ManagedAgentProvider;
|
|
const hook = mount(provider, { enabled: true, events: [] });
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(listPending).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => vi.advanceTimersByTimeAsync(2_000));
|
|
expect(listPending).toHaveBeenCalledTimes(2);
|
|
expect(hook.latest?.loadError).toBeDefined();
|
|
},
|
|
);
|
|
});
|