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 <noreply@anthropic.com>
This commit is contained in:
Lukas May
2026-03-06 14:54:53 +01:00
parent e2c489dc48
commit 41c5d292bb
5 changed files with 52 additions and 4 deletions

View File

@@ -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);
});
});

View File

@@ -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');

View File

@@ -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})`);
}

View File

@@ -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 <id> "<your answer>"
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

View File

@@ -121,7 +121,7 @@ Stored as `credentials: {"claudeAiOauth":{"accessToken":"<token>"}}` 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.