From 592dfd8854be207a4f6f294eb8d75281da2f05eb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 20:53:32 +0800 Subject: [PATCH] test(ci): cover the owned promotion paths An agent-attributed timeout promotion fences the job to the calling session in both shell tools, and the pwsh promoted job's cancel hook is exercised through a registry kill. --- .../shell/tool-bash/tests/observe.spec.ts | 25 ++++++++++ .../shell/tool-pwsh/tests/observe.spec.ts | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/shell/tool-bash/tests/observe.spec.ts b/packages/shell/tool-bash/tests/observe.spec.ts index 90150a5dfb..56489b51a4 100644 --- a/packages/shell/tool-bash/tests/observe.spec.ts +++ b/packages/shell/tool-bash/tests/observe.spec.ts @@ -217,6 +217,31 @@ describe('foreground timeout promotion', () => { await until(() => ctx.jobs.get(job!.id).status === 'killed' ? true : undefined) }) + it('promotes under the calling agent so the job is fenced to its session', async () => { + const ctx = await setup() + const owner = { + id: SessionId('promote-owner'), + session: { id: SessionId('promote-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('observe-promote-owned'), + name: 'bash', + arguments: { command: 'printf "held\\n"; sleep 30', description: 'test command', timeoutMs: 250 }, + agent: owner, + }) + expect(text(result)).toContain('moved to background job') + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + expect(job!.ownerSession).toBe(owner.id) + expect(() => ctx.jobs.readRecord(job!.id, 0)).toThrow(/belongs to another session/) + expect(ctx.jobs.kill(job!.id, owner, { reason: 'test cleanup' })).toBe('requested') + await until(() => ctx.jobs.get(job!.id, owner).status === 'killed' ? true : undefined) + }) + it('falls back to the timeout kill when the job admission refuses the promotion', async () => { const ctx = await setup() const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) diff --git a/packages/shell/tool-pwsh/tests/observe.spec.ts b/packages/shell/tool-pwsh/tests/observe.spec.ts index fcd9de6d6b..bf7972a90a 100644 --- a/packages/shell/tool-pwsh/tests/observe.spec.ts +++ b/packages/shell/tool-pwsh/tests/observe.spec.ts @@ -275,6 +275,56 @@ describe('foreground timeout promotion (pwsh)', () => { await until(() => ctx.jobs.get(job!.id).status === 'completed' ? true : undefined) }) + it('promotes under the calling agent and stops the promoted job through the registry kill', async () => { + const { ctx, pwsh } = await setup() + const owner = { + id: SessionId('pwsh-promote-owner'), + session: { id: SessionId('pwsh-promote-owner'), header: { cwd: process.cwd() } }, + status: 'idle', + ctx, + } as unknown as Agent + ctx.agents.register(owner) + let killed = false + let resolveDone: () => void = () => {} + const proc = { + status: 'running', + exitCode: null, + signal: null, + done: new Promise((resolve) => { resolveDone = resolve }), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + killed = true + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + promotion: Promise.resolve({ accepted: false, accept() { this.accepted = true }, decline() {} }), + result: () => Promise.reject(new Error('result projection unused after promotion')), + } as unknown as ShellExecution & { status: string; signal: string | null } + pwsh.foregroundHandler = () => proc + + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: ToolCallId('pwsh-observe-promote-owned'), + name: 'pwsh', + arguments: { command: 'Get-Slow', description: 'test command', timeoutMs: 250 }, + agent: owner, + }) + expect((result.content[0] as { text: string }).text).toContain('moved to background job') + const job = ctx.jobs.list(owner)[0] + expect(job).toBeDefined() + expect(job!.ownerSession).toBe(owner.id) + + expect(ctx.jobs.kill(job!.id, owner, { reason: 'test cleanup' })).toBe('requested') + await until(() => killed ? true : undefined) + const settled = await until(() => { + const snapshot = ctx.jobs.get(job!.id, owner) + return snapshot.status === 'killed' ? snapshot : undefined + }) + expect(settled.detail).toBe('signal: SIGTERM; test cleanup') + }) + it('declines the offer and reports the timeout when no job controller serves the owner', async () => { // The same composition minus dsh-tool-jobs: the registry exists, so the // deadline still offers, but admission refuses and the tool falls back.