@cuongtran001/kanna 0.43.2 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-DLVKyl74.js +1 -0
- package/dist/client/assets/index-DVqFbcyL.css +32 -0
- package/dist/client/assets/index-DrdIVK7H.js +2655 -0
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/{index-D5AqC1n_.js → index-DE2jvYMB.js} +95 -95
- package/dist/export-viewer/assets/index-V85HqTWK.css +1 -0
- package/dist/export-viewer/index.html +2 -2
- package/package.json +4 -3
- package/src/server/agent.test.ts +643 -0
- package/src/server/agent.ts +143 -16
- package/src/server/app-settings.test.ts +54 -1
- package/src/server/app-settings.ts +49 -0
- package/src/server/auth.test.ts +23 -1
- package/src/server/background-tasks.test.ts +293 -0
- package/src/server/background-tasks.ts +219 -0
- package/src/server/cli-runtime.test.ts +15 -1
- package/src/server/cloudflare-tunnel/e2e.test.ts +4 -1
- package/src/server/codex-app-server.test.ts +69 -0
- package/src/server/codex-app-server.ts +13 -1
- package/src/server/diff-store.ts +2 -2
- package/src/server/kanna-mcp.test.ts +90 -0
- package/src/server/kanna-mcp.ts +116 -0
- package/src/server/orphan-persistence.test.ts +148 -0
- package/src/server/orphan-persistence.ts +147 -0
- package/src/server/server.ts +32 -7
- package/src/server/terminal-manager.test.ts +100 -0
- package/src/server/terminal-manager.ts +16 -2
- package/src/server/test-helpers/worktree-repo.ts +37 -0
- package/src/server/uploads.test.ts +36 -0
- package/src/server/worktree-store.test.ts +209 -0
- package/src/server/worktree-store.ts +120 -0
- package/src/server/ws-router.test.ts +360 -2
- package/src/server/ws-router.ts +86 -2
- package/src/shared/protocol.ts +18 -1
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +56 -0
- package/src/shared/types.ts +75 -0
- package/dist/client/assets/index-CC4TiTzA.css +0 -32
- package/dist/client/assets/index-UQWb6QtR.js +0 -2620
- package/dist/export-viewer/assets/index-BWQYh3zz.css +0 -1
package/src/server/agent.test.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
normalizeClaudeStreamMessage,
|
|
8
8
|
normalizeClaudeUsageSnapshot,
|
|
9
9
|
} from "./agent"
|
|
10
|
+
import { BackgroundTaskRegistry } from "./background-tasks"
|
|
10
11
|
import type { HarnessTurn } from "./harness-types"
|
|
11
12
|
import type { ChatAttachment, SlashCommand, TranscriptEntry } from "../shared/types"
|
|
12
13
|
import type { AutoContinueEvent } from "./auto-continue/events"
|
|
@@ -1560,6 +1561,93 @@ describe("AgentCoordinator claude integration", () => {
|
|
|
1560
1561
|
events.close()
|
|
1561
1562
|
})
|
|
1562
1563
|
|
|
1564
|
+
test("Claude steer + result without echoed cancel still clears running state", async () => {
|
|
1565
|
+
// Repro: sometimes the underlying SDK closes its stream cleanly on cancel
|
|
1566
|
+
// and never emits a `result.subtype=cancelled` (which would map to an
|
|
1567
|
+
// `interrupted` entry). When the next prompt's `result` arrives the
|
|
1568
|
+
// session.pendingPromptSeqs queue still holds the orphaned cancelled seq,
|
|
1569
|
+
// so the FIFO shift returns the wrong seq and `activeTurns` is never
|
|
1570
|
+
// cleared — leaving the UI stuck on "Running...".
|
|
1571
|
+
const events = new AsyncEventQueue<any>()
|
|
1572
|
+
const prompts: string[] = []
|
|
1573
|
+
|
|
1574
|
+
const store = createFakeStore()
|
|
1575
|
+
await store.enqueueMessage("chat-1", {
|
|
1576
|
+
id: "queued-1",
|
|
1577
|
+
content: "follow up",
|
|
1578
|
+
attachments: [],
|
|
1579
|
+
provider: "claude",
|
|
1580
|
+
model: "claude-opus-4-1",
|
|
1581
|
+
planMode: false,
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
const coordinator = new AgentCoordinator({
|
|
1585
|
+
store: store as never,
|
|
1586
|
+
onStateChange: () => {},
|
|
1587
|
+
startClaudeSession: async () => ({
|
|
1588
|
+
provider: "claude",
|
|
1589
|
+
stream: events,
|
|
1590
|
+
getAccountInfo: async () => null,
|
|
1591
|
+
interrupt: async () => {},
|
|
1592
|
+
close: () => {},
|
|
1593
|
+
setModel: async () => {},
|
|
1594
|
+
setPermissionMode: async () => {},
|
|
1595
|
+
getSupportedCommands: async () => [],
|
|
1596
|
+
sendPrompt: async (content: string) => {
|
|
1597
|
+
prompts.push(content)
|
|
1598
|
+
if (prompts.length === 1) {
|
|
1599
|
+
events.push({
|
|
1600
|
+
type: "transcript" as const,
|
|
1601
|
+
entry: timestamped({
|
|
1602
|
+
kind: "system_init",
|
|
1603
|
+
provider: "claude",
|
|
1604
|
+
model: "claude-opus-4-1",
|
|
1605
|
+
tools: [],
|
|
1606
|
+
agents: [],
|
|
1607
|
+
slashCommands: [],
|
|
1608
|
+
mcpServers: [],
|
|
1609
|
+
}),
|
|
1610
|
+
})
|
|
1611
|
+
}
|
|
1612
|
+
},
|
|
1613
|
+
}),
|
|
1614
|
+
})
|
|
1615
|
+
|
|
1616
|
+
await coordinator.send({
|
|
1617
|
+
type: "chat.send",
|
|
1618
|
+
chatId: "chat-1",
|
|
1619
|
+
provider: "claude",
|
|
1620
|
+
content: "first prompt",
|
|
1621
|
+
model: "claude-opus-4-1",
|
|
1622
|
+
})
|
|
1623
|
+
|
|
1624
|
+
await coordinator.steer({
|
|
1625
|
+
type: "message.steer",
|
|
1626
|
+
chatId: "chat-1",
|
|
1627
|
+
queuedMessageId: "queued-1",
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1630
|
+
expect(prompts).toHaveLength(2)
|
|
1631
|
+
|
|
1632
|
+
// SDK never echoes a cancelled result for the first prompt — only the
|
|
1633
|
+
// result for the steered second prompt arrives.
|
|
1634
|
+
events.push({
|
|
1635
|
+
type: "transcript" as const,
|
|
1636
|
+
entry: timestamped({
|
|
1637
|
+
kind: "result",
|
|
1638
|
+
subtype: "success",
|
|
1639
|
+
isError: false,
|
|
1640
|
+
durationMs: 0,
|
|
1641
|
+
result: "done",
|
|
1642
|
+
}),
|
|
1643
|
+
})
|
|
1644
|
+
|
|
1645
|
+
await waitFor(() => !coordinator.getActiveStatuses().has("chat-1"))
|
|
1646
|
+
expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
|
|
1647
|
+
|
|
1648
|
+
events.close()
|
|
1649
|
+
})
|
|
1650
|
+
|
|
1563
1651
|
test("uses Claude forkSession when starting a forked chat", async () => {
|
|
1564
1652
|
const startSessionCalls: Array<{ sessionToken: string | null; forkSession: boolean }> = []
|
|
1565
1653
|
const events = new AsyncEventQueue<any>()
|
|
@@ -2367,3 +2455,558 @@ describe("AgentCoordinator.listLiveSchedules", () => {
|
|
|
2367
2455
|
expect(live.sort()).toEqual(["sched-proposed", "sched-scheduled"].sort())
|
|
2368
2456
|
})
|
|
2369
2457
|
})
|
|
2458
|
+
|
|
2459
|
+
// ── AgentCoordinator: BackgroundTaskRegistry integration ──
|
|
2460
|
+
|
|
2461
|
+
describe("AgentCoordinator background task registry", () => {
|
|
2462
|
+
function makeCoordinatorWithRegistry(registry: BackgroundTaskRegistry) {
|
|
2463
|
+
const store = createFakeStore()
|
|
2464
|
+
const coordinator = new AgentCoordinator({
|
|
2465
|
+
store: store as never,
|
|
2466
|
+
onStateChange: () => {},
|
|
2467
|
+
backgroundTasks: registry,
|
|
2468
|
+
})
|
|
2469
|
+
return { store, coordinator }
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
test("registers a bash_shell task on tool_result when run_in_background is true", () => {
|
|
2473
|
+
const registry = new BackgroundTaskRegistry()
|
|
2474
|
+
const { coordinator } = makeCoordinatorWithRegistry(registry)
|
|
2475
|
+
const chatId = "chat-1"
|
|
2476
|
+
const toolId = "tool-bg-1"
|
|
2477
|
+
|
|
2478
|
+
// Simulate tool_call with run_in_background: true
|
|
2479
|
+
const toolCallEntry = timestamped({
|
|
2480
|
+
kind: "tool_call",
|
|
2481
|
+
tool: {
|
|
2482
|
+
kind: "tool",
|
|
2483
|
+
toolKind: "bash",
|
|
2484
|
+
toolName: "Bash",
|
|
2485
|
+
toolId,
|
|
2486
|
+
input: {
|
|
2487
|
+
command: "bun run dev",
|
|
2488
|
+
runInBackground: true,
|
|
2489
|
+
},
|
|
2490
|
+
rawInput: { command: "bun run dev", run_in_background: true },
|
|
2491
|
+
},
|
|
2492
|
+
})
|
|
2493
|
+
|
|
2494
|
+
// Simulate tool_result with text containing "pid 12345"
|
|
2495
|
+
const toolResultEntry = timestamped({
|
|
2496
|
+
kind: "tool_result",
|
|
2497
|
+
toolId,
|
|
2498
|
+
content: "Started process pid: 12345\nServer running on port 3000",
|
|
2499
|
+
})
|
|
2500
|
+
|
|
2501
|
+
// Invoke trackBashToolEntry via the public appendMessage path is not
|
|
2502
|
+
// available directly; call the private method via casting.
|
|
2503
|
+
const coord = coordinator as unknown as {
|
|
2504
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2505
|
+
}
|
|
2506
|
+
coord.trackBashToolEntry(chatId, toolCallEntry)
|
|
2507
|
+
coord.trackBashToolEntry(chatId, toolResultEntry)
|
|
2508
|
+
|
|
2509
|
+
expect(registry.list()).toHaveLength(1)
|
|
2510
|
+
const task = registry.list()[0]
|
|
2511
|
+
expect(task?.kind).toBe("bash_shell")
|
|
2512
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2513
|
+
expect(task.chatId).toBe(chatId)
|
|
2514
|
+
expect(task.command).toBe("bun run dev")
|
|
2515
|
+
expect(task.pid).toBe(12345)
|
|
2516
|
+
expect(task.status).toBe("running")
|
|
2517
|
+
})
|
|
2518
|
+
|
|
2519
|
+
test("does not register when run_in_background is false", () => {
|
|
2520
|
+
const registry = new BackgroundTaskRegistry()
|
|
2521
|
+
const { coordinator } = makeCoordinatorWithRegistry(registry)
|
|
2522
|
+
const chatId = "chat-1"
|
|
2523
|
+
const toolId = "tool-fg-1"
|
|
2524
|
+
|
|
2525
|
+
const coord = coordinator as unknown as {
|
|
2526
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
coord.trackBashToolEntry(chatId, timestamped({
|
|
2530
|
+
kind: "tool_call",
|
|
2531
|
+
tool: {
|
|
2532
|
+
kind: "tool",
|
|
2533
|
+
toolKind: "bash",
|
|
2534
|
+
toolName: "Bash",
|
|
2535
|
+
toolId,
|
|
2536
|
+
input: {
|
|
2537
|
+
command: "ls",
|
|
2538
|
+
runInBackground: false,
|
|
2539
|
+
},
|
|
2540
|
+
rawInput: { command: "ls" },
|
|
2541
|
+
},
|
|
2542
|
+
}))
|
|
2543
|
+
coord.trackBashToolEntry(chatId, timestamped({
|
|
2544
|
+
kind: "tool_result",
|
|
2545
|
+
toolId,
|
|
2546
|
+
content: "file.txt",
|
|
2547
|
+
}))
|
|
2548
|
+
|
|
2549
|
+
expect(registry.list()).toHaveLength(0)
|
|
2550
|
+
})
|
|
2551
|
+
|
|
2552
|
+
test("does not register when backgroundTasks is not provided", () => {
|
|
2553
|
+
const store = createFakeStore()
|
|
2554
|
+
const coordinator = new AgentCoordinator({
|
|
2555
|
+
store: store as never,
|
|
2556
|
+
onStateChange: () => {},
|
|
2557
|
+
// no backgroundTasks
|
|
2558
|
+
})
|
|
2559
|
+
const toolId = "tool-no-reg-1"
|
|
2560
|
+
|
|
2561
|
+
const coord = coordinator as unknown as {
|
|
2562
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// Should not throw
|
|
2566
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2567
|
+
kind: "tool_call",
|
|
2568
|
+
tool: {
|
|
2569
|
+
kind: "tool",
|
|
2570
|
+
toolKind: "bash",
|
|
2571
|
+
toolName: "Bash",
|
|
2572
|
+
toolId,
|
|
2573
|
+
input: { command: "sleep 999", runInBackground: true },
|
|
2574
|
+
rawInput: { command: "sleep 999", run_in_background: true },
|
|
2575
|
+
},
|
|
2576
|
+
}))
|
|
2577
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2578
|
+
kind: "tool_result",
|
|
2579
|
+
toolId,
|
|
2580
|
+
content: "pid: 99999",
|
|
2581
|
+
}))
|
|
2582
|
+
// No registry to check — just verify no exception was thrown
|
|
2583
|
+
})
|
|
2584
|
+
|
|
2585
|
+
test("uses toolId as shellId fallback when output has no shell_id text", () => {
|
|
2586
|
+
const registry = new BackgroundTaskRegistry()
|
|
2587
|
+
const { coordinator } = makeCoordinatorWithRegistry(registry)
|
|
2588
|
+
const toolId = "tool-no-shell-id"
|
|
2589
|
+
|
|
2590
|
+
const coord = coordinator as unknown as {
|
|
2591
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2595
|
+
kind: "tool_call",
|
|
2596
|
+
tool: {
|
|
2597
|
+
kind: "tool",
|
|
2598
|
+
toolKind: "bash",
|
|
2599
|
+
toolName: "Bash",
|
|
2600
|
+
toolId,
|
|
2601
|
+
input: { command: "tail -f /tmp/log.txt", runInBackground: true },
|
|
2602
|
+
rawInput: { command: "tail -f /tmp/log.txt", run_in_background: true },
|
|
2603
|
+
},
|
|
2604
|
+
}))
|
|
2605
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2606
|
+
kind: "tool_result",
|
|
2607
|
+
toolId,
|
|
2608
|
+
content: "Tailing log file…",
|
|
2609
|
+
}))
|
|
2610
|
+
|
|
2611
|
+
const tasks = registry.list()
|
|
2612
|
+
expect(tasks).toHaveLength(1)
|
|
2613
|
+
const task = tasks[0]
|
|
2614
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2615
|
+
expect(task.shellId).toBe(toolId)
|
|
2616
|
+
expect(task.pid).toBeNull()
|
|
2617
|
+
})
|
|
2618
|
+
|
|
2619
|
+
test("uses SDK backgroundTaskId from structured content array when present", () => {
|
|
2620
|
+
const registry = new BackgroundTaskRegistry()
|
|
2621
|
+
const { coordinator } = makeCoordinatorWithRegistry(registry)
|
|
2622
|
+
const toolId = "tool-struct-bg-1"
|
|
2623
|
+
|
|
2624
|
+
const coord = coordinator as unknown as {
|
|
2625
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2629
|
+
kind: "tool_call",
|
|
2630
|
+
tool: {
|
|
2631
|
+
kind: "tool",
|
|
2632
|
+
toolKind: "bash",
|
|
2633
|
+
toolName: "Bash",
|
|
2634
|
+
toolId,
|
|
2635
|
+
input: { command: "bun run dev", runInBackground: true },
|
|
2636
|
+
rawInput: { command: "bun run dev", run_in_background: true },
|
|
2637
|
+
},
|
|
2638
|
+
}))
|
|
2639
|
+
|
|
2640
|
+
// Simulate a tool_result where content is an array of content blocks
|
|
2641
|
+
// and one block carries the SDK's canonical backgroundTaskId field.
|
|
2642
|
+
const structuredContent: Array<{ type: string; text?: string; backgroundTaskId?: string }> = [
|
|
2643
|
+
{ type: "text", text: "Started pid: 99" },
|
|
2644
|
+
{ type: "tool_result", backgroundTaskId: "shell_abc123" },
|
|
2645
|
+
]
|
|
2646
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2647
|
+
kind: "tool_result",
|
|
2648
|
+
toolId,
|
|
2649
|
+
content: structuredContent,
|
|
2650
|
+
}))
|
|
2651
|
+
|
|
2652
|
+
const tasks = registry.list()
|
|
2653
|
+
expect(tasks).toHaveLength(1)
|
|
2654
|
+
const task = tasks[0]
|
|
2655
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2656
|
+
// Structured backgroundTaskId must win over regex-parsed shell_id or toolId fallback
|
|
2657
|
+
expect(task.shellId).toBe("shell_abc123")
|
|
2658
|
+
})
|
|
2659
|
+
|
|
2660
|
+
test("uses SDK backgroundTaskId from direct BashOutput object when present", () => {
|
|
2661
|
+
const registry = new BackgroundTaskRegistry()
|
|
2662
|
+
const { coordinator } = makeCoordinatorWithRegistry(registry)
|
|
2663
|
+
const toolId = "tool-direct-bg-1"
|
|
2664
|
+
|
|
2665
|
+
const coord = coordinator as unknown as {
|
|
2666
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2670
|
+
kind: "tool_call",
|
|
2671
|
+
tool: {
|
|
2672
|
+
kind: "tool",
|
|
2673
|
+
toolKind: "bash",
|
|
2674
|
+
toolName: "Bash",
|
|
2675
|
+
toolId,
|
|
2676
|
+
input: { command: "sleep 300", runInBackground: true },
|
|
2677
|
+
rawInput: { command: "sleep 300", run_in_background: true },
|
|
2678
|
+
},
|
|
2679
|
+
}))
|
|
2680
|
+
|
|
2681
|
+
// Simulate a BashOutput object as content (direct-object form)
|
|
2682
|
+
const bashOutputContent = {
|
|
2683
|
+
stdout: "Started pid: 55555",
|
|
2684
|
+
stderr: "",
|
|
2685
|
+
interrupted: false,
|
|
2686
|
+
backgroundTaskId: "shell_direct789",
|
|
2687
|
+
}
|
|
2688
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2689
|
+
kind: "tool_result",
|
|
2690
|
+
toolId,
|
|
2691
|
+
content: bashOutputContent,
|
|
2692
|
+
}))
|
|
2693
|
+
|
|
2694
|
+
const tasks = registry.list()
|
|
2695
|
+
expect(tasks).toHaveLength(1)
|
|
2696
|
+
const task = tasks[0]
|
|
2697
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2698
|
+
expect(task.shellId).toBe("shell_direct789")
|
|
2699
|
+
})
|
|
2700
|
+
|
|
2701
|
+
// ── draining_stream registry tracking ──
|
|
2702
|
+
|
|
2703
|
+
function makeDrainingStreamSetup() {
|
|
2704
|
+
let resolveStream!: () => void
|
|
2705
|
+
const fakeCodexManager = {
|
|
2706
|
+
async startSession() {},
|
|
2707
|
+
async startTurn(): Promise<HarnessTurn> {
|
|
2708
|
+
async function* stream() {
|
|
2709
|
+
yield {
|
|
2710
|
+
type: "transcript" as const,
|
|
2711
|
+
entry: timestamped({
|
|
2712
|
+
kind: "system_init",
|
|
2713
|
+
provider: "codex" as const,
|
|
2714
|
+
model: "gpt-5.4",
|
|
2715
|
+
tools: [],
|
|
2716
|
+
agents: [],
|
|
2717
|
+
slashCommands: [],
|
|
2718
|
+
mcpServers: [],
|
|
2719
|
+
}),
|
|
2720
|
+
}
|
|
2721
|
+
yield {
|
|
2722
|
+
type: "transcript" as const,
|
|
2723
|
+
entry: timestamped({
|
|
2724
|
+
kind: "result",
|
|
2725
|
+
subtype: "success" as const,
|
|
2726
|
+
isError: false,
|
|
2727
|
+
durationMs: 0,
|
|
2728
|
+
result: "done",
|
|
2729
|
+
}),
|
|
2730
|
+
}
|
|
2731
|
+
await new Promise<void>((resolve) => {
|
|
2732
|
+
resolveStream = resolve
|
|
2733
|
+
})
|
|
2734
|
+
}
|
|
2735
|
+
return {
|
|
2736
|
+
provider: "codex" as const,
|
|
2737
|
+
stream: stream(),
|
|
2738
|
+
interrupt: async () => {},
|
|
2739
|
+
close: () => {
|
|
2740
|
+
resolveStream?.()
|
|
2741
|
+
},
|
|
2742
|
+
}
|
|
2743
|
+
},
|
|
2744
|
+
}
|
|
2745
|
+
return { fakeCodexManager, resolveStream: () => resolveStream() }
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
test("registers a draining_stream entry in the registry when a turn result arrives with stream still open", async () => {
|
|
2749
|
+
const registry = new BackgroundTaskRegistry()
|
|
2750
|
+
const { fakeCodexManager } = makeDrainingStreamSetup()
|
|
2751
|
+
const store = createFakeStore()
|
|
2752
|
+
const coordinator = new AgentCoordinator({
|
|
2753
|
+
store: store as never,
|
|
2754
|
+
onStateChange: () => {},
|
|
2755
|
+
backgroundTasks: registry,
|
|
2756
|
+
codexManager: fakeCodexManager as never,
|
|
2757
|
+
})
|
|
2758
|
+
|
|
2759
|
+
await coordinator.send({
|
|
2760
|
+
type: "chat.send",
|
|
2761
|
+
chatId: "chat-1",
|
|
2762
|
+
provider: "codex",
|
|
2763
|
+
content: "run with bg task",
|
|
2764
|
+
})
|
|
2765
|
+
|
|
2766
|
+
await waitFor(() => coordinator.getDrainingChatIds().has("chat-1"))
|
|
2767
|
+
|
|
2768
|
+
const tasks = registry.list()
|
|
2769
|
+
expect(tasks).toHaveLength(1)
|
|
2770
|
+
const task = tasks[0]
|
|
2771
|
+
if (task?.kind !== "draining_stream") throw new Error("unexpected task kind")
|
|
2772
|
+
expect(task.id).toBe("drain:chat-1")
|
|
2773
|
+
expect(task.chatId).toBe("chat-1")
|
|
2774
|
+
|
|
2775
|
+
// Clean up the hanging stream
|
|
2776
|
+
await coordinator.stopDraining("chat-1")
|
|
2777
|
+
})
|
|
2778
|
+
|
|
2779
|
+
test("unregisters the draining_stream entry when stopDraining is called directly", async () => {
|
|
2780
|
+
const registry = new BackgroundTaskRegistry()
|
|
2781
|
+
const { fakeCodexManager } = makeDrainingStreamSetup()
|
|
2782
|
+
const store = createFakeStore()
|
|
2783
|
+
const coordinator = new AgentCoordinator({
|
|
2784
|
+
store: store as never,
|
|
2785
|
+
onStateChange: () => {},
|
|
2786
|
+
backgroundTasks: registry,
|
|
2787
|
+
codexManager: fakeCodexManager as never,
|
|
2788
|
+
})
|
|
2789
|
+
|
|
2790
|
+
await coordinator.send({
|
|
2791
|
+
type: "chat.send",
|
|
2792
|
+
chatId: "chat-1",
|
|
2793
|
+
provider: "codex",
|
|
2794
|
+
content: "run with bg task",
|
|
2795
|
+
})
|
|
2796
|
+
|
|
2797
|
+
await waitFor(() => coordinator.getDrainingChatIds().has("chat-1"))
|
|
2798
|
+
expect(registry.list()).toHaveLength(1)
|
|
2799
|
+
|
|
2800
|
+
await coordinator.stopDraining("chat-1")
|
|
2801
|
+
|
|
2802
|
+
expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
|
|
2803
|
+
expect(registry.list()).toHaveLength(0)
|
|
2804
|
+
})
|
|
2805
|
+
|
|
2806
|
+
test("registry.stop('drain:CHATID') invokes closeStream strategy and clears both drainingStreams and registry", async () => {
|
|
2807
|
+
const registry = new BackgroundTaskRegistry()
|
|
2808
|
+
const { fakeCodexManager } = makeDrainingStreamSetup()
|
|
2809
|
+
const store = createFakeStore()
|
|
2810
|
+
const coordinator = new AgentCoordinator({
|
|
2811
|
+
store: store as never,
|
|
2812
|
+
onStateChange: () => {},
|
|
2813
|
+
backgroundTasks: registry,
|
|
2814
|
+
codexManager: fakeCodexManager as never,
|
|
2815
|
+
})
|
|
2816
|
+
|
|
2817
|
+
await coordinator.send({
|
|
2818
|
+
type: "chat.send",
|
|
2819
|
+
chatId: "chat-1",
|
|
2820
|
+
provider: "codex",
|
|
2821
|
+
content: "run with bg task",
|
|
2822
|
+
})
|
|
2823
|
+
|
|
2824
|
+
await waitFor(() => coordinator.getDrainingChatIds().has("chat-1"))
|
|
2825
|
+
expect(registry.list()).toHaveLength(1)
|
|
2826
|
+
|
|
2827
|
+
const result = await registry.stop("drain:chat-1")
|
|
2828
|
+
|
|
2829
|
+
expect(result).toEqual({ ok: true, method: "close" })
|
|
2830
|
+
expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
|
|
2831
|
+
expect(registry.list()).toHaveLength(0)
|
|
2832
|
+
})
|
|
2833
|
+
|
|
2834
|
+
test("clearDrainingStream: natural completion clears both drainingStreams and registry", async () => {
|
|
2835
|
+
// Drives a turn through to its stream finally block (natural completion —
|
|
2836
|
+
// the most common path). We use the private-method-cast pattern to exercise
|
|
2837
|
+
// clearDrainingStream directly because driving the full async stream through
|
|
2838
|
+
// its finally block in a reliable, race-free way would require
|
|
2839
|
+
// substantially more scaffolding than the existing helpers provide.
|
|
2840
|
+
const registry = new BackgroundTaskRegistry()
|
|
2841
|
+
const store = createFakeStore()
|
|
2842
|
+
const coordinator = new AgentCoordinator({
|
|
2843
|
+
store: store as never,
|
|
2844
|
+
onStateChange: () => {},
|
|
2845
|
+
backgroundTasks: registry,
|
|
2846
|
+
})
|
|
2847
|
+
|
|
2848
|
+
// Seed the registry with a draining_stream entry for chat-1 to simulate
|
|
2849
|
+
// the state that exists just before the finally block runs.
|
|
2850
|
+
const coord = coordinator as unknown as {
|
|
2851
|
+
drainingStreams: Map<string, { turn: { close(): void } }>
|
|
2852
|
+
clearDrainingStream(chatId: string): void
|
|
2853
|
+
}
|
|
2854
|
+
coord.drainingStreams.set("chat-1", { turn: { close: () => {} } })
|
|
2855
|
+
registry.register({
|
|
2856
|
+
kind: "draining_stream",
|
|
2857
|
+
id: "drain:chat-1",
|
|
2858
|
+
chatId: "chat-1",
|
|
2859
|
+
startedAt: Date.now(),
|
|
2860
|
+
lastOutput: "",
|
|
2861
|
+
})
|
|
2862
|
+
|
|
2863
|
+
expect(coord.drainingStreams.has("chat-1")).toBe(true)
|
|
2864
|
+
expect(registry.list()).toHaveLength(1)
|
|
2865
|
+
|
|
2866
|
+
// Call clearDrainingStream — this is what the stream finally block now calls.
|
|
2867
|
+
coord.clearDrainingStream("chat-1")
|
|
2868
|
+
|
|
2869
|
+
expect(coord.drainingStreams.has("chat-1")).toBe(false)
|
|
2870
|
+
expect(registry.list()).toHaveLength(0)
|
|
2871
|
+
})
|
|
2872
|
+
|
|
2873
|
+
test("clearDrainingStream: startTurnForChat clears stale draining_stream for same chat", async () => {
|
|
2874
|
+
// Verifies clearDrainingStream correctly removes both the Map entry and the
|
|
2875
|
+
// registry entry when a new turn starts on a chat that already has a
|
|
2876
|
+
// draining stream. Uses the private-method cast for the same reason as the
|
|
2877
|
+
// natural-completion test above.
|
|
2878
|
+
const registry = new BackgroundTaskRegistry()
|
|
2879
|
+
const store = createFakeStore()
|
|
2880
|
+
const coordinator = new AgentCoordinator({
|
|
2881
|
+
store: store as never,
|
|
2882
|
+
onStateChange: () => {},
|
|
2883
|
+
backgroundTasks: registry,
|
|
2884
|
+
})
|
|
2885
|
+
|
|
2886
|
+
const coord = coordinator as unknown as {
|
|
2887
|
+
drainingStreams: Map<string, { turn: { close(): void } }>
|
|
2888
|
+
clearDrainingStream(chatId: string): void
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// Seed state: a previous turn left a draining stream for chat-1.
|
|
2892
|
+
coord.drainingStreams.set("chat-1", { turn: { close: () => {} } })
|
|
2893
|
+
registry.register({
|
|
2894
|
+
kind: "draining_stream",
|
|
2895
|
+
id: "drain:chat-1",
|
|
2896
|
+
chatId: "chat-1",
|
|
2897
|
+
startedAt: Date.now(),
|
|
2898
|
+
lastOutput: "",
|
|
2899
|
+
})
|
|
2900
|
+
|
|
2901
|
+
expect(registry.list()).toHaveLength(1)
|
|
2902
|
+
|
|
2903
|
+
// A new turn starting for chat-1 calls clearDrainingStream before proceeding.
|
|
2904
|
+
coord.clearDrainingStream("chat-1")
|
|
2905
|
+
|
|
2906
|
+
expect(coord.drainingStreams.has("chat-1")).toBe(false)
|
|
2907
|
+
expect(registry.list()).toHaveLength(0)
|
|
2908
|
+
})
|
|
2909
|
+
|
|
2910
|
+
test("clearDrainingStream: cancel() clears draining_stream from registry", async () => {
|
|
2911
|
+
// Verifies cancel()'s cleanup path (clearDrainingStream) removes the
|
|
2912
|
+
// registry entry. Uses the private-method cast because wiring cancel()
|
|
2913
|
+
// through to a live draining stream requires the full harness scaffolding.
|
|
2914
|
+
const registry = new BackgroundTaskRegistry()
|
|
2915
|
+
const store = createFakeStore()
|
|
2916
|
+
const coordinator = new AgentCoordinator({
|
|
2917
|
+
store: store as never,
|
|
2918
|
+
onStateChange: () => {},
|
|
2919
|
+
backgroundTasks: registry,
|
|
2920
|
+
})
|
|
2921
|
+
|
|
2922
|
+
const coord = coordinator as unknown as {
|
|
2923
|
+
drainingStreams: Map<string, { turn: { close(): void } }>
|
|
2924
|
+
clearDrainingStream(chatId: string): void
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
// Seed state: a result event left a draining_stream registered for chat-1.
|
|
2928
|
+
coord.drainingStreams.set("chat-1", { turn: { close: () => {} } })
|
|
2929
|
+
registry.register({
|
|
2930
|
+
kind: "draining_stream",
|
|
2931
|
+
id: "drain:chat-1",
|
|
2932
|
+
chatId: "chat-1",
|
|
2933
|
+
startedAt: Date.now(),
|
|
2934
|
+
lastOutput: "",
|
|
2935
|
+
})
|
|
2936
|
+
|
|
2937
|
+
expect(registry.list()).toHaveLength(1)
|
|
2938
|
+
|
|
2939
|
+
// cancel() calls clearDrainingStream when a draining entry exists.
|
|
2940
|
+
coord.clearDrainingStream("chat-1")
|
|
2941
|
+
|
|
2942
|
+
expect(coord.drainingStreams.has("chat-1")).toBe(false)
|
|
2943
|
+
expect(registry.list()).toHaveLength(0)
|
|
2944
|
+
})
|
|
2945
|
+
})
|
|
2946
|
+
|
|
2947
|
+
describe("parseBackgroundPid regex variants", () => {
|
|
2948
|
+
// These tests exercise parseBackgroundPid indirectly via trackBashToolEntry,
|
|
2949
|
+
// verifying the regex accepts all supported PID output formats.
|
|
2950
|
+
function makeRegistryAndCoord() {
|
|
2951
|
+
const registry = new BackgroundTaskRegistry()
|
|
2952
|
+
const store = createFakeStore()
|
|
2953
|
+
const coordinator = new AgentCoordinator({
|
|
2954
|
+
store: store as never,
|
|
2955
|
+
onStateChange: () => {},
|
|
2956
|
+
backgroundTasks: registry,
|
|
2957
|
+
})
|
|
2958
|
+
const coord = coordinator as unknown as {
|
|
2959
|
+
trackBashToolEntry(chatId: string, entry: TranscriptEntry): void
|
|
2960
|
+
}
|
|
2961
|
+
return { registry, coord }
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
function registerWithOutput(output: string, toolId: string) {
|
|
2965
|
+
const { registry, coord } = makeRegistryAndCoord()
|
|
2966
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2967
|
+
kind: "tool_call",
|
|
2968
|
+
tool: {
|
|
2969
|
+
kind: "tool",
|
|
2970
|
+
toolKind: "bash",
|
|
2971
|
+
toolName: "Bash",
|
|
2972
|
+
toolId,
|
|
2973
|
+
input: { command: "sleep 60", runInBackground: true },
|
|
2974
|
+
rawInput: { command: "sleep 60", run_in_background: true },
|
|
2975
|
+
},
|
|
2976
|
+
}))
|
|
2977
|
+
coord.trackBashToolEntry("chat-1", timestamped({
|
|
2978
|
+
kind: "tool_result",
|
|
2979
|
+
toolId,
|
|
2980
|
+
content: output,
|
|
2981
|
+
}))
|
|
2982
|
+
return registry
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
test("parses 'pid: N' format", () => {
|
|
2986
|
+
const registry = registerWithOutput("Started pid: 11111", "t1")
|
|
2987
|
+
const task = registry.list()[0]
|
|
2988
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2989
|
+
expect(task.pid).toBe(11111)
|
|
2990
|
+
})
|
|
2991
|
+
|
|
2992
|
+
test("parses 'pid N' format (space only)", () => {
|
|
2993
|
+
const registry = registerWithOutput("Server pid 22222 running", "t2")
|
|
2994
|
+
const task = registry.list()[0]
|
|
2995
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
2996
|
+
expect(task.pid).toBe(22222)
|
|
2997
|
+
})
|
|
2998
|
+
|
|
2999
|
+
test("parses 'PID=N' format", () => {
|
|
3000
|
+
const registry = registerWithOutput("PID=33333", "t3")
|
|
3001
|
+
const task = registry.list()[0]
|
|
3002
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
3003
|
+
expect(task.pid).toBe(33333)
|
|
3004
|
+
})
|
|
3005
|
+
|
|
3006
|
+
test("returns null when no pid in output", () => {
|
|
3007
|
+
const registry = registerWithOutput("No process info here", "t4")
|
|
3008
|
+
const task = registry.list()[0]
|
|
3009
|
+
if (task?.kind !== "bash_shell") throw new Error("unexpected task kind")
|
|
3010
|
+
expect(task.pid).toBeNull()
|
|
3011
|
+
})
|
|
3012
|
+
})
|