Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ea4e495
fix(managed-agent): harden commit retry, worker containment, panel po…
wenshao Oct 1, 2026
4e085e6
fix(managed-agent): defer the plain-commit retry until fault-gate dri…
wenshao Oct 1, 2026
99c465f
Merge branch 'main' into fix/managed-agent-quality-hardening
wenshao Oct 2, 2026
3ad03d2
fix(web-shell): reset managed stream backoff on delivered events
wenshao Oct 2, 2026
24248ef
fix(web-shell): track managed stream, snapshot, and poller health sep…
wenshao Oct 2, 2026
3e84c17
fix(web-shell): keep managed streams alive across definite snapshot a…
wenshao Oct 2, 2026
ca150cb
fix(web-shell): render managed action errors beside the terminal stop
wenshao Oct 2, 2026
4e6a02a
Merge branch 'main' into fix/managed-agent-quality-hardening
wenshao Oct 3, 2026
eb6c54c
Merge remote-tracking branch 'origin/main' into fix/managed-agent-qua…
wenshao Oct 3, 2026
07a9756
chore(vscode-ide-companion): restore main's NOTICES.txt
wenshao Oct 3, 2026
e826f59
fix(web-shell): retire managed stream stops through their own authority
wenshao Oct 4, 2026
35495f2
fix(web-shell): record managed health signals per answer authority
wenshao Oct 4, 2026
c07ebea
fix(web-shell): let the managed stream retire its own verdict on deli…
wenshao Oct 4, 2026
fec5222
fix(web-shell): guard managed verdict finality and clear loading on e…
wenshao Oct 5, 2026
9439da7
fix(web-shell): let every managed read authority retire its own verdi…
wenshao Oct 5, 2026
9eecbc5
fix(web-shell): retire managed verdicts on any answered read and rest…
qwen-code-ci-bot Oct 5, 2026
36d3898
Merge branch 'main' into fix/managed-agent-quality-hardening
qwen-code-dev-bot Oct 5, 2026
1780afc
fix(web-shell): gate the managed proof-of-life verdict expiry on an a…
qwen-code-ci-bot Oct 5, 2026
ae0f9f8
Merge branch 'main' into fix/managed-agent-quality-hardening
Oct 6, 2026
f82bab1
fix(web-shell): expire stream verdicts on heartbeat-answered idle rec…
Oct 6, 2026
2061be7
Merge branch 'main' into fix/managed-agent-quality-hardening
qwen-code-dev-bot Oct 6, 2026
264823f
Merge remote-tracking branch 'origin/main' into fix/managed-agent-qua…
wenshao Oct 6, 2026
d8cf0a0
fix(web-shell): never resurrect a heartbeat-expired stream verdict (#…
Oct 7, 2026
99dea2d
fix(web-shell): tighten the signal-leg ledger and alert de-duplicatio…
Oct 7, 2026
c25755d
fix(web-shell): land the proof-of-life expiry on late frames and boun…
Oct 7, 2026
79d77b2
fix(web-shell): keep the spent re-arm bound's verdict standing (#13179)
Oct 8, 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
74 changes: 74 additions & 0 deletions packages/cli/src/serve/managed-runtime-file-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,80 @@ it('rejects symlinks, traversal, foreign owners and mutation without preparation
expect(await readFile(path.join(root, 'outside'), 'utf8')).toBe('decoy');
});

it('rejects a relative file path that resolves outside the workspace', async () => {
const runtime = randomUUID();
const tools = createManagedToolSet(workspace, runtime);
const executor = new ManagedToolExecutor(async () => tools);
const reference = {
sessionId: runtime,
promptId: randomUUID(),
callId: randomUUID(),
argsDigest: 'digest',
};
expect(
await executor.execute(reference, 'write_file', {
file_path: '../escape',
content: 'x',
}),
).toMatchObject({
executionStatus: 'error',
error: {
message: expect.stringContaining(
'not within the Session working directory',
),
},
});
expect(await stat(path.join(root, 'escape')).catch(() => null)).toBeNull();
expect(
await executor.execute(
{ ...reference, callId: randomUUID() },
'write_file',
{ file_path: 'inside', content: 'x' },
),
).toMatchObject({ executionStatus: 'success' });
expect(await readFile(path.join(workspace, 'inside'), 'utf8')).toBe('x');
});

it('rejects a relative read path that escapes the workspace, by traversal or link', async () => {
const runtime = randomUUID();
const tools = createManagedToolSet(workspace, runtime);
const executor = new ManagedToolExecutor(async () => tools);
const reference = {
sessionId: runtime,
promptId: randomUUID(),
callId: randomUUID(),
argsDigest: 'digest',
};
await writeFile(path.join(root, 'secret'), 'sensitive');
await writeFile(path.join(workspace, 'inside'), 'x');
await symlink(path.join(root, 'secret'), path.join(workspace, 'link'));
// Reads never pass through file history, so the executor's containment
// check is their only guard.
for (const filePath of ['../secret', 'link']) {
expect(
await executor.execute(
{ ...reference, callId: randomUUID() },
'read_file',
{ file_path: filePath },
),
).toMatchObject({
executionStatus: 'error',
error: {
message: expect.stringContaining(
'not within the Session working directory',
),
},
});
}
expect(
await executor.execute(
{ ...reference, callId: randomUUID() },
'read_file',
{ file_path: 'inside' },
),
).toMatchObject({ executionStatus: 'success' });
});

it('admits history preparation beside async Hooks while retaining their close and undo hold', async () => {
const runtime = 'hooks-activation-original';
const holds = vi.fn(() => true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,245 @@ describe('ManagedSessionsPage', () => {
expect(mocks.client.getTranscript).toHaveBeenCalledTimes(3);
});

it('prefers the terminal stop reason over a later transient error and keeps it after the transient clears', async () => {
// Pin the poller's first-rung failure delay to its maximum so the
// recovering read lands after both positional alert checks.
const random = vi.spyOn(Math, 'random').mockReturnValue(0.999999);
vi.useFakeTimers();
try {
mocks.client.getSession
.mockResolvedValueOnce(summary('s1'))
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
)
.mockResolvedValue(summary('s1'));
mocks.client.subscribeEvents
.mockImplementationOnce(async function* () {
yield event(3, 'still streaming');
await new Promise((resolve) => setTimeout(resolve, 3_500));
throw new JavaManagedAgentHttpError(
502,
'bad_gateway',
'Bad gateway',
);
})
.mockImplementationOnce(async function* (
_id: string,
opts: { signal: AbortSignal },
) {
yield event(4, 'resumed after the stop');
await new Promise<void>((resolve) => {
if (opts.signal.aborted) resolve();
else
opts.signal.addEventListener('abort', () => resolve(), {
once: true,
});
});
});
await render('s1');
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
expect(
container.querySelector('[data-testid="messages"]')?.textContent,
).toContain('resumed after the stop');
} finally {
random.mockRestore();
}
});

it('shows a fresh action error alongside a sticky terminal stop', async () => {
vi.useFakeTimers();
mocks.client.getSession
.mockResolvedValueOnce(summary('s1'))
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
)
.mockResolvedValue(summary('s1'));
await render('s1');
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
mocks.client.submitPrompt.mockRejectedValueOnce(
new JavaManagedAgentHttpError(409, 'turn_active', 'Turn already active'),
);
await input('Is anything there?');
await click('Send');
await act(async () => {
await flush();
});
const alerts = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(alerts).toEqual(['Not found', 'Turn already active']);
const actionAlert = container.querySelectorAll('[role="alert"]')[1];
// The session leg heals on the next poll rung: the sticky stop leaves,
// and the still-standing action error must keep its node — index
// reconciliation would keep node 0 and rewrite its text, re-announcing
// an already-announced alert.
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000);
await flush();
});
expect(
[...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
),
).toEqual(['Turn already active']);
expect(container.querySelector('[role="alert"]')).toBe(actionAlert);
});

it('shows a failed older-page fetch alongside a sticky terminal stop', async () => {
vi.useFakeTimers();
mocks.client.getSession
.mockResolvedValueOnce(summary('s1'))
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
)
.mockResolvedValue(summary('s1'));
mocks.client.getTranscript
.mockResolvedValueOnce({
events: [event(1, 'Persisted answer')],
olderCursor: '1',
lastEventId: 1,
})
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(500, 'internal', 'History unavailable'),
);
await render('s1');
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
await click('Older history');
const alerts = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(alerts).toContain('Not found');
expect(alerts).toContain('History unavailable');
});

it('renders one alert when the action error repeats the standing verdict', async () => {
vi.useFakeTimers();
mocks.client.getSession
.mockResolvedValueOnce(summary('s1'))
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
)
.mockResolvedValue(summary('s1'));
await render('s1');
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
// The same server condition fails the page action: the message must
// not be announced twice.
mocks.client.submitPrompt.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
);
await input('Is anything there?');
await click('Send');
const alerts = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(alerts).toEqual(['Not found']);
// When the poller's next read heals the session leg, the suppressed
// duplicate is not re-mounted as a brand-new assertive region: the
// message already on screen keeps its DOM node. The recovery read
// lands at one poll rung (at most 3000+5999ms) after the 404.
const standingAlert = container.querySelector('[role="alert"]');
await act(async () => {
await vi.advanceTimersByTimeAsync(6_000);
await flush();
});
const healed = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(healed).toEqual(['Not found']);
expect(container.querySelector('[role="alert"]')).toBe(standingAlert);
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
});

it('renders one alert when the failed older-page fetch repeats the standing verdict', async () => {
vi.useFakeTimers();
mocks.client.getSession
.mockResolvedValueOnce(summary('s1'))
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
)
.mockResolvedValue(summary('s1'));
mocks.client.getTranscript
.mockResolvedValueOnce({
events: [event(1, 'Persisted answer')],
olderCursor: '1',
lastEventId: 1,
})
.mockRejectedValueOnce(
new JavaManagedAgentHttpError(404, 'session_not_found', 'Not found'),
);
await render('s1');
await act(async () => {
await vi.advanceTimersByTimeAsync(3_500);
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Not found',
);
// The failed page fetch carries the same message the verdict already
// shows: it must not be announced twice.
await click('Older history');
const alerts = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(alerts).toEqual(['Not found']);
});

it('renders one alert when the action error repeats the standing stream failure', async () => {
vi.useFakeTimers();
mocks.client.subscribeEvents.mockImplementationOnce(async function* () {
yield event(3, 'still streaming');
throw new JavaManagedAgentHttpError(502, 'bad_gateway', 'Bad gateway');
});
await render('s1');
await act(async () => {
await flush();
});
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Bad gateway',
);
// The user's Send fails with the same message the stream leg already
// shows: it must not be announced twice.
mocks.client.submitPrompt.mockRejectedValueOnce(
new JavaManagedAgentHttpError(502, 'bad_gateway', 'Bad gateway'),
);
await input('Is anything there?');
await click('Send');
const alerts = [...container.querySelectorAll('[role="alert"]')].map(
(el) => el.textContent,
);
expect(alerts).toEqual(['Bad gateway']);
});

it('merges a gapped stream with a durable snapshot and keeps paged history', async () => {
vi.useFakeTimers();
let deliverGap!: () => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,11 +428,20 @@ function ManagedSessionsContent({
{t('managed.refresh')}
</Button>
</div>
{(error || detail.error) && (
<p role="alert" className="text-sm text-destructive">
{error || detail.error}
{/* One server condition can surface through both channels; announce
each distinct message once, keyed by the message so a standing
alert keeps its node when a duplicate of it stops rendering. */}
{[
...new Set(
[detail.stoppedReason, detail.error, error].filter(
(message): message is string => Boolean(message),
),
),
].map((message) => (
<p key={message} role="alert" className="text-sm text-destructive">
{message}
</p>
)}
))}
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 md:grid-cols-[minmax(180px,240px)_minmax(0,1fr)]">
<nav
ref={sessionsNavRef}
Expand Down
Loading
Loading