Skip to content

Commit 8cff1da

Browse files
committed
🤖 Add Storybook story for Tasks settings section
- Add Tasks story to App.settings.stories.tsx - Add getTaskSettings/setTaskSettings mock endpoints - Fix lint errors in taskService.test.ts (use return instead of async/await) - Fix taskId passed to injectToolOutputToParent (use child ID, not parent) - Include tool args in ask_user_question tool-call-end events Change-Id: Ica4b4e9ef846233c7a316cacf6c0a8630784bdae Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 2aab8e8 commit 8cff1da

File tree

6 files changed

+100
-50
lines changed

6 files changed

+100
-50
lines changed

.storybook/mocks/orpc.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
152152
tick: async function* () {
153153
// No-op generator
154154
},
155+
getTaskSettings: async () => ({
156+
maxParallelAgentTasks: 3,
157+
maxTaskNestingDepth: 3,
158+
}),
159+
setTaskSettings: async () => undefined,
155160
},
156161
projects: {
157162
list: async () => Array.from(projects.entries()),

src/browser/stories/App.settings.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,11 @@ export const ExperimentsToggleOff: AppStory = {
200200
// Default state is OFF - no clicks needed
201201
},
202202
};
203+
204+
/** Tasks section - shows agent task limits configuration */
205+
export const Tasks: AppStory = {
206+
render: () => <AppWithMocks setup={() => setupSettingsStory({})} />,
207+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
208+
await openSettingsToSection(canvasElement, "tasks");
209+
},
210+
};

src/browser/utils/messages/retryEligibility.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ export function hasInterruptedStream(
110110
// restart, which drops the unfinished tool call from the prompt and can lead
111111
// to duplicate task spawns. TaskService injects the tool output and auto-resumes
112112
// the parent once the subagent reports.
113-
if (lastMessage.type === "tool" && lastMessage.toolName === "task" && lastMessage.status === "executing") {
113+
if (
114+
lastMessage.type === "tool" &&
115+
lastMessage.toolName === "task" &&
116+
lastMessage.status === "executing"
117+
) {
114118
return false;
115119
}
116120

src/node/services/taskService.test.ts

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,19 @@ import type { SendMessageError } from "@/common/types/errors";
1515
import { Err, Ok, type Result } from "@/common/types/result";
1616
import assert from "@/common/utils/assert";
1717

18-
type SendMessageCall = {
18+
interface SendMessageCall {
1919
workspaceId: string;
2020
message: string;
2121
options: SendMessageOptions | undefined;
22-
};
22+
}
2323

24-
type CreateCall = {
24+
interface CreateCall {
2525
projectPath: string;
2626
branchName: string;
2727
trunkBranch: string | undefined;
2828
title: string | undefined;
2929
runtimeConfig: RuntimeConfig | undefined;
30-
};
30+
}
3131

3232
class FakeAIService extends EventEmitter {
3333
private readonly streaming = new Set<string>();
@@ -49,11 +49,9 @@ class FakeAIService extends EventEmitter {
4949
this.metadataById.set(metadata.id, metadata);
5050
}
5151

52-
async getWorkspaceMetadata(
53-
workspaceId: string
54-
): Promise<Result<FrontendWorkspaceMetadata, string>> {
52+
getWorkspaceMetadata(workspaceId: string): Promise<Result<FrontendWorkspaceMetadata, string>> {
5553
const metadata = this.metadataById.get(workspaceId);
56-
return metadata ? Ok(metadata) : Err(`Workspace ${workspaceId} not found`);
54+
return Promise.resolve(metadata ? Ok(metadata) : Err(`Workspace ${workspaceId} not found`));
5755
}
5856
}
5957

@@ -96,7 +94,7 @@ class FakeConfig {
9694
return this.taskStateById.get(workspaceId);
9795
}
9896

99-
async setWorkspaceTaskState(workspaceId: string, taskState: TaskState): Promise<void> {
97+
setWorkspaceTaskState(workspaceId: string, taskState: TaskState): Promise<void> {
10098
this.taskStateById.set(workspaceId, taskState);
10199

102100
const existing = this.metadataById.get(workspaceId);
@@ -108,6 +106,7 @@ class FakeConfig {
108106
taskState,
109107
});
110108
}
109+
return Promise.resolve();
111110
}
112111

113112
countRunningAgentTasks(): number {
@@ -156,8 +155,8 @@ class FakeConfig {
156155
return result;
157156
}
158157

159-
async getAllWorkspaceMetadata(): Promise<FrontendWorkspaceMetadata[]> {
160-
return [...this.metadataById.values()];
158+
getAllWorkspaceMetadata(): Promise<FrontendWorkspaceMetadata[]> {
159+
return Promise.resolve([...this.metadataById.values()]);
161160
}
162161
}
163162

@@ -168,13 +167,13 @@ class FakePartialService {
168167
this.partialByWorkspaceId.set(workspaceId, partial);
169168
}
170169

171-
async readPartial(workspaceId: string): Promise<MuxMessage | null> {
172-
return this.partialByWorkspaceId.get(workspaceId) ?? null;
170+
readPartial(workspaceId: string): Promise<MuxMessage | null> {
171+
return Promise.resolve(this.partialByWorkspaceId.get(workspaceId) ?? null);
173172
}
174173

175-
async writePartial(workspaceId: string, msg: MuxMessage): Promise<Result<void, string>> {
174+
writePartial(workspaceId: string, msg: MuxMessage): Promise<Result<void, string>> {
176175
this.partialByWorkspaceId.set(workspaceId, msg);
177-
return Ok(undefined);
176+
return Promise.resolve(Ok(undefined));
178177
}
179178
}
180179

@@ -185,29 +184,31 @@ class FakeHistoryService {
185184
this.historyByWorkspaceId.set(workspaceId, history);
186185
}
187186

188-
async getHistory(workspaceId: string): Promise<Result<MuxMessage[], string>> {
189-
return Ok(this.historyByWorkspaceId.get(workspaceId) ?? []);
187+
getHistory(workspaceId: string): Promise<Result<MuxMessage[], string>> {
188+
return Promise.resolve(Ok(this.historyByWorkspaceId.get(workspaceId) ?? []));
190189
}
191190

192-
async updateHistory(workspaceId: string, msg: MuxMessage): Promise<Result<void, string>> {
191+
updateHistory(workspaceId: string, msg: MuxMessage): Promise<Result<void, string>> {
193192
const existing = this.historyByWorkspaceId.get(workspaceId) ?? [];
194193
const idx = existing.findIndex((m) => m.id === msg.id);
195194
if (idx === -1) {
196-
return Err(`Message ${msg.id} not found`);
195+
return Promise.resolve(Err(`Message ${msg.id} not found`));
197196
}
198197
const updated = [...existing];
199198
updated[idx] = msg;
200199
this.historyByWorkspaceId.set(workspaceId, updated);
201-
return Ok(undefined);
200+
return Promise.resolve(Ok(undefined));
202201
}
203202
}
204203

205204
class FakeWorkspaceService {
206205
private nextWorkspaceId = 1;
207206
readonly createCalls: CreateCall[] = [];
208207
readonly sendMessageCalls: SendMessageCall[] = [];
209-
readonly resumeStreamCalls: Array<{ workspaceId: string; options: SendMessageOptions | undefined }> =
210-
[];
208+
readonly resumeStreamCalls: Array<{
209+
workspaceId: string;
210+
options: SendMessageOptions | undefined;
211+
}> = [];
211212
readonly removedWorkspaceIds: string[] = [];
212213
readonly appendedMessages: Array<{ workspaceId: string; message: MuxMessage }> = [];
213214

@@ -218,7 +219,7 @@ class FakeWorkspaceService {
218219
private readonly aiService: FakeAIService
219220
) {}
220221

221-
async create(
222+
create(
222223
projectPath: string,
223224
branchName: string,
224225
trunkBranch: string | undefined,
@@ -240,38 +241,38 @@ class FakeWorkspaceService {
240241
this.config.addWorkspace(metadata, metadata.namedWorkspacePath);
241242
this.aiService.setWorkspaceMetadata(metadata);
242243

243-
return Ok({ metadata });
244+
return Promise.resolve(Ok({ metadata }));
244245
}
245246

246-
async sendMessage(
247+
sendMessage(
247248
workspaceId: string,
248249
message: string,
249250
options: SendMessageOptions | undefined = undefined
250251
): Promise<Result<void, SendMessageError>> {
251252
this.sendMessageCalls.push({ workspaceId, message, options });
252-
return this.sendMessageResult;
253+
return Promise.resolve(this.sendMessageResult);
253254
}
254255

255-
async resumeStream(
256+
resumeStream(
256257
workspaceId: string,
257258
options: SendMessageOptions | undefined = undefined
258259
): Promise<Result<void, SendMessageError>> {
259260
this.resumeStreamCalls.push({ workspaceId, options });
260-
return Ok(undefined);
261+
return Promise.resolve(Ok(undefined));
261262
}
262263

263-
async remove(workspaceId: string): Promise<Result<void, string>> {
264+
remove(workspaceId: string): Promise<Result<void, string>> {
264265
this.removedWorkspaceIds.push(workspaceId);
265266
this.config.removeWorkspace(workspaceId);
266-
return Ok(undefined);
267+
return Promise.resolve(Ok(undefined));
267268
}
268269

269-
async appendToHistoryAndEmit(
270+
appendToHistoryAndEmit(
270271
workspaceId: string,
271272
muxMessage: MuxMessage
272273
): Promise<Result<void, string>> {
273274
this.appendedMessages.push({ workspaceId, message: muxMessage });
274-
return Ok(undefined);
275+
return Promise.resolve(Ok(undefined));
275276
}
276277
}
277278

@@ -346,7 +347,7 @@ describe("TaskService", () => {
346347
globalThis.setTimeout = realSetTimeout;
347348
});
348349

349-
it("throws if parent workspace not found", async () => {
350+
it("throws if parent workspace not found", () => {
350351
const config = new FakeConfig();
351352
const aiService = new FakeAIService();
352353
const workspaceService = new FakeWorkspaceService(config, aiService);
@@ -361,7 +362,7 @@ describe("TaskService", () => {
361362
aiService as unknown as AIService
362363
);
363364

364-
await expect(
365+
return expect(
365366
taskService.createTask({
366367
parentWorkspaceId: "missing",
367368
agentType: "research",
@@ -371,7 +372,7 @@ describe("TaskService", () => {
371372
).rejects.toThrow("Parent workspace missing not found");
372373
});
373374

374-
it("enforces maxTaskNestingDepth", async () => {
375+
it("enforces maxTaskNestingDepth", () => {
375376
const config = new FakeConfig();
376377
config.setTaskSettings({ maxTaskNestingDepth: 1 });
377378

@@ -395,7 +396,7 @@ describe("TaskService", () => {
395396
aiService as unknown as AIService
396397
);
397398

398-
await expect(
399+
return expect(
399400
taskService.createTask({
400401
parentWorkspaceId: "child",
401402
agentType: "research",
@@ -565,7 +566,7 @@ describe("TaskService", () => {
565566
const taskPart = parentPartialAfter.parts.find(
566567
(p) => p.type === "dynamic-tool" && p.toolName === "task" && p.toolCallId === "call_1"
567568
);
568-
assert(taskPart && taskPart.type === "dynamic-tool", "expected dynamic tool part");
569+
assert(taskPart?.type === "dynamic-tool", "expected dynamic tool part");
569570
expect(taskPart.state).toBe("output-available");
570571

571572
// Synthetic tool-call-end for the task tool should be emitted for UI update.

src/node/services/taskService.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,11 @@ export class TaskService extends EventEmitter {
428428
}
429429
}
430430

431-
private async postFailureToParent(taskId: string, taskState: TaskState, error: string): Promise<void> {
431+
private async postFailureToParent(
432+
taskId: string,
433+
taskState: TaskState,
434+
error: string
435+
): Promise<void> {
432436
try {
433437
const preset = getAgentPreset(taskState.agentType);
434438
const title = `${preset.name} Task Failed`;
@@ -476,7 +480,10 @@ export class TaskService extends EventEmitter {
476480
if (partial) {
477481
const finalized = this.tryFinalizeTaskToolCall(partial, parentToolCallId, toolOutput);
478482
if (finalized) {
479-
const writeResult = await this.partialService.writePartial(parentWorkspaceId, finalized.updated);
483+
const writeResult = await this.partialService.writePartial(
484+
parentWorkspaceId,
485+
finalized.updated
486+
);
480487
if (!writeResult.success) {
481488
throw new Error(writeResult.error);
482489
}
@@ -624,7 +631,11 @@ export class TaskService extends EventEmitter {
624631

625632
// Re-check state
626633
const updatedState = this.config.getWorkspaceTaskState(workspaceId);
627-
if (!updatedState || updatedState.taskStatus === "reported" || updatedState.taskStatus === "failed") {
634+
if (
635+
!updatedState ||
636+
updatedState.taskStatus === "reported" ||
637+
updatedState.taskStatus === "failed"
638+
) {
628639
return;
629640
}
630641

@@ -673,7 +684,9 @@ export class TaskService extends EventEmitter {
673684

674685
if (updatedState.taskStatus === "awaiting_report") {
675686
// Reminder stream ended and agent_report still wasn't called. Fall back to best-effort report.
676-
log.warn(`Task ${workspaceId} still missing agent_report after reminder, using fallback report`);
687+
log.warn(
688+
`Task ${workspaceId} still missing agent_report after reminder, using fallback report`
689+
);
677690
const fallback = await this.synthesizeFallbackReportMarkdown(workspaceId);
678691
await this.handleAgentReport(workspaceId, {
679692
reportMarkdown: fallback,
@@ -857,7 +870,10 @@ export class TaskService extends EventEmitter {
857870

858871
// If the parent is also a completed task workspace, it might now be eligible for cleanup.
859872
const parentTaskState = this.config.getWorkspaceTaskState(taskState.parentWorkspaceId);
860-
if (parentTaskState && (parentTaskState.taskStatus === "reported" || parentTaskState.taskStatus === "failed")) {
873+
if (
874+
parentTaskState &&
875+
(parentTaskState.taskStatus === "reported" || parentTaskState.taskStatus === "failed")
876+
) {
861877
await this.cleanupTaskSubtree(taskState.parentWorkspaceId, seen);
862878
}
863879
} catch (error) {
@@ -943,7 +959,9 @@ export class TaskService extends EventEmitter {
943959
const allMetadata = await this.config.getAllWorkspaceMetadata();
944960
const completedTaskIds = allMetadata
945961
.filter(
946-
(m) => m.taskState && (m.taskState.taskStatus === "reported" || m.taskState.taskStatus === "failed")
962+
(m) =>
963+
m.taskState &&
964+
(m.taskState.taskStatus === "reported" || m.taskState.taskStatus === "failed")
947965
)
948966
.map((m) => m.id);
949967

@@ -980,7 +998,8 @@ export class TaskService extends EventEmitter {
980998
}
981999

9821000
// Prefer resuming with the model used for the interrupted assistant message.
983-
const modelFromPartial = typeof partial.metadata?.model === "string" ? partial.metadata.model : "";
1001+
const modelFromPartial =
1002+
typeof partial.metadata?.model === "string" ? partial.metadata.model : "";
9841003

9851004
const metadataResult = await this.aiService.getWorkspaceMetadata(parentWorkspaceId);
9861005
const aiSettings = metadataResult.success ? metadataResult.data.aiSettings : undefined;
@@ -997,7 +1016,10 @@ export class TaskService extends EventEmitter {
9971016
...(normalizedMode ? { mode: normalizedMode } : {}),
9981017
});
9991018
if (!resumeResult.success) {
1000-
log.error(`Failed to auto-resume parent workspace ${parentWorkspaceId}:`, resumeResult.error);
1019+
log.error(
1020+
`Failed to auto-resume parent workspace ${parentWorkspaceId}:`,
1021+
resumeResult.error
1022+
);
10011023
}
10021024
} catch (error) {
10031025
log.error(`Failed to auto-resume parent workspace ${parentWorkspaceId}:`, error);

src/node/services/workspaceService.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,7 +1146,10 @@ export class WorkspaceService extends EventEmitter {
11461146
// If this workspace has a pending `task` tool call in partial.json and isn't currently
11471147
// streaming, starting a new stream would drop the unfinished tool call from the prompt
11481148
// (ignoreIncompleteToolCalls=true) and can lead to duplicate task spawns.
1149-
if (!this.aiService.isStreaming(workspaceId) && (await this.hasPendingTaskToolCalls(workspaceId))) {
1149+
if (
1150+
!this.aiService.isStreaming(workspaceId) &&
1151+
(await this.hasPendingTaskToolCalls(workspaceId))
1152+
) {
11501153
return Err({
11511154
type: "unknown",
11521155
raw: "Workspace is awaiting a subagent task. Please wait for it to complete; it will auto-resume when ready.",
@@ -1275,7 +1278,8 @@ export class WorkspaceService extends EventEmitter {
12751278
}
12761279

12771280
return partial.parts.some(
1278-
(part) => isDynamicToolPart(part) && part.toolName === "task" && part.state !== "output-available"
1281+
(part) =>
1282+
isDynamicToolPart(part) && part.toolName === "task" && part.state !== "output-available"
12791283
);
12801284
}
12811285

@@ -1756,9 +1760,15 @@ export class WorkspaceService extends EventEmitter {
17561760
}
17571761
}
17581762

1759-
async appendToHistoryAndEmit(workspaceId: string, muxMessage: MuxMessage): Promise<Result<void, string>> {
1763+
async appendToHistoryAndEmit(
1764+
workspaceId: string,
1765+
muxMessage: MuxMessage
1766+
): Promise<Result<void, string>> {
17601767
try {
1761-
assert(workspaceId && workspaceId.trim().length > 0, "appendToHistoryAndEmit requires workspaceId");
1768+
assert(
1769+
workspaceId && workspaceId.trim().length > 0,
1770+
"appendToHistoryAndEmit requires workspaceId"
1771+
);
17621772

17631773
const appendResult = await this.historyService.appendToHistory(workspaceId, muxMessage);
17641774
if (!appendResult.success) {

0 commit comments

Comments
 (0)