Skip to content

Commit dd3b7bf

Browse files
oratistclaude
authored
feat(cli): wire /tasks + /background on a session-scoped TaskManager (#172)
The background-task infrastructure (TaskManager + TASK_TOOLS) existed but the manager was created per runAgent call (agent.ts), so tasks vanished after each turn and slash commands couldn't see them. Re-scope it to the REPL session so tasks persist and are visible to both the agent and the user. - core: TaskManager.setRunner() lets a host own a long-lived manager while the agent loop attaches its run-local sub-agent runner each turn (resolves named sub-agents + fires SubagentStop). Already-started tasks are unaffected. - core: RunAgentOptions.taskManager — when set, runAgent attaches its runner and exposes it on the tool context instead of making a per-run manager (the fallback, unchanged, keeps M1/headless/desktop behavior). - cli: REPL creates one session-scoped TaskManager (baseline runner handles /background started before the first turn) and threads it into every runAgent call + onto the slash-command SessionContext. - cli: /tasks lists this session's background tasks (id/status/description); /tasks <id> shows one's status + output. /background <prompt> (alias /bg) runs the prompt as a depth-1 background sub-agent via the manager. - docs: flip /background + /tasks to ✅ in BEHAVIOR_PARITY; /batch stays 🔄. Slice 1 (visibility) only. Moving the in-flight turn to the background (Ctrl+B parity) needs REPL concurrency and is scoped separately. Tests: setRunner re-targeting (core); /tasks + /background against a stub-backed TaskManager (cli, 10 cases). typecheck + lint + format:check + full suite green. Co-authored-by: t <t@t> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 96b4f8a commit dd3b7bf

7 files changed

Lines changed: 267 additions & 7 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Tests for the background-task slash commands: /tasks and /background.
2+
// Both drive a session-scoped TaskManager (ctx.tasks). Here it's a real
3+
// TaskManager wired to a stub runner — no sub-agent actually runs, so the tests
4+
// stay fast and deterministic while exercising create / list / get / output.
5+
6+
import { describe, expect, it } from 'vitest';
7+
import { SessionManager, TaskManager, type TaskRunHandle } from '@deepcode/core';
8+
import { CommandRegistry, type SessionContext } from './commands.js';
9+
10+
const reg = new CommandRegistry();
11+
12+
/** A TaskManager whose runner immediately resolves with a fixed result string. */
13+
function stubManager(result = 'done'): TaskManager {
14+
return new TaskManager(
15+
() => ({ done: Promise.resolve(result), abort: () => {} }) as TaskRunHandle,
16+
);
17+
}
18+
19+
function ctx(overrides: Partial<SessionContext> = {}): SessionContext {
20+
return {
21+
cwd: '/tmp/x',
22+
model: 'deepseek-chat',
23+
mode: 'default',
24+
effort: 'medium',
25+
settings: {},
26+
creds: { apiKey: 'sk-test' },
27+
sessionId: 's1',
28+
sessions: new SessionManager({ root: '/tmp/x' }),
29+
usage: { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 },
30+
...overrides,
31+
};
32+
}
33+
34+
describe('/background', () => {
35+
it('creates a task and reports its id', async () => {
36+
const tasks = stubManager();
37+
const out = (
38+
await reg.match('/background')!.cmd.run(['fix', 'the', 'flaky', 'test'], ctx({ tasks }))
39+
).join('\n');
40+
expect(out).toMatch(/Started background task task-/);
41+
const list = tasks.list();
42+
expect(list).toHaveLength(1);
43+
expect(list[0]!.description).toBe('fix the flaky test');
44+
});
45+
46+
it('the `/bg` alias works', async () => {
47+
const tasks = stubManager();
48+
await reg.match('/bg')!.cmd.run(['do', 'thing'], ctx({ tasks }));
49+
expect(tasks.list()).toHaveLength(1);
50+
});
51+
52+
it('shows usage when given no prompt', async () => {
53+
const out = (await reg.match('/background')!.cmd.run([], ctx({ tasks: stubManager() }))).join(
54+
'\n',
55+
);
56+
expect(out).toMatch(/Usage: \/background/);
57+
});
58+
59+
it('is unavailable without a task manager', async () => {
60+
const out = (await reg.match('/background')!.cmd.run(['x'], ctx())).join('\n');
61+
expect(out).toMatch(/unavailable/i);
62+
});
63+
64+
it('reports a runner failure instead of throwing', async () => {
65+
const tasks = new TaskManager(() => {
66+
throw new Error('no runner attached');
67+
});
68+
const out = (await reg.match('/background')!.cmd.run(['x'], ctx({ tasks }))).join('\n');
69+
expect(out).toMatch(/Could not start background task: no runner attached/);
70+
});
71+
});
72+
73+
describe('/tasks', () => {
74+
it('reports an empty list', async () => {
75+
const out = (await reg.match('/tasks')!.cmd.run([], ctx({ tasks: stubManager() }))).join('\n');
76+
expect(out).toMatch(/No background tasks yet/);
77+
});
78+
79+
it('lists started tasks with id, status, and description', async () => {
80+
const tasks = stubManager();
81+
tasks.create({ description: 'task one', prompt: 'p1' });
82+
tasks.create({ description: 'task two', prompt: 'p2' });
83+
const out = (await reg.match('/tasks')!.cmd.run([], ctx({ tasks }))).join('\n');
84+
expect(out).toMatch(/Background tasks \(2\)/);
85+
expect(out).toContain('task one');
86+
expect(out).toContain('task two');
87+
expect(out).toMatch(/\[(running|completed)\]/);
88+
});
89+
90+
it('`/tasks <id>` shows a single task’s status and output', async () => {
91+
const tasks = stubManager('the background result');
92+
const t = tasks.create({ description: 'investigate', prompt: 'look into x' });
93+
await tasks.wait(t.id); // let the stub runner settle → completed + output
94+
const out = (await reg.match('/tasks')!.cmd.run([t.id], ctx({ tasks }))).join('\n');
95+
expect(out).toContain(t.id);
96+
expect(out).toMatch(/\[completed\]/);
97+
expect(out).toContain('the background result');
98+
});
99+
100+
it('`/tasks <unknown>` reports no such task', async () => {
101+
const out = (
102+
await reg.match('/tasks')!.cmd.run(['task-nope'], ctx({ tasks: stubManager() }))
103+
).join('\n');
104+
expect(out).toMatch(/No task "task-nope"/);
105+
});
106+
107+
it('is unavailable without a task manager', async () => {
108+
const out = (await reg.match('/tasks')!.cmd.run([], ctx())).join('\n');
109+
expect(out).toMatch(/unavailable/i);
110+
});
111+
});

apps/cli/src/commands.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
SessionManager,
1111
SessionMeta,
1212
StoredMessage,
13+
TaskManager,
1314
} from '@deepcode/core';
1415
import {
1516
contextWindowFor,
@@ -163,6 +164,10 @@ export interface SessionContext {
163164
provider?: Provider;
164165
/** Set by /rewind to request history replacement. REPL applies after run. */
165166
newHistory?: StoredMessage[];
167+
/** Session-scoped background-task manager (REPL-injected) — backs /tasks and
168+
* /background. Same instance the agent loop uses, so tasks the agent starts
169+
* are visible here and vice-versa. */
170+
tasks?: TaskManager;
166171
}
167172

168173
export interface SlashCommand {
@@ -1134,6 +1139,58 @@ export const BtwCommand: SlashCommand = {
11341139
},
11351140
};
11361141

1142+
export const TasksCommand: SlashCommand = {
1143+
name: '/tasks',
1144+
description: 'List background tasks this session, or `/tasks <id>` to show one’s output.',
1145+
run(args, ctx) {
1146+
if (!ctx.tasks) return ['(Background tasks are unavailable here.)'];
1147+
// `/tasks <id>` → show that task's status + output so far.
1148+
if (args[0]) {
1149+
const id = args[0].trim();
1150+
const task = ctx.tasks.get(id);
1151+
if (!task) return [`No task "${id}". Run /tasks to list them.`];
1152+
const out = (task.output || '').trim();
1153+
return [
1154+
`${task.id} [${task.status}] ${task.description}`,
1155+
` created ${task.createdAt}${task.finishedAt ? ` · finished ${task.finishedAt}` : ''}`,
1156+
'',
1157+
out || `(no output yet — task is ${task.status})`,
1158+
];
1159+
}
1160+
const tasks = ctx.tasks.list();
1161+
if (tasks.length === 0) {
1162+
return ['No background tasks yet.', 'Start one with `/background <prompt>`.'];
1163+
}
1164+
const lines = [`Background tasks (${tasks.length}):`];
1165+
for (const t of tasks) lines.push(` ${t.id} [${t.status}] ${t.description}`);
1166+
lines.push('');
1167+
lines.push('Show one with `/tasks <id>`; cancel via the agent’s TaskStop tool.');
1168+
return lines;
1169+
},
1170+
};
1171+
1172+
export const BackgroundCommand: SlashCommand = {
1173+
name: '/background',
1174+
aliases: ['/bg'],
1175+
description: 'Run a prompt as a background sub-agent while you keep working.',
1176+
run(args, ctx) {
1177+
if (!ctx.tasks) return ['(Background tasks are unavailable here.)'];
1178+
const prompt = args.join(' ').trim();
1179+
if (!prompt) {
1180+
return ['Usage: /background <prompt> — runs <prompt> as a background sub-agent.'];
1181+
}
1182+
try {
1183+
const task = ctx.tasks.create({ description: prompt.slice(0, 60), prompt });
1184+
return [
1185+
`Started background task ${task.id}: “${task.description}”.`,
1186+
'It runs while you keep chatting. Check it with `/tasks` (or `/tasks ' + task.id + '`).',
1187+
];
1188+
} catch (err) {
1189+
return [`Could not start background task: ${(err as Error).message}`];
1190+
}
1191+
},
1192+
};
1193+
11371194
export const BUILTIN_COMMANDS: SlashCommand[] = [
11381195
HelpCommand,
11391196
ClearCommand,
@@ -1170,6 +1227,8 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
11701227
UpgradeCommand,
11711228
PrivacySettingsCommand,
11721229
BtwCommand,
1230+
TasksCommand,
1231+
BackgroundCommand,
11731232
];
11741233

11751234
// ──────────────────────────────────────────────────────────────────────────

apps/cli/src/repl.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
HookDispatcher,
1010
ReadTool,
1111
SessionManager,
12+
TaskManager,
1213
ToolRegistry,
1314
WebFetchTool,
1415
WriteTool,
@@ -456,6 +457,38 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
456457
history,
457458
};
458459

460+
// Session-scoped background-task manager (M3.15.3 / parity: /tasks, /background).
461+
// ONE manager for the whole REPL session so tasks persist across turns and are
462+
// visible to both the agent (via TaskCreate) and slash commands. Each turn's
463+
// runAgent attaches a richer runner (named sub-agents + SubagentStop). This
464+
// baseline runner only handles `/background` started before the first turn:
465+
// it runs the prompt as a depth-1 sub-agent (clean context, no nested tasks),
466+
// reading ctx.model/ctx.mode live so /model and /mode switches are honored.
467+
const tasks = new TaskManager((spec) => {
468+
const ac = new AbortController();
469+
const done = runAgent({
470+
provider,
471+
tools,
472+
systemPrompt,
473+
userMessage: spec.prompt,
474+
model: ctx.model,
475+
maxTokens,
476+
temperature,
477+
cwd: ctx.cwd,
478+
signal: ac.signal,
479+
mode: ctx.mode as Mode,
480+
permissions: settings.permissions,
481+
hooks,
482+
pluginDirs: pluginContrib.dirs,
483+
sandboxConfig: settings.sandbox,
484+
autoMode: settings.autoMode,
485+
subAgentDepth: 1,
486+
systemReminders: false,
487+
}).then((r) => assistantText(r.history));
488+
return { done, abort: () => ac.abort() };
489+
});
490+
ctx.tasks = tasks;
491+
459492
if (!opts.bare) {
460493
output.write(
461494
`\n ▎ DeepCode · ${ctx.model} · mode: ${ctx.mode} · effort: ${ctx.effort}\n`,
@@ -619,6 +652,9 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
619652
autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 },
620653
autoMode: settings.autoMode,
621654
sandboxConfig: settings.sandbox,
655+
// Session-scoped manager: the agent's TaskCreate calls land here too, so
656+
// background tasks persist across turns and show up in /tasks.
657+
taskManager: tasks,
622658
approval: async (toolName, _input, verdict) => {
623659
output.write(`\n ⏸ Approve ${toolName}? Reason: ${verdict.reason}\n`);
624660
const answer = (await rl.question(' [y]es / [n]o / [a]lways: ')).trim().toLowerCase();
@@ -717,6 +753,17 @@ function formatEvent(out: Writable, e: AgentEvent): void {
717753
}
718754
}
719755

756+
/** Flatten an agent run's assistant text — the result of a background task. */
757+
function assistantText(history: StoredMessage[]): string {
758+
return history
759+
.filter((m) => m.role === 'assistant')
760+
.flatMap((m) => m.content)
761+
.filter((b): b is Extract<typeof b, { type: 'text' }> => b.type === 'text')
762+
.map((b) => b.text)
763+
.join('\n')
764+
.trim();
765+
}
766+
720767
function formatToolInput(input: Record<string, unknown>): string {
721768
for (const key of ['file_path', 'command', 'pattern', 'path']) {
722769
const v = input[key];

docs/BEHAVIOR_PARITY.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ Legend: `✅` matches · `🟡` matches with caveats · `🔄` deferred · `⚠
4747
| `/voice` ||| 🔄 M8 |
4848
| `/teleport` ||| 🔄 M8 |
4949
| `/desktop` ||| 🔄 M6 |
50-
| `/background` || | 🔄 (paired with TaskCreate M3.15.3) |
51-
| `/batch` ||| 🔄 |
52-
| `/tasks` || | 🔄 |
50+
| `/background` || | ✅ — runs a prompt as a background sub-agent via the session TaskManager (alias `/bg`); agent-started TaskCreate tasks appear too |
51+
| `/batch` ||| 🔄 — batch-of-prompts not yet wired (use `/background` per prompt) |
52+
| `/tasks` || | ✅ — lists this session's background tasks; `/tasks <id>` shows one's status + output |
5353
| `/plan` ||| 🔄 — set via `/mode plan` in DeepCode |
5454
| `/login` / `/logout` ||| ✅ — /logout clears creds + exits; /login <key> stores a new key (next launch) |
5555
| `/export` ||| ✅ — writes the conversation to a markdown file |

packages/core/src/agent.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { compact, shouldCompact } from './compaction/index.js';
55
import type { PermissionRules } from './config/types.js';
66
import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js';
7-
import { TaskManager } from './tasks/manager.js';
7+
import { TaskManager, type TaskRunner } from './tasks/manager.js';
88
import type { HookDispatcher } from './hooks/index.js';
99
import type { Mode } from './types.js';
1010
import type { Provider } from './providers/types.js';
@@ -92,6 +92,12 @@ export interface RunAgentOptions {
9292
/** Installed-plugin directories — so the Task tool can resolve plugin-bundled
9393
* sub-agents (`<dir>/agents/*.md`) in addition to user/project ones. */
9494
pluginDirs?: string[];
95+
/** Optional host-owned background-task manager (e.g. the REPL's session-scoped
96+
* one). When set, this run attaches its sub-agent runner to it and exposes it
97+
* on the tool context, so background tasks persist across runAgent calls and
98+
* are visible to slash commands. When absent, a per-run manager is created
99+
* (the original behavior). Top-level only. */
100+
taskManager?: TaskManager;
95101
}
96102

97103
/** Max sub-agent recursion: top-level (0) may spawn sub-agents (depth 1); those
@@ -374,15 +380,25 @@ export async function runAgent(opts: RunAgentOptions): Promise<RunAgentResult> {
374380
// just that task. A sub-agent (depth ≥ 1) gets no manager → can't spawn tasks.
375381
if (depth === 0 && toolCtx.runSubAgent) {
376382
const runSub = toolCtx.runSubAgent;
377-
toolCtx.tasks = new TaskManager((spec) => {
383+
const runner: TaskRunner = (spec) => {
378384
const ac = new AbortController();
379385
const done = runSub({
380386
prompt: spec.prompt,
381387
agentType: spec.agentType,
382388
signal: ac.signal,
383389
}).then((r) => r.text);
384390
return { done, abort: () => ac.abort() };
385-
});
391+
};
392+
// Reuse a host-provided manager (e.g. REPL session-scoped) so tasks persist
393+
// across turns and stay visible to slash commands; attach THIS run's runner
394+
// either way (it resolves named sub-agents + fires SubagentStop). Otherwise
395+
// fall back to a per-run manager (the original behavior).
396+
if (opts.taskManager) {
397+
opts.taskManager.setRunner(runner);
398+
toolCtx.tasks = opts.taskManager;
399+
} else {
400+
toolCtx.tasks = new TaskManager(runner);
401+
}
386402
}
387403

388404
const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 };

packages/core/src/tasks/manager.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,23 @@ describe('TaskManager', () => {
7171
expect(mgr.get(t.id)?.status).toBe('completed');
7272
});
7373

74+
it('setRunner re-targets the runner for subsequent create() calls', async () => {
75+
const calls: string[] = [];
76+
const mgr = new TaskManager((spec) => {
77+
calls.push(`A:${spec.prompt}`);
78+
return { done: Promise.resolve('a'), abort: () => {} };
79+
});
80+
mgr.create({ description: 'one', prompt: 'p1' });
81+
mgr.setRunner((spec) => {
82+
calls.push(`B:${spec.prompt}`);
83+
return { done: Promise.resolve('b'), abort: () => {} };
84+
});
85+
mgr.create({ description: 'two', prompt: 'p2' });
86+
expect(calls).toEqual(['A:p1', 'B:p2']);
87+
// Both tasks remain tracked — setRunner doesn't disturb existing records.
88+
expect(mgr.list()).toHaveLength(2);
89+
});
90+
7491
it('list / get / update / unknown-id behaviour', async () => {
7592
const mgr = new TaskManager(() => ({ done: Promise.resolve('r'), abort: () => {} }));
7693
const t = mgr.create({ description: 'orig', prompt: 'p' });

packages/core/src/tasks/manager.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,17 @@ export class TaskManager {
4242
private readonly handles = new Map<string, TaskRunHandle>();
4343
private seq = 0;
4444

45-
constructor(private readonly runner: TaskRunner) {}
45+
constructor(private runner: TaskRunner) {}
46+
47+
/**
48+
* Replace the runner used for subsequent `create()` calls. Lets a host own a
49+
* long-lived (e.g. REPL session-scoped) manager while the agent loop attaches
50+
* its run-local sub-agent runner each turn. Tasks already started are
51+
* unaffected — their handle is captured at `create()` time.
52+
*/
53+
setRunner(runner: TaskRunner): void {
54+
this.runner = runner;
55+
}
4656

4757
private newId(): string {
4858
return `task-${(this.seq++).toString(36)}`;

0 commit comments

Comments
 (0)