Skip to content

Commit 5704300

Browse files
committed
fix: avoid resumeStream on empty history
Change-Id: I5363d2c940e341f3c6b0623603564388eb777c83 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 018c935 commit 5704300

File tree

4 files changed

+109
-1
lines changed

4 files changed

+109
-1
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test, mock } from "bun:test";
2+
3+
import { AgentSession } from "./agentSession";
4+
import type { Config } from "@/node/config";
5+
import type { HistoryService } from "./historyService";
6+
import type { PartialService } from "./partialService";
7+
import type { AIService } from "./aiService";
8+
import type { InitStateManager } from "./initStateManager";
9+
import type { BackgroundProcessManager } from "./backgroundProcessManager";
10+
import type { Result } from "@/common/types/result";
11+
import { Ok } from "@/common/types/result";
12+
13+
describe("AgentSession.resumeStream", () => {
14+
test("returns an error when history is empty", async () => {
15+
const streamMessage = mock(() => Promise.resolve(Ok(undefined)));
16+
17+
const aiService: AIService = {
18+
on: mock(() => aiService),
19+
off: mock(() => aiService),
20+
stopStream: mock(() => Promise.resolve(Ok(undefined))),
21+
isStreaming: mock(() => false),
22+
streamMessage,
23+
} as unknown as AIService;
24+
25+
const historyService: HistoryService = {
26+
getHistory: mock(() => Promise.resolve(Ok([]))),
27+
} as unknown as HistoryService;
28+
29+
const partialService: PartialService = {
30+
commitToHistory: mock((): Promise<Result<void>> => Promise.resolve(Ok(undefined))),
31+
} as unknown as PartialService;
32+
33+
const initStateManager: InitStateManager = {
34+
on: mock(() => initStateManager),
35+
off: mock(() => initStateManager),
36+
} as unknown as InitStateManager;
37+
38+
const backgroundProcessManager: BackgroundProcessManager = {
39+
cleanup: mock(() => Promise.resolve()),
40+
setMessageQueued: mock(() => undefined),
41+
} as unknown as BackgroundProcessManager;
42+
43+
const config: Config = { srcDir: "/tmp" } as unknown as Config;
44+
45+
const session = new AgentSession({
46+
workspaceId: "ws",
47+
config,
48+
historyService,
49+
partialService,
50+
aiService,
51+
initStateManager,
52+
backgroundProcessManager,
53+
});
54+
55+
const result = await session.resumeStream({ model: "anthropic:claude-sonnet-4-5" });
56+
expect(result.success).toBe(false);
57+
if (result.success) return;
58+
expect(result.error.type).toBe("unknown");
59+
if (result.error.type !== "unknown") {
60+
throw new Error(`Expected unknown error, got ${result.error.type}`);
61+
}
62+
expect(result.error.raw).toContain("history is empty");
63+
expect(streamMessage).toHaveBeenCalledTimes(0);
64+
});
65+
});

src/node/services/agentSession.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,13 @@ export class AgentSession {
613613
if (!historyResult.success) {
614614
return Err(createUnknownSendMessageError(historyResult.error));
615615
}
616+
if (historyResult.data.length === 0) {
617+
return Err(
618+
createUnknownSendMessageError(
619+
"Cannot resume stream: workspace history is empty. Send a new message instead."
620+
)
621+
);
622+
}
616623

617624
// Check for external file edits (timestamp-based polling)
618625
const changedFileAttachments = await this.getChangedFileAttachments();

src/node/services/taskService.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ export class TaskService {
185185
this.aiService.on("tool-call-end", (payload: unknown) => {
186186
if (!isToolCallEndEvent(payload)) return;
187187
if (payload.toolName !== "agent_report") return;
188+
// Ignore failed agent_report attempts (e.g. tool rejected due to active descendants).
189+
if (!isSuccessfulToolResult(payload.result)) return;
188190
void this.handleAgentReport(payload).catch((error: unknown) => {
189191
log.error("TaskService.handleAgentReport failed", { error });
190192
});
@@ -1721,7 +1723,14 @@ export class TaskService {
17211723
const childEntry = this.findWorkspaceEntry(cfgAfterReport, childWorkspaceId);
17221724
const parentWorkspaceId = childEntry?.workspace.parentWorkspaceId;
17231725
if (!parentWorkspaceId) {
1724-
log.error("agent_report called from non-task workspace", { childWorkspaceId });
1726+
const reason = childEntry ? "missing parentWorkspaceId" : "workspace not found in config";
1727+
log.debug("Ignoring agent_report: workspace is not an agent task", {
1728+
childWorkspaceId,
1729+
reason,
1730+
});
1731+
// Best-effort: resolve any foreground waiters even if we can't deliver to a parent.
1732+
this.resolveWaiters(childWorkspaceId, reportArgs);
1733+
void this.maybeStartQueuedTasks();
17251734
return;
17261735
}
17271736

@@ -1738,6 +1747,10 @@ export class TaskService {
17381747

17391748
// Auto-resume any parent stream that was waiting on a task tool call (restart-safe).
17401749
const postCfg = this.config.loadConfigOrDefault();
1750+
if (!this.findWorkspaceEntry(postCfg, parentWorkspaceId)) {
1751+
// Parent may have been cleaned up (e.g. it already reported and this was its last descendant).
1752+
return;
1753+
}
17411754
const hasActiveDescendants = this.hasActiveDescendantAgentTasks(postCfg, parentWorkspaceId);
17421755
if (!hasActiveDescendants && !this.aiService.isStreaming(parentWorkspaceId)) {
17431756
const resumeResult = await this.workspaceService.resumeStream(parentWorkspaceId, {

src/node/services/workspaceService.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,21 @@ export class WorkspaceService extends EventEmitter {
633633
async remove(workspaceId: string, force = false): Promise<Result<void>> {
634634
// Try to remove from runtime (filesystem)
635635
try {
636+
// Stop any active stream before deleting metadata/config to avoid tool calls racing with removal.
637+
try {
638+
if (this.aiService.isStreaming(workspaceId)) {
639+
const stopResult = await this.aiService.stopStream(workspaceId, { abandonPartial: true });
640+
if (!stopResult.success) {
641+
log.debug("Failed to stop stream during workspace removal", {
642+
workspaceId,
643+
error: stopResult.error,
644+
});
645+
}
646+
}
647+
} catch (error: unknown) {
648+
log.debug("Failed to stop stream during workspace removal (threw)", { workspaceId, error });
649+
}
650+
636651
const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId);
637652
if (metadataResult.success) {
638653
const metadata = metadataResult.data;
@@ -1314,6 +1329,14 @@ export class WorkspaceService extends EventEmitter {
13141329
});
13151330
}
13161331

1332+
// Guard: avoid creating sessions for workspaces that don't exist anymore.
1333+
if (!this.config.findWorkspace(workspaceId)) {
1334+
return Err({
1335+
type: "unknown",
1336+
raw: "Workspace not found. It may have been deleted.",
1337+
});
1338+
}
1339+
13171340
// Guard: queued agent tasks must not be resumed by generic UI/API calls.
13181341
// TaskService is responsible for dequeuing and starting them.
13191342
if (!internal?.allowQueuedAgentTask) {

0 commit comments

Comments
 (0)