From 41c5d292bb562d0edb7044147ec4d414c869060a Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 14:54:53 +0100 Subject: [PATCH] fix: allow errand agent to end session with questions and resume The errand agent can now write { "status": "questions", ... } to signal.json to pause mid-task and ask the user for clarification. The session ends cleanly; the user answers via UI or CLI; the system resumes the agent with their answers via sendUserMessage. Two changes: - buildErrandPrompt: adds "Option B" explaining the questions signal format and the resume-on-answer lifecycle, alongside the existing inline-question approach. - sendUserMessage: extends allowed statuses from running|idle to also include waiting_for_input, so agents paused on a questions signal can be resumed when the user replies. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/agent/file-io.test.ts | 11 +++++++++++ apps/server/agent/manager.test.ts | 25 +++++++++++++++++++++++++ apps/server/agent/manager.ts | 2 +- apps/server/agent/prompts/errand.ts | 16 ++++++++++++++-- docs/agent.md | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/server/agent/file-io.test.ts b/apps/server/agent/file-io.test.ts index 8c567f0..321ff8a 100644 --- a/apps/server/agent/file-io.test.ts +++ b/apps/server/agent/file-io.test.ts @@ -487,4 +487,15 @@ describe('buildErrandPrompt', () => { expect(result).toMatch(/ask|question/i); expect(result).toMatch(/chat|message|reply/i); }); + + it('includes questions signal format for session-ending questions', () => { + const result = buildErrandPrompt('some change'); + expect(result).toContain('"status": "questions"'); + expect(result).toContain('"questions"'); + }); + + it('explains session ends and resumes with user answers', () => { + const result = buildErrandPrompt('some change'); + expect(result).toMatch(/resume|end.*session|session.*end/i); + }); }); diff --git a/apps/server/agent/manager.test.ts b/apps/server/agent/manager.test.ts index 5781477..ef0d60a 100644 --- a/apps/server/agent/manager.test.ts +++ b/apps/server/agent/manager.test.ts @@ -462,6 +462,31 @@ describe('MultiProviderAgentManager', () => { }); }); + describe('sendUserMessage', () => { + it('resumes errand agent in waiting_for_input status', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'waiting_for_input', + }); + + const mockChild = createMockChildProcess(); + mockSpawn.mockReturnValue(mockChild); + + await expect(manager.sendUserMessage(mockAgent.id, 'my answer')).resolves.not.toThrow(); + }); + + it('rejects if agent is stopped', async () => { + mockRepository.findById = vi.fn().mockResolvedValue({ + ...mockAgent, + status: 'stopped', + }); + + await expect(manager.sendUserMessage(mockAgent.id, 'message')).rejects.toThrow( + 'Agent is not running' + ); + }); + }); + describe('getResult', () => { it('returns null when agent has no result', async () => { const result = await manager.getResult('agent-123'); diff --git a/apps/server/agent/manager.ts b/apps/server/agent/manager.ts index 0d8fad2..e9c1e16 100644 --- a/apps/server/agent/manager.ts +++ b/apps/server/agent/manager.ts @@ -634,7 +634,7 @@ export class MultiProviderAgentManager implements AgentManager { const agent = await this.repository.findById(agentId); if (!agent) throw new Error(`Agent not found: ${agentId}`); - if (agent.status !== 'running' && agent.status !== 'idle') { + if (agent.status !== 'running' && agent.status !== 'idle' && agent.status !== 'waiting_for_input') { throw new Error(`Agent is not running (status: ${agent.status})`); } diff --git a/apps/server/agent/prompts/errand.ts b/apps/server/agent/prompts/errand.ts index cb9feaf..f7f2228 100644 --- a/apps/server/agent/prompts/errand.ts +++ b/apps/server/agent/prompts/errand.ts @@ -7,11 +7,23 @@ Work interactively with the user. Make only the changes needed to fulfill the de ## Asking questions -If you need clarification before or during the change, ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: +### Option A — Ask inline (session stays open) + +Ask the user directly in your response and wait. The user can reply via the UI chat input on the Errands page or by running: cw errand chat "" -Their reply will be delivered as the next message in this session. Be explicit about what you need — don't make assumptions when the task is ambiguous. +Their reply will be delivered as the next message in this session. + +### Option B — End session with questions (then resume) + +If you need answers before you can start, end the session by writing .cw/output/signal.json with this format: + +{ "status": "questions", "questions": [{ "id": "q1", "question": "What is the target file?" }] } + +The session will end. The user will be shown your questions in the UI. When they answer (via UI or cw errand chat), the session resumes and you will receive their answers. Use this when blocking questions need to be resolved before any work can proceed. + +Be explicit about what you need — don't make assumptions when the task is ambiguous. ## Finishing diff --git a/docs/agent.md b/docs/agent.md index 0a1898e..5ae593d 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -121,7 +121,7 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":""}}` and `config Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure. -**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. +**Steps**: look up agent → validate status (`running`|`idle`|`waiting_for_input`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling. **Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt.