Repository navigation
feat(managed-agent): ask for Hosted tool approvals (D6a) #13071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Recognise allow under the policy revision the Action's options recorded, not the current constant. - Answer an Action from its record whenever one landed: before the write, after the decision is published and when a write conflicts, so a same decision that won a race answers like a replay and a different one gets action_already_resolved. - Ask the Session's writability inside the authority's serial section through a new optional resolveAction guard, so a decision is not written after the Session blocks while the write waits its turn. - Treat a failed expiry write like a failed decision write: 409 hosted_turn_recovery_required and a woken Turn when the Session can no longer write, 503 only when a retry can succeed. - Tests for each review suggestion and race, and a 10 s wait limit for the Session-level approval tests. - Docs: the server README lists the Actions note, and the Hosted Workspace tool-turn note carries an approval update banner. Part of #12867
- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,6 +29,7 @@ import { HostedShellPublisher } from './hosted-shell-publisher.js'; | |
| import type { ShellPublisherDescriptor } from './managed-shell-publisher.js'; | ||
| import type { HostedWorkspaceToolTurn } from './hosted-workspace-tool-turn.js'; | ||
| import { | ||
| HOSTED_APPROVAL_TIMEOUT_MS, | ||
| HOSTED_TOOL_APPROVAL_POLICY, | ||
| HostedApprovalWaiters, | ||
| } from './hosted-tool-approval.js'; | ||
|
|
@@ -1117,6 +1118,11 @@ describe('Hosted Harness no-tool session', () => { | |
| }); | ||
|
|
||
| describe('Hosted Harness tool approvals', () => { | ||
| // Turns commit and sync several records, which can take over a second | ||
| // on a busy host. | ||
| const waitFor = <T>(check: () => T | Promise<T>) => | ||
| vi.waitFor(check, { timeout: 10_000 }); | ||
|
|
||
| beforeEach(async () => { | ||
| state.root = await mkdtemp(path.join(tmpdir(), 'hosted-harness-test-')); | ||
| state.model.mockReset(); | ||
|
|
@@ -1334,11 +1340,11 @@ describe('Hosted Harness tool approvals', () => { | |
| }) | ||
| .expect(202); | ||
| const count = requestIds.length + 1; | ||
| await vi.waitFor(() => expect(requestIds).toHaveLength(count)); | ||
| await waitFor(() => expect(requestIds).toHaveLength(count)); | ||
| return requestIds.at(-1)!; | ||
| }; | ||
| const finished = async (clientId: string) => | ||
| vi.waitFor(async () => { | ||
| waitFor(async () => { | ||
| const status = await headers( | ||
| supertest(server).get(`/session/${SESSION_ID}/status`), | ||
| ).set('X-Qwen-Client-Id', clientId); | ||
|
|
@@ -1470,7 +1476,7 @@ describe('Hosted Harness tool approvals', () => { | |
| }) | ||
| .expect(202); | ||
| const count = requestIds.length + 1; | ||
| await vi.waitFor(() => expect(requestIds).toHaveLength(count)); | ||
| await waitFor(() => expect(requestIds).toHaveLength(count)); | ||
| }; | ||
| await submit(PROMPT_ID); | ||
| const answer = (optionId: string) => | ||
|
|
@@ -1506,7 +1512,7 @@ describe('Hosted Harness tool approvals', () => { | |
| const failed = await answer('allow'); | ||
| expect(failed.status).toBe(409); | ||
| expect(failed.body.code).toBe('hosted_turn_recovery_required'); | ||
| await vi.waitFor(async () => | ||
| await waitFor(async () => | ||
| expect(await status()).toMatchObject({ | ||
| hasActivePrompt: false, | ||
| recoveryBlocked: true, | ||
|
|
@@ -1524,7 +1530,7 @@ describe('Hosted Harness tool approvals', () => { | |
| await headers(supertest(server).post(`/session/${SESSION_ID}/cancel`)) | ||
| .set('X-Qwen-Client-Id', clientId) | ||
| .expect(204); | ||
| await vi.waitFor(async () => | ||
| await waitFor(async () => | ||
| expect(await status()).toMatchObject({ | ||
| hasActivePrompt: false, | ||
| recoveryBlocked: false, | ||
|
|
@@ -1561,7 +1567,7 @@ describe('Hosted Harness tool approvals', () => { | |
| await headers(supertest(server).post(`/session/${SESSION_ID}/cancel`)) | ||
| .set('X-Qwen-Client-Id', clientId) | ||
| .expect(204); | ||
| await vi.waitFor(async () => | ||
| await waitFor(async () => | ||
| expect(await status()).toMatchObject({ | ||
| hasActivePrompt: false, | ||
| recoveryBlocked: true, | ||
|
|
@@ -1576,12 +1582,21 @@ describe('Hosted Harness tool approvals', () => { | |
| it('asks again in the Turn after one whose calls were all refused', async () => { | ||
| const { server, clientId, answer, status, submit } = await waitingSession(); | ||
| const finished = () => | ||
| vi.waitFor(async () => | ||
| waitFor(async () => | ||
| expect(await status()).toMatchObject({ | ||
| hasActivePrompt: false, | ||
| recoveryBlocked: false, | ||
| }), | ||
| ); | ||
| expect(await definitions()).toEqual([ | ||
| { | ||
| engine: 'managed', | ||
| sessionId: SESSION_ID, | ||
| toolProfile: files, | ||
| approvalMode: 'default', | ||
| approvalTimeoutMs: HOSTED_APPROVAL_TIMEOUT_MS, | ||
| }, | ||
| ]); | ||
| expect((await answer('deny')).status).toBe(200); | ||
| await finished(); | ||
| const second = randomUUID(); | ||
|
|
@@ -1605,7 +1620,7 @@ describe('Hosted Harness tool approvals', () => { | |
| .mockImplementation(() => {}); | ||
| const { server, clientId, answer, status } = await waitingSession(); | ||
| expect((await answer('allow')).status).toBe(200); | ||
| await vi.waitFor(async () => | ||
| await waitFor(async () => | ||
| expect(await status()).toMatchObject({ hasActivePrompt: false }), | ||
| ); | ||
| vi.spyOn( | ||
|
|
@@ -1616,6 +1631,11 @@ describe('Hosted Harness tool approvals', () => { | |
| .set('X-Qwen-Client-Id', clientId) | ||
| .send({ title: 'renamed' }) | ||
| .expect(503); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] R1-3: This test never observes its own premise: every assertion holds identically whether or not the Session's writes were actually stopped. Still stands at the reviewed commit (dcec43b) — no second /title call is driven, so the decided-before-writable ordering still has no positive witness. Witness: Fix: drive the /title failure twice so only a genuinely stopped Session can reject the second write, and assert the premise before the answers. The original comment below carries the full fix and fix witness. 中文说明建议 R1-3:该测试从未验证它自己的前提:无论写入是否真的停止,每条断言都照样成立。 在本轮审查的提交(dcec43b9)上仍然成立——仍未驱动第二次 /title 失败,「先 decided 后 writable」的顺序依然缺少正向见证。 修复:连续触发两次 /title 失败,使只有真正停止写入的 Session 才会拒绝第二次写入,并在回答前断言该前提。(完整修复与验收标准见下方原始评论)。 — glm-5.3-flash via Qwen Code /review (v0.24.6) |
||
| // Only a Session whose writes stopped refuses the next write as well. | ||
| await headers(supertest(server).post(`/session/${SESSION_ID}/title`)) | ||
| .set('X-Qwen-Client-Id', clientId) | ||
| .send({ title: 'renamed again' }) | ||
| .expect(503); | ||
| vi.spyOn( | ||
| LocalManagedSessionResourceStore.prototype, | ||
| 'read', | ||
|
|
@@ -1631,4 +1651,28 @@ describe('Hosted Harness tool approvals', () => { | |
| expect.stringContaining('store unavailable'), | ||
| ); | ||
| }); | ||
|
|
||
| it('asks again in the next Turn after an approval expired unanswered', async () => { | ||
| const { answer, status, submit } = await waitingSession(); | ||
| const finished = () => | ||
| waitFor(async () => | ||
| expect(await status()).toMatchObject({ | ||
| hasActivePrompt: false, | ||
| recoveryBlocked: false, | ||
| }), | ||
| ); | ||
| // An answer after the expiry time expires the Action at once. | ||
| const now = vi | ||
| .spyOn(Date, 'now') | ||
| .mockReturnValue(Date.now() + HOSTED_APPROVAL_TIMEOUT_MS); | ||
| const late = await answer('allow'); | ||
| now.mockRestore(); | ||
| expect(late.status).toBe(409); | ||
| expect(late.body.code).toBe('action_expired'); | ||
| await finished(); | ||
| await submit(randomUUID()); | ||
| expect((await answer('allow')).status).toBe(200); | ||
| await finished(); | ||
| expect(HostedWorkspaceBroker.prototype.execute).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.