feat: Add persistent chat sessions for iterative phase/task refinement
Introduces a chat loop where users send instructions to an agent that applies changes (create/update/delete phases, tasks, pages) and stays alive for follow-up messages. Includes schema + migration, repository layer, chat prompt, file-io action field extension, output handler chat mode, revert support for deletes, tRPC procedures, events, frontend slide-over UI with inline changeset display and revert, and docs.
This commit is contained in:
@@ -37,6 +37,7 @@ export interface ParsedPhaseFile {
|
||||
title: string;
|
||||
dependencies: string[];
|
||||
body: string;
|
||||
action?: 'create' | 'update' | 'delete';
|
||||
}
|
||||
|
||||
export interface ParsedTaskFile {
|
||||
@@ -46,6 +47,9 @@ export interface ParsedTaskFile {
|
||||
type: string;
|
||||
dependencies: string[];
|
||||
body: string;
|
||||
action?: 'create' | 'update' | 'delete';
|
||||
phaseId?: string;
|
||||
parentTaskId?: string;
|
||||
}
|
||||
|
||||
export interface ParsedDecisionFile {
|
||||
@@ -61,6 +65,7 @@ export interface ParsedPageFile {
|
||||
title: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
action?: 'create' | 'update' | 'delete';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -324,11 +329,13 @@ export function readPhaseFiles(agentWorkdir: string): ParsedPhaseFile[] {
|
||||
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
||||
const id = filename.replace(/\.md$/, '');
|
||||
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
|
||||
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
|
||||
return {
|
||||
id,
|
||||
title: String(data.title ?? ''),
|
||||
dependencies: deps,
|
||||
body,
|
||||
action,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -338,6 +345,7 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
|
||||
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
||||
const id = filename.replace(/\.md$/, '');
|
||||
const deps = Array.isArray(data.dependencies) ? data.dependencies.map(String) : [];
|
||||
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
|
||||
return {
|
||||
id,
|
||||
title: String(data.title ?? ''),
|
||||
@@ -345,6 +353,9 @@ export function readTaskFiles(agentWorkdir: string): ParsedTaskFile[] {
|
||||
type: String(data.type ?? 'auto'),
|
||||
dependencies: deps,
|
||||
body,
|
||||
action,
|
||||
phaseId: data.phaseId ? String(data.phaseId) : undefined,
|
||||
parentTaskId: data.parentTaskId ? String(data.parentTaskId) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -367,11 +378,13 @@ export function readPageFiles(agentWorkdir: string): ParsedPageFile[] {
|
||||
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
|
||||
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
||||
const pageId = filename.replace(/\.md$/, '');
|
||||
const action = String(data.action ?? 'create') as 'create' | 'update' | 'delete';
|
||||
return {
|
||||
pageId,
|
||||
title: String(data.title ?? ''),
|
||||
summary: String(data.summary ?? ''),
|
||||
body,
|
||||
action,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import { generateUniqueAlias } from './alias.js';
|
||||
import type {
|
||||
EventBus,
|
||||
@@ -81,11 +82,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
private logChunkRepository?: LogChunkRepository,
|
||||
private debug: boolean = false,
|
||||
processManagerOverride?: ProcessManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
) {
|
||||
this.signalManager = new FileSystemSignalManager();
|
||||
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
|
||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager);
|
||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
|
||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||
this.lifecycleController = createLifecycleController({
|
||||
repository,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/reposi
|
||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||
import type {
|
||||
EventBus,
|
||||
AgentStoppedEvent,
|
||||
@@ -90,6 +91,7 @@ export class OutputHandler {
|
||||
private taskRepository?: TaskRepository,
|
||||
private pageRepository?: PageRepository,
|
||||
private signalManager?: SignalManager,
|
||||
private chatSessionRepository?: ChatSessionRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -390,6 +392,10 @@ export class OutputHandler {
|
||||
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
|
||||
break;
|
||||
case 'questions':
|
||||
// Chat mode: process output files before handling questions
|
||||
if (mode === 'chat') {
|
||||
await this.processOutputFiles(agentId, agent, mode, getAgentWorkdir);
|
||||
}
|
||||
await this.handleQuestions(agentId, agent, signal.questions, sessionId);
|
||||
break;
|
||||
case 'error':
|
||||
@@ -655,6 +661,194 @@ export class OutputHandler {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'chat': {
|
||||
const chatPhases = readPhaseFiles(agentWorkdir);
|
||||
const chatTasks = readTaskFiles(agentWorkdir);
|
||||
const chatPages = readPageFiles(agentWorkdir);
|
||||
if (canWriteChangeSets) {
|
||||
const entries: CreateChangeSetEntryData[] = [];
|
||||
let sortOrd = 0;
|
||||
|
||||
// Process phases
|
||||
if (this.phaseRepository) {
|
||||
for (const p of chatPhases) {
|
||||
try {
|
||||
const action = p.action ?? 'create';
|
||||
if (action === 'create') {
|
||||
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
|
||||
const created = await this.phaseRepository.create({
|
||||
id: p.id ?? undefined,
|
||||
initiativeId: initiativeId!,
|
||||
name: p.title,
|
||||
content: tiptapContent,
|
||||
});
|
||||
entries.push({ entityType: 'phase', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
|
||||
} else if (action === 'update') {
|
||||
const existing = await this.phaseRepository.findById(p.id);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
const tiptapContent = p.body ? JSON.stringify(markdownToTiptapJson(p.body)) : undefined;
|
||||
await this.phaseRepository.update(p.id, { name: p.title, content: tiptapContent });
|
||||
const updated = await this.phaseRepository.findById(p.id);
|
||||
entries.push({ entityType: 'phase', entityId: p.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
|
||||
} else if (action === 'delete') {
|
||||
const existing = await this.phaseRepository.findById(p.id);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
await this.phaseRepository.delete(p.id);
|
||||
entries.push({ entityType: 'phase', entityId: p.id, action: 'delete', previousState, sortOrder: sortOrd++ });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, phase: p.title, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat phase');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process tasks — two pass (create first, then deps)
|
||||
if (this.taskRepository) {
|
||||
const fileIdToDbId = new Map<string, string>();
|
||||
for (const t of chatTasks) {
|
||||
try {
|
||||
const action = t.action ?? 'create';
|
||||
if (action === 'create') {
|
||||
const created = await this.taskRepository.create({
|
||||
initiativeId: initiativeId!,
|
||||
phaseId: t.phaseId ?? null,
|
||||
parentTaskId: t.parentTaskId ?? null,
|
||||
name: t.title,
|
||||
description: t.body ?? undefined,
|
||||
category: (t.category as any) ?? 'execute',
|
||||
type: (t.type as any) ?? 'auto',
|
||||
});
|
||||
fileIdToDbId.set(t.id, created.id);
|
||||
entries.push({ entityType: 'task', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
|
||||
} else if (action === 'update') {
|
||||
const existing = await this.taskRepository.findById(t.id);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
await this.taskRepository.update(t.id, {
|
||||
name: t.title,
|
||||
description: t.body ?? undefined,
|
||||
category: (t.category as any) ?? existing.category,
|
||||
type: (t.type as any) ?? existing.type,
|
||||
});
|
||||
const updated = await this.taskRepository.findById(t.id);
|
||||
fileIdToDbId.set(t.id, t.id);
|
||||
entries.push({ entityType: 'task', entityId: t.id, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
|
||||
} else if (action === 'delete') {
|
||||
const existing = await this.taskRepository.findById(t.id);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
await this.taskRepository.delete(t.id);
|
||||
entries.push({ entityType: 'task', entityId: t.id, action: 'delete', previousState, sortOrder: sortOrd++ });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, task: t.title, action: t.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat task');
|
||||
}
|
||||
}
|
||||
// Second pass: deps for created tasks
|
||||
for (const t of chatTasks) {
|
||||
if (t.action !== 'create' || t.dependencies.length === 0) continue;
|
||||
const taskDbId = fileIdToDbId.get(t.id);
|
||||
if (!taskDbId) continue;
|
||||
for (const depFileId of t.dependencies) {
|
||||
const depDbId = fileIdToDbId.get(depFileId) ?? depFileId;
|
||||
try {
|
||||
await this.taskRepository.createDependency(taskDbId, depDbId);
|
||||
entries.push({ entityType: 'task_dependency', entityId: `${taskDbId}:${depDbId}`, action: 'create', newState: JSON.stringify({ taskId: taskDbId, dependsOnTaskId: depDbId }), sortOrder: sortOrd++ });
|
||||
} catch (err) {
|
||||
log.warn({ agentId, taskDbId, depFileId, err: err instanceof Error ? err.message : String(err) }, 'failed to create chat task dependency');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process pages
|
||||
if (this.pageRepository) {
|
||||
for (const p of chatPages) {
|
||||
try {
|
||||
const action = p.action ?? 'create';
|
||||
if (action === 'create') {
|
||||
const tiptapJson = markdownToTiptapJson(p.body || '');
|
||||
const created = await this.pageRepository.create({
|
||||
id: p.pageId ?? undefined,
|
||||
initiativeId: initiativeId!,
|
||||
title: p.title,
|
||||
content: JSON.stringify(tiptapJson),
|
||||
});
|
||||
entries.push({ entityType: 'page', entityId: created.id, action: 'create', newState: JSON.stringify(created), sortOrder: sortOrd++ });
|
||||
} else if (action === 'update') {
|
||||
const existing = await this.pageRepository.findById(p.pageId);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
const tiptapJson = markdownToTiptapJson(p.body || '');
|
||||
await this.pageRepository.update(p.pageId, { content: JSON.stringify(tiptapJson), title: p.title });
|
||||
const updated = await this.pageRepository.findById(p.pageId);
|
||||
entries.push({ entityType: 'page', entityId: p.pageId, action: 'update', previousState, newState: JSON.stringify(updated), sortOrder: sortOrd++ });
|
||||
} else if (action === 'delete') {
|
||||
const existing = await this.pageRepository.findById(p.pageId);
|
||||
if (!existing) continue;
|
||||
const previousState = JSON.stringify(existing);
|
||||
await this.pageRepository.delete(p.pageId);
|
||||
entries.push({ entityType: 'page', entityId: p.pageId, action: 'delete', previousState, sortOrder: sortOrd++ });
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, pageId: p.pageId, action: p.action, err: err instanceof Error ? err.message : String(err) }, 'failed to process chat page');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create change set
|
||||
let changeSetId: string | null = null;
|
||||
if (entries.length > 0) {
|
||||
try {
|
||||
const cs = await this.changeSetRepository!.createWithEntries({
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
initiativeId: initiativeId!,
|
||||
mode: 'chat' as 'plan' | 'detail' | 'refine',
|
||||
summary: summary?.body ?? `Chat: ${entries.length} changes applied`,
|
||||
}, entries);
|
||||
changeSetId = cs.id;
|
||||
this.eventBus?.emit({
|
||||
type: 'changeset:created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { changeSetId: cs.id, initiativeId, agentId, mode: 'chat', entryCount: entries.length },
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to record chat change set');
|
||||
}
|
||||
}
|
||||
|
||||
// Store assistant message in chat session
|
||||
if (this.chatSessionRepository) {
|
||||
try {
|
||||
const session = await this.chatSessionRepository.findActiveSessionByAgentId(agentId);
|
||||
if (session) {
|
||||
const assistantContent = summary?.body ?? `Applied ${entries.length} changes`;
|
||||
await this.chatSessionRepository.createMessage({
|
||||
chatSessionId: session.id,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
changeSetId,
|
||||
});
|
||||
this.eventBus?.emit({
|
||||
type: 'chat:message_created' as const,
|
||||
timestamp: new Date(),
|
||||
payload: { chatSessionId: session.id, role: 'assistant' },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to store chat assistant message');
|
||||
}
|
||||
}
|
||||
|
||||
resultMessage = summary?.body ?? `${entries.length} changes applied`;
|
||||
} else {
|
||||
resultMessage = JSON.stringify({ summary: summary?.body, phases: chatPhases, tasks: chatTasks, pages: chatPages });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const resultPayload: AgentResult = {
|
||||
@@ -738,6 +932,7 @@ export class OutputHandler {
|
||||
case 'plan': return 'plan_complete';
|
||||
case 'detail': return 'detail_complete';
|
||||
case 'refine': return 'refine_complete';
|
||||
case 'chat': return 'chat_complete';
|
||||
default: return 'task_complete';
|
||||
}
|
||||
}
|
||||
|
||||
71
apps/server/agent/prompts/chat.ts
Normal file
71
apps/server/agent/prompts/chat.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Chat mode prompt — iterative conversation for refining phases/tasks/pages.
|
||||
*/
|
||||
|
||||
import { INPUT_FILES, SIGNAL_FORMAT, ID_GENERATION } from './shared.js';
|
||||
|
||||
export interface ChatHistoryEntry {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function buildChatPrompt(
|
||||
targetType: 'phase' | 'task',
|
||||
chatHistory: ChatHistoryEntry[],
|
||||
userInstruction: string,
|
||||
): string {
|
||||
const historyBlock = chatHistory.length > 0
|
||||
? `<chat_history>\n${chatHistory.map(m => `[${m.role}]: ${m.content}`).join('\n\n')}\n</chat_history>`
|
||||
: '';
|
||||
|
||||
return `<role>
|
||||
You are an Architect agent in chat mode. You iteratively refine ${targetType} structure and content through conversation with the user. You do NOT write code.
|
||||
</role>
|
||||
${INPUT_FILES}
|
||||
${ID_GENERATION}
|
||||
${SIGNAL_FORMAT}
|
||||
|
||||
${historyBlock}
|
||||
|
||||
<current_instruction>
|
||||
${userInstruction}
|
||||
</current_instruction>
|
||||
|
||||
<output_format>
|
||||
Write output files to \`.cw/output/\` with YAML frontmatter including an \`action\` field:
|
||||
|
||||
**Phases** — \`.cw/output/phases/{id}.md\`:
|
||||
- \`action: create\` — new phase. Filename = new ID from \`cw id\`.
|
||||
- \`action: update\` — modify existing. Filename = existing phase ID.
|
||||
- \`action: delete\` — remove. Filename = existing phase ID. Body can be empty.
|
||||
- Frontmatter: \`title\`, \`action\`, \`dependencies\` (array of phase IDs)
|
||||
- Body: Phase description in markdown
|
||||
|
||||
**Tasks** — \`.cw/output/tasks/{id}.md\`:
|
||||
- \`action: create\` — new task. Filename = new ID from \`cw id\`.
|
||||
- \`action: update\` — modify existing. Filename = existing task ID.
|
||||
- \`action: delete\` — remove. Filename = existing task ID. Body can be empty.
|
||||
- Frontmatter: \`title\`, \`action\`, \`category\`, \`type\`, \`dependencies\` (array of task IDs), \`phaseId\`, \`parentTaskId\`
|
||||
- Body: Task description in markdown
|
||||
|
||||
**Pages** — \`.cw/output/pages/{pageId}.md\`:
|
||||
- \`action: create\` — new page. Filename = new ID from \`cw id\`.
|
||||
- \`action: update\` — modify existing. Filename = existing page ID.
|
||||
- \`action: delete\` — remove. Filename = existing page ID. Body can be empty.
|
||||
- Frontmatter: \`title\`, \`action\`, \`summary\` (what changed)
|
||||
- Body: Full replacement markdown content
|
||||
</output_format>
|
||||
|
||||
<summary_file>
|
||||
After writing output files, write \`.cw/output/SUMMARY.md\` with a brief description of what you changed and why.
|
||||
</summary_file>
|
||||
|
||||
<rules>
|
||||
- After applying changes, ALWAYS signal "questions" with: \`{ "status": "questions", "questions": [{ "id": "next", "question": "What would you like to do next?" }] }\`
|
||||
- Only signal "done" when the user explicitly says they are finished
|
||||
- Only signal "error" for unrecoverable problems
|
||||
- Apply the minimal set of changes needed for the user's instruction
|
||||
- Preserve existing entity IDs — only use \`cw id\` for new entities
|
||||
- When updating, only include changed fields in frontmatter (plus required \`action\` and \`title\`)
|
||||
</rules>`;
|
||||
}
|
||||
@@ -11,4 +11,6 @@ export { buildDiscussPrompt } from './discuss.js';
|
||||
export { buildPlanPrompt } from './plan.js';
|
||||
export { buildDetailPrompt } from './detail.js';
|
||||
export { buildRefinePrompt } from './refine.js';
|
||||
export { buildChatPrompt } from './chat.js';
|
||||
export type { ChatHistoryEntry } from './chat.js';
|
||||
export { buildWorkspaceLayout } from './workspace.js';
|
||||
|
||||
@@ -15,7 +15,7 @@ export type AgentStatus = 'idle' | 'running' | 'waiting_for_input' | 'stopped' |
|
||||
* - plan: Plan initiative into phases
|
||||
* - detail: Detail phase into individual tasks
|
||||
*/
|
||||
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine';
|
||||
export type AgentMode = 'execute' | 'discuss' | 'plan' | 'detail' | 'refine' | 'chat';
|
||||
|
||||
/**
|
||||
* Context data written as input files in agent workdir before spawn.
|
||||
|
||||
Reference in New Issue
Block a user