|
|
@@ -66,6 +66,23 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
|
|
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Resolve once the background task with `id` completes. The task is started
|
|
|
+ * indirectly (via `ctx.tools.execute`), so `start()`'s return is not accessible
|
|
|
+ * here; the executor's `onTaskDone` listener delivers the SAME task object on
|
|
|
+ * completion, which is the surviving seam for awaiting a task by id.
|
|
|
+ */
|
|
|
+function doneFor(ctx: Context, id: string): Promise<BashTask> {
|
|
|
+ return new Promise<BashTask>((resolve) => {
|
|
|
+ const dispose = ctx.bash.onTaskDone((task) => {
|
|
|
+ if (task.id === id) {
|
|
|
+ dispose()
|
|
|
+ resolve(task)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
class LossyReadBashExecutor extends BashExecutor {
|
|
|
private readonly task: BashTask = {
|
|
|
id: 'bash-lossy',
|
|
|
@@ -94,18 +111,10 @@ class LossyReadBashExecutor extends BashExecutor {
|
|
|
return this.task
|
|
|
}
|
|
|
|
|
|
- get(id: string): BashTask | undefined {
|
|
|
- return id === this.task.id ? this.task : undefined
|
|
|
- }
|
|
|
-
|
|
|
ownerOf(): string | undefined {
|
|
|
return undefined
|
|
|
}
|
|
|
|
|
|
- list(): BashTask[] {
|
|
|
- return [this.task]
|
|
|
- }
|
|
|
-
|
|
|
readOutput(id: string): BashTaskRead {
|
|
|
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
|
|
|
return { task: this.task, delta: 'tail', lossy: true }
|
|
|
@@ -286,7 +295,7 @@ describe('background tools', () => {
|
|
|
expect(text(first)).toContain('first')
|
|
|
expect(text(first)).toContain('[status: running]')
|
|
|
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
const second = await call(ctx, 'bash_output', { task_id: id })
|
|
|
expect(text(second)).toContain('second')
|
|
|
expect(text(second)).not.toContain('first')
|
|
|
@@ -306,7 +315,7 @@ describe('background tools', () => {
|
|
|
|
|
|
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
const read = await call(ctx, 'bash_output', { task_id: id })
|
|
|
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
|
|
|
})
|
|
|
@@ -329,7 +338,7 @@ describe('background tools', () => {
|
|
|
|
|
|
const killed = await call(ctx, 'bash_kill', { task_id: id })
|
|
|
expect(text(killed)).toBe(`killed background task ${id}`)
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
|
|
|
const again = await call(ctx, 'bash_kill', { task_id: id })
|
|
|
expect(text(again)).toBe(`task ${id} had already finished`)
|
|
|
@@ -373,7 +382,7 @@ describe('background tools', () => {
|
|
|
agent,
|
|
|
})
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
|
|
|
expect(inject).toHaveBeenCalledTimes(1)
|
|
|
const [content, options] = inject.mock.calls[0] as [
|
|
|
@@ -396,7 +405,7 @@ describe('background tools', () => {
|
|
|
agent,
|
|
|
})
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
|
|
+ await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
|
|
})
|
|
|
|
|
|
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
|
|
|
@@ -415,7 +424,7 @@ describe('background tools', () => {
|
|
|
agent,
|
|
|
})
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
// notifyTaskDone caught and logged the rethrown error.
|
|
|
expect(errorSpy).toHaveBeenCalled()
|
|
|
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
|
|
|
@@ -443,7 +452,7 @@ describe('background tools', () => {
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
// Unregister the agent BEFORE the task completes (simulate disconnect).
|
|
|
unregisterFakeAgents(ctx)
|
|
|
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
|
|
+ await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
|
|
expect(inject).not.toHaveBeenCalled()
|
|
|
})
|
|
|
|
|
|
@@ -451,7 +460,7 @@ describe('background tools', () => {
|
|
|
const ctx = await setup()
|
|
|
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
|
|
|
+ await expect(doneFor(ctx, id)).resolves.toBeDefined()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
@@ -534,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => {
|
|
|
const b = fakeAgent('sess-b')
|
|
|
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- await ctx.bash.get(id)!.done
|
|
|
+ await doneFor(ctx, id)
|
|
|
// Completion does NOT clear ownership: B is still rejected, A still allowed.
|
|
|
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
|
|
|
expect(readByB.isError).toBe(true)
|
|
|
@@ -567,7 +576,9 @@ describe('background task ownership (cross-session isolation)', () => {
|
|
|
// token) survive.
|
|
|
await fiber.dispose()
|
|
|
await ctx.plugin(ToolBash)
|
|
|
- expect(ctx.bash.get(id)?.status).toBe('running')
|
|
|
+ // The task survived the reload, still running and still owned by A — proven
|
|
|
+ // via A's own bash_output (reports running status) and the surviving owner token.
|
|
|
+ expect(text(await callAs(ctx, a, 'bash_output', { task_id: id }))).toContain('[status: running]')
|
|
|
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
|
|
|
|
|
|
// After reload, ownership is INTACT → B is STILL rejected.
|
|
|
@@ -675,10 +686,10 @@ describe('status lines', () => {
|
|
|
const ctx = await setup()
|
|
|
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- const task = ctx.bash.get(id)!
|
|
|
+ const done = doneFor(ctx, id)
|
|
|
|
|
|
await call(ctx, 'bash_kill', { task_id: id })
|
|
|
- await task.done
|
|
|
+ const task = await done
|
|
|
// Simulate the variant where the close event carried no signal.
|
|
|
task.signal = null
|
|
|
const read = await call(ctx, 'bash_output', { task_id: id })
|
|
|
@@ -689,8 +700,7 @@ describe('status lines', () => {
|
|
|
const ctx = await setup()
|
|
|
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
|
|
|
const id = /task (bash-\d+)/.exec(text(started))![1]!
|
|
|
- const task = ctx.bash.get(id)!
|
|
|
- await task.done
|
|
|
+ const task = await doneFor(ctx, id)
|
|
|
// Defensive: completed tasks always carry an exit code in practice; the
|
|
|
// ?? 0 fallback covers task shapes from other executor implementations.
|
|
|
task.exitCode = null
|