Merge branch 'main' into cw/agent-details-conflict-1772802863659
# Conflicts: # docs/server-api.md
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* File-Based Agent I/O Tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
readDecisionFiles,
|
||||
readPageFiles,
|
||||
generateId,
|
||||
writeErrandManifest,
|
||||
} from './file-io.js';
|
||||
import { buildErrandPrompt } from './prompts/index.js';
|
||||
import type { Initiative, Phase, Task } from '../db/schema.js';
|
||||
|
||||
let testDir: string;
|
||||
@@ -367,3 +369,116 @@ New content for the page.
|
||||
expect(pages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeErrandManifest', () => {
|
||||
let errandTestDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`);
|
||||
mkdirSync(errandTestDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// no-op: beforeEach creates dirs, afterEach in outer scope cleans up
|
||||
});
|
||||
|
||||
it('writes manifest.json with correct shape', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json');
|
||||
expect(existsSync(manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
expect(manifest).toEqual({
|
||||
errandId: 'errand-abc',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
mode: 'errand',
|
||||
});
|
||||
expect('files' in manifest).toBe(false);
|
||||
expect('contextFiles' in manifest).toBe(false);
|
||||
});
|
||||
|
||||
it('writes errand.md with correct YAML frontmatter', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md');
|
||||
expect(existsSync(errandMdPath)).toBe(true);
|
||||
const content = readFileSync(errandMdPath, 'utf-8');
|
||||
expect(content).toContain('id: errand-abc');
|
||||
expect(content).toContain('description: fix typo');
|
||||
expect(content).toContain('branch: cw/errand/fix-typo-errandabc');
|
||||
expect(content).toContain('project: my-project');
|
||||
});
|
||||
|
||||
it('writes expected-pwd.txt with agentWorkdir path', async () => {
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: errandTestDir,
|
||||
errandId: 'errand-abc',
|
||||
description: 'fix typo',
|
||||
branch: 'cw/errand/fix-typo-errandabc',
|
||||
projectName: 'my-project',
|
||||
agentId: 'agent-1',
|
||||
agentName: 'swift-owl',
|
||||
});
|
||||
|
||||
const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt');
|
||||
expect(existsSync(pwdPath)).toBe(true);
|
||||
const content = readFileSync(pwdPath, 'utf-8').trim();
|
||||
expect(content).toBe(errandTestDir);
|
||||
});
|
||||
|
||||
it('creates input directory if it does not exist', async () => {
|
||||
const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`);
|
||||
mkdirSync(freshDir, { recursive: true });
|
||||
|
||||
await writeErrandManifest({
|
||||
agentWorkdir: freshDir,
|
||||
errandId: 'errand-xyz',
|
||||
description: 'add feature',
|
||||
branch: 'cw/errand/add-feature-errandxyz',
|
||||
projectName: 'other-project',
|
||||
agentId: 'agent-2',
|
||||
agentName: 'brave-eagle',
|
||||
});
|
||||
|
||||
expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true);
|
||||
expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true);
|
||||
expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true);
|
||||
|
||||
rmSync(freshDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildErrandPrompt', () => {
|
||||
it('includes the description in the output', () => {
|
||||
const result = buildErrandPrompt('fix typo in README');
|
||||
expect(result).toContain('fix typo in README');
|
||||
});
|
||||
|
||||
it('includes signal.json instruction', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('signal.json');
|
||||
expect(result).toContain('"status": "done"');
|
||||
});
|
||||
|
||||
it('includes error signal format', () => {
|
||||
const result = buildErrandPrompt('some change');
|
||||
expect(result).toContain('"status": "error"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise<
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ERRAND INPUT FILE WRITING
|
||||
// =============================================================================
|
||||
|
||||
export async function writeErrandManifest(options: {
|
||||
agentWorkdir: string;
|
||||
errandId: string;
|
||||
description: string;
|
||||
branch: string;
|
||||
projectName: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}): Promise<void> {
|
||||
await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true });
|
||||
|
||||
// Write errand.md first (before manifest.json)
|
||||
const errandMdContent = formatFrontmatter({
|
||||
id: options.errandId,
|
||||
description: options.description,
|
||||
branch: options.branch,
|
||||
project: options.projectName,
|
||||
});
|
||||
await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8');
|
||||
|
||||
// Write manifest.json last (after all other files exist)
|
||||
await writeFile(
|
||||
join(options.agentWorkdir, '.cw', 'input', 'manifest.json'),
|
||||
JSON.stringify({
|
||||
errandId: options.errandId,
|
||||
agentId: options.agentId,
|
||||
agentName: options.agentName,
|
||||
mode: 'errand',
|
||||
}) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Write expected-pwd.txt
|
||||
await writeFile(
|
||||
join(options.agentWorkdir, '.cw', 'expected-pwd.txt'),
|
||||
options.agentWorkdir,
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OUTPUT FILE READING
|
||||
// =============================================================================
|
||||
|
||||
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* AgentLifecycleController Tests — Regression coverage for event emissions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AgentLifecycleController } from './controller.js';
|
||||
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { SignalManager } from './signal-manager.js';
|
||||
import type { RetryPolicy } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { CleanupStrategy } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
function makeController(overrides: {
|
||||
repository?: Partial<AgentRepository>;
|
||||
accountRepository?: Partial<AccountRepository>;
|
||||
eventBus?: EventBus;
|
||||
}): AgentLifecycleController {
|
||||
const signalManager: SignalManager = {
|
||||
clearSignal: vi.fn(),
|
||||
checkSignalExists: vi.fn(),
|
||||
readSignal: vi.fn(),
|
||||
waitForSignal: vi.fn(),
|
||||
validateSignalFile: vi.fn(),
|
||||
};
|
||||
const retryPolicy: RetryPolicy = {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [1000, 2000, 4000],
|
||||
shouldRetry: vi.fn().mockReturnValue(false),
|
||||
getRetryDelay: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer;
|
||||
const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager;
|
||||
const cleanupManager = {} as unknown as CleanupManager;
|
||||
const cleanupStrategy = {
|
||||
shouldCleanup: vi.fn(),
|
||||
executeCleanup: vi.fn(),
|
||||
} as unknown as CleanupStrategy;
|
||||
|
||||
return new AgentLifecycleController(
|
||||
signalManager,
|
||||
retryPolicy,
|
||||
errorAnalyzer,
|
||||
processManager,
|
||||
overrides.repository as AgentRepository,
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
overrides.accountRepository as AccountRepository | undefined,
|
||||
false,
|
||||
overrides.eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentLifecycleController', () => {
|
||||
describe('handleAccountExhaustion', () => {
|
||||
it('emits agent:account_switched with correct payload when new account is available', async () => {
|
||||
const emittedEvents: AgentAccountSwitchedEvent[] = [];
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-1',
|
||||
name: 'test-agent',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
const newAccount = { id: 'new-account-id' };
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(newAccount),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
// Call private method via any-cast
|
||||
await (controller as any).handleAccountExhaustion('agent-1');
|
||||
|
||||
const accountSwitchedEvents = emittedEvents.filter(
|
||||
(e) => e.type === 'agent:account_switched'
|
||||
);
|
||||
expect(accountSwitchedEvents).toHaveLength(1);
|
||||
const event = accountSwitchedEvents[0];
|
||||
expect(event.type).toBe('agent:account_switched');
|
||||
expect(event.payload.agentId).toBe('agent-1');
|
||||
expect(event.payload.name).toBe('test-agent');
|
||||
expect(event.payload.previousAccountId).toBe('old-account-id');
|
||||
expect(event.payload.newAccountId).toBe('new-account-id');
|
||||
expect(event.payload.reason).toBe('account_exhausted');
|
||||
});
|
||||
|
||||
it('does not emit agent:account_switched when no new account is available', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-2',
|
||||
name: 'test-agent-2',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-2');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not emit when agent has no accountId', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn(),
|
||||
findNextAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-3');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
|
||||
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
const log = createModuleLogger('lifecycle-controller');
|
||||
|
||||
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
|
||||
private cleanupStrategy: CleanupStrategy,
|
||||
private accountRepository?: AccountRepository,
|
||||
private debug: boolean = false,
|
||||
private eventBus?: EventBus,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account exhaustion by marking account as exhausted.
|
||||
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
|
||||
*/
|
||||
private async handleAccountExhaustion(agentId: string): Promise<void> {
|
||||
if (!this.accountRepository) {
|
||||
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAccountId = agent.accountId;
|
||||
|
||||
// Mark account as exhausted for 1 hour
|
||||
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
|
||||
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
|
||||
|
||||
log.info({
|
||||
agentId,
|
||||
accountId: agent.accountId,
|
||||
accountId: previousAccountId,
|
||||
exhaustedUntil
|
||||
}, 'marked account as exhausted due to usage limits');
|
||||
|
||||
// Find the next available account and emit account_switched event
|
||||
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
|
||||
if (newAccount && this.eventBus) {
|
||||
const event: AgentAccountSwitchedEvent = {
|
||||
type: 'agent:account_switched',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: agent.name,
|
||||
previousAccountId,
|
||||
newAccountId: newAccount.id,
|
||||
reason: 'account_exhausted',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn({
|
||||
agentId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
export interface LifecycleFactoryOptions {
|
||||
repository: AgentRepository;
|
||||
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
|
||||
cleanupManager: CleanupManager;
|
||||
accountRepository?: AccountRepository;
|
||||
debug?: boolean;
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
processManager,
|
||||
cleanupManager,
|
||||
accountRepository,
|
||||
debug = false
|
||||
debug = false,
|
||||
eventBus,
|
||||
} = options;
|
||||
|
||||
// Create core components
|
||||
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
accountRepository,
|
||||
debug
|
||||
debug,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
return lifecycleController;
|
||||
|
||||
@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
cleanupManager: this.cleanupManager,
|
||||
accountRepository,
|
||||
debug,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
// Listen for process crashed events to handle agents specially
|
||||
@@ -282,14 +283,15 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
});
|
||||
const agentId = agent.id;
|
||||
|
||||
// 3a. Append inter-agent communication instructions with actual agent ID
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
// 3a. Append inter-agent communication + preview instructions (skipped for focused agents)
|
||||
if (!options.skipPromptExtras) {
|
||||
prompt = prompt + buildInterAgentCommunication(agentId, mode);
|
||||
|
||||
// 3b. Append preview deployment instructions if applicable
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
|
||||
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
|
||||
if (shouldInject) {
|
||||
prompt = prompt + buildPreviewInstructions(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +609,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
@@ -629,6 +632,73 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a user message to a running or idle errand agent.
|
||||
* Does not use the conversations table — the message is injected directly
|
||||
* as the next resume prompt for the agent's Claude Code session.
|
||||
*/
|
||||
async sendUserMessage(agentId: string, message: string): Promise<void> {
|
||||
const agent = await this.repository.findById(agentId);
|
||||
if (!agent) throw new Error(`Agent not found: ${agentId}`);
|
||||
|
||||
if (agent.status !== 'running' && agent.status !== 'idle') {
|
||||
throw new Error(`Agent is not running (status: ${agent.status})`);
|
||||
}
|
||||
|
||||
if (!agent.sessionId) {
|
||||
throw new Error('Agent has no session ID');
|
||||
}
|
||||
|
||||
const provider = getProvider(agent.provider);
|
||||
if (!provider) throw new Error(`Unknown provider: ${agent.provider}`);
|
||||
|
||||
const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId);
|
||||
|
||||
// Clear previous signal.json
|
||||
const signalPath = join(agentCwd, '.cw/output/signal.json');
|
||||
try {
|
||||
await unlink(signalPath);
|
||||
} catch {
|
||||
// File might not exist
|
||||
}
|
||||
|
||||
await this.repository.update(agentId, { status: 'running', result: null });
|
||||
|
||||
const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message);
|
||||
const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId);
|
||||
|
||||
// Stop previous tailer/poll
|
||||
const prevActive = this.activeAgents.get(agentId);
|
||||
prevActive?.cancelPoll?.();
|
||||
if (prevActive?.tailer) {
|
||||
await prevActive.tailer.stop();
|
||||
}
|
||||
|
||||
let sessionNumber = 1;
|
||||
if (this.logChunkRepository) {
|
||||
sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
|
||||
}
|
||||
|
||||
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
|
||||
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message,
|
||||
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
|
||||
this.createLogChunkCallback(agentId, agent.name, sessionNumber),
|
||||
);
|
||||
|
||||
await this.repository.update(agentId, { pid, outputFilePath });
|
||||
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath };
|
||||
this.activeAgents.set(agentId, activeEntry);
|
||||
|
||||
const { cancel } = this.processManager.pollForCompletion(
|
||||
agentId, pid,
|
||||
() => this.handleDetachedAgentCompletion(agentId),
|
||||
() => this.activeAgents.get(agentId)?.tailer,
|
||||
);
|
||||
activeEntry.cancelPoll = cancel;
|
||||
|
||||
log.info({ agentId, pid }, 'resumed errand agent for user message');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync credentials from agent's config dir back to DB after completion.
|
||||
* The subprocess may have refreshed tokens mid-session; this ensures
|
||||
@@ -796,6 +866,7 @@ export class MultiProviderAgentManager implements AgentManager {
|
||||
log.info({ agentId, pid }, 'resume detached subprocess started');
|
||||
|
||||
if (this.eventBus) {
|
||||
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
|
||||
const event: AgentResumedEvent = {
|
||||
type: 'agent:resumed',
|
||||
timestamp: new Date(),
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
|
||||
import {
|
||||
SIGNAL_FORMAT,
|
||||
SESSION_STARTUP,
|
||||
GIT_WORKFLOW,
|
||||
CONTEXT_MANAGEMENT,
|
||||
} from './shared.js';
|
||||
|
||||
export function buildConflictResolutionPrompt(
|
||||
@@ -29,7 +27,12 @@ You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` in
|
||||
${conflictList}
|
||||
</conflict_details>
|
||||
${SIGNAL_FORMAT}
|
||||
${SESSION_STARTUP}
|
||||
|
||||
<session_startup>
|
||||
1. \`pwd\` — confirm working directory
|
||||
2. \`git status\` — check branch state
|
||||
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow.
|
||||
</session_startup>
|
||||
|
||||
<resolution_protocol>
|
||||
Follow these steps in order:
|
||||
@@ -57,7 +60,6 @@ Follow these steps in order:
|
||||
8. **Signal done**: Write signal.json with status "done".
|
||||
</resolution_protocol>
|
||||
${GIT_WORKFLOW}
|
||||
${CONTEXT_MANAGEMENT}
|
||||
|
||||
<important>
|
||||
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.
|
||||
|
||||
16
apps/server/agent/prompts/errand.ts
Normal file
16
apps/server/agent/prompts/errand.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function buildErrandPrompt(description: string): string {
|
||||
return `You are working on a small, focused change in an isolated worktree.
|
||||
|
||||
Description: ${description}
|
||||
|
||||
Work interactively with the user. Make only the changes needed to fulfill the description.
|
||||
When you are done, write .cw/output/signal.json:
|
||||
|
||||
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
|
||||
|
||||
If you cannot complete the change:
|
||||
|
||||
{ "status": "error", "error": "<explanation>" }
|
||||
|
||||
Do not create any other output files.`;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export { buildDetailPrompt } from './detail.js';
|
||||
export { buildRefinePrompt } from './refine.js';
|
||||
export { buildChatPrompt } from './chat.js';
|
||||
export type { ChatHistoryEntry } from './chat.js';
|
||||
export { buildErrandPrompt } from './errand.js';
|
||||
export { buildWorkspaceLayout } from './workspace.js';
|
||||
export { buildPreviewInstructions } from './preview.js';
|
||||
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||
|
||||
@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
|
||||
</examples>
|
||||
</specificity>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your analysis — don't do everything sequentially:
|
||||
- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan.
|
||||
- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism.
|
||||
- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time.
|
||||
|
||||
Don't spawn subagents for trivial initiatives with obvious structure — use judgment.
|
||||
</subagent_usage>
|
||||
|
||||
<existing_context>
|
||||
- Account for existing phases/tasks — don't plan work already covered
|
||||
- Always generate new phase IDs — never reuse existing ones
|
||||
|
||||
@@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but
|
||||
If all pages are already clear, signal done with no output files.
|
||||
</improvement_priorities>
|
||||
|
||||
<subagent_usage>
|
||||
Use subagents to parallelize your work:
|
||||
- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially.
|
||||
- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages.
|
||||
- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages.
|
||||
|
||||
Don't over-split — if there are only 1-2 short pages, just do the work directly.
|
||||
</subagent_usage>
|
||||
|
||||
<rules>
|
||||
- Ask 2-4 questions if you need clarification
|
||||
- Preserve [[page:\$id|title]] cross-references
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface SpawnAgentOptions {
|
||||
branchName?: string;
|
||||
/** Context data to write as input files in agent workdir */
|
||||
inputContext?: AgentInputContext;
|
||||
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
|
||||
skipPromptExtras?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -202,6 +202,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return candidates[0] ?? null;
|
||||
}),
|
||||
|
||||
getTaskAgent: publicProcedure
|
||||
.input(z.object({ taskId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
const agentManager = requireAgentManager(ctx);
|
||||
const all = await agentManager.list();
|
||||
const matches = all
|
||||
.filter(a => a.taskId === input.taskId)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
return matches[0] ?? null;
|
||||
}),
|
||||
|
||||
getActiveConflictAgent: publicProcedure
|
||||
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||
@@ -225,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
getAgentOutput: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }): Promise<string> => {
|
||||
.query(async ({ ctx, input }) => {
|
||||
const agent = await resolveAgent(ctx, input);
|
||||
const logChunkRepo = requireLogChunkRepository(ctx);
|
||||
|
||||
const chunks = await logChunkRepo.findByAgentId(agent.id);
|
||||
return chunks.map(c => c.content).join('');
|
||||
return chunks.map(c => ({
|
||||
content: c.content,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
}));
|
||||
}),
|
||||
|
||||
onAgentOutput: publicProcedure
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
|
||||
initiativeId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
|
||||
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
|
||||
if (initiative.status === 'archived') {
|
||||
return { ...base, state: 'archived' };
|
||||
}
|
||||
|
||||
// Check for active conflict resolution agent — takes priority over pending_review
|
||||
// because the agent is actively working to resolve merge conflicts
|
||||
const conflictAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (conflictAgent) {
|
||||
return { ...base, state: 'resolving_conflict' };
|
||||
}
|
||||
|
||||
if (initiative.status === 'pending_review') {
|
||||
return { ...base, state: 'pending_review' };
|
||||
}
|
||||
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
|
||||
// so architect agents (discuss/plan/detail/refine) surface activity
|
||||
const activeAgent = activeArchitectAgents?.find(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& !a.name?.startsWith('conflict-')
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (activeAgent) {
|
||||
|
||||
@@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
: await repo.findAll();
|
||||
}
|
||||
|
||||
// Fetch active architect agents once for all initiatives
|
||||
// Fetch active agents once for all initiatives (architect + conflict)
|
||||
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
|
||||
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
|
||||
const activeArchitectAgents = allAgents
|
||||
.filter(a =>
|
||||
ARCHITECT_MODES.includes(a.mode ?? '')
|
||||
(ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-'))
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input')
|
||||
&& !a.userDismissedAt,
|
||||
)
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name }));
|
||||
|
||||
// Batch-fetch projects for all initiatives
|
||||
const projectRepo = ctx.projectRepository;
|
||||
const projectsByInitiativeId = new Map<string, Array<{ id: string; name: string }>>();
|
||||
if (projectRepo) {
|
||||
await Promise.all(initiatives.map(async (init) => {
|
||||
const projects = await projectRepo.findProjectsByInitiativeId(init.id);
|
||||
projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name })));
|
||||
}));
|
||||
}
|
||||
|
||||
const addProjects = (init: typeof initiatives[0]) => ({
|
||||
projects: projectsByInitiativeId.get(init.id) ?? [],
|
||||
});
|
||||
|
||||
if (ctx.phaseRepository) {
|
||||
const phaseRepo = ctx.phaseRepository;
|
||||
return Promise.all(initiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
|
||||
}));
|
||||
}
|
||||
|
||||
return initiatives.map(init => ({
|
||||
...init,
|
||||
...addProjects(init),
|
||||
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
|
||||
}));
|
||||
}),
|
||||
@@ -473,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
initiativeId: input.initiativeId,
|
||||
baseBranch: initiative.branch,
|
||||
branchName: tempBranch,
|
||||
skipPromptExtras: true,
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
||||
'chat:session_closed',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -102,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
|
||||
'phase:merged',
|
||||
'initiative:pending_review',
|
||||
'initiative:review_approved',
|
||||
'initiative:changes_requested',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
import {
|
||||
type ParsedMessage,
|
||||
type TimestampedChunk,
|
||||
getMessageStyling,
|
||||
parseAgentOutput,
|
||||
} from "@/lib/parse-agent-output";
|
||||
@@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
const [messages, setMessages] = useState<ParsedMessage[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Accumulate raw JSONL: initial query data + live subscription chunks
|
||||
const rawBufferRef = useRef<string>('');
|
||||
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
||||
const chunksRef = useRef<TimestampedChunk[]>([]);
|
||||
|
||||
// Load initial/historical output
|
||||
const outputQuery = trpc.getAgentOutput.useQuery(
|
||||
@@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
|
||||
const raw = event?.data?.data ?? event?.data;
|
||||
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
||||
rawBufferRef.current += chunk;
|
||||
setMessages(parseAgentOutput(rawBufferRef.current));
|
||||
chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }];
|
||||
setMessages(parseAgentOutput(chunksRef.current));
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Agent output subscription error:', error);
|
||||
@@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
// Set initial output when query loads
|
||||
useEffect(() => {
|
||||
if (outputQuery.data) {
|
||||
rawBufferRef.current = outputQuery.data;
|
||||
chunksRef.current = outputQuery.data;
|
||||
setMessages(parseAgentOutput(outputQuery.data));
|
||||
}
|
||||
}, [outputQuery.data]);
|
||||
|
||||
// Reset output when agent changes
|
||||
useEffect(() => {
|
||||
rawBufferRef.current = '';
|
||||
chunksRef.current = [];
|
||||
setMessages([]);
|
||||
setFollow(true);
|
||||
}, [agentId]);
|
||||
@@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-terminal p-4"
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-terminal-muted text-sm">Loading output...</div>
|
||||
) : !hasOutput ? (
|
||||
<div className="text-terminal-muted text-sm">No output yet...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 min-w-0">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index} className={getMessageStyling(message.type)}>
|
||||
{message.type === 'system' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
||||
<span className="text-xs text-terminal-muted">{message.content}</span>
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'text' && (
|
||||
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
|
||||
{message.content}
|
||||
</div>
|
||||
<>
|
||||
<Timestamp date={message.timestamp} />
|
||||
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
|
||||
{message.content}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{message.type === 'tool_call' && (
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1">
|
||||
<Badge variant="default" className="mb-1 text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'tool_result' && (
|
||||
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
|
||||
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
|
||||
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
|
||||
Result
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'error' && (
|
||||
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
|
||||
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
|
||||
<Badge variant="destructive" className="mb-1 text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
|
||||
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
{message.meta?.duration && (
|
||||
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function Timestamp({ date }: { date?: Date }) {
|
||||
if (!date) return null;
|
||||
return (
|
||||
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
|
||||
{formatTime(date)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,6 +21,7 @@ export interface SerializedInitiative {
|
||||
branch: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
projects?: Array<{ id: string; name: string }>;
|
||||
activity: {
|
||||
state: string;
|
||||
activePhase?: { id: string; name: string };
|
||||
@@ -30,11 +32,12 @@ export interface SerializedInitiative {
|
||||
|
||||
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
|
||||
switch (state) {
|
||||
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
||||
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
||||
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
|
||||
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
||||
case "refining": return { label: "Refining", variant: "active", pulse: true };
|
||||
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
||||
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
||||
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
|
||||
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
||||
case "refining": return { label: "Refining", variant: "active", pulse: true };
|
||||
case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true };
|
||||
case "ready": return { label: "Ready", variant: "active", pulse: false };
|
||||
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
|
||||
case "complete": return { label: "Complete", variant: "success", pulse: false };
|
||||
@@ -87,11 +90,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
|
||||
className="p-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Row 1: Name + overflow menu */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="min-w-0 truncate text-base font-bold">
|
||||
{initiative.name}
|
||||
</span>
|
||||
{/* Row 1: Name + project pills + overflow menu */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-base font-bold">
|
||||
{initiative.name}
|
||||
</span>
|
||||
{initiative.projects && initiative.projects.length > 0 &&
|
||||
initiative.projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant {
|
||||
case "medium":
|
||||
return "warning";
|
||||
|
||||
// Urgent / conflict resolution
|
||||
case "resolving_conflict":
|
||||
return "urgent";
|
||||
|
||||
// Error / failed
|
||||
case "crashed":
|
||||
case "blocked":
|
||||
|
||||
@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
|
||||
{resolvedActivePageId && (
|
||||
<>
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<div className="flex justify-end mb-2 h-4">
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{activePageQuery.isSuccess && (
|
||||
<input
|
||||
value={pageTitle}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
@@ -36,33 +36,33 @@ export function TiptapEditor({
|
||||
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
|
||||
onPageLinkDeletedRef.current = onPageLinkDeleted;
|
||||
|
||||
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
|
||||
|
||||
const baseExtensions = [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
BlockSelectionExtension,
|
||||
];
|
||||
|
||||
const extensions = enablePageLinks
|
||||
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
|
||||
: baseExtensions;
|
||||
const extensions = useMemo(() => {
|
||||
const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
|
||||
const base = [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
BlockSelectionExtension,
|
||||
];
|
||||
return enablePageLinks
|
||||
? [...base, PageLinkExtension, detector]
|
||||
: base;
|
||||
}, [enablePageLinks]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useCallback, useEffect, useRef, useMemo, useState } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
|
||||
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
|
||||
@@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
import { TiptapEditor } from "@/components/editor/TiptapEditor";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { getCategoryConfig } from "@/lib/category";
|
||||
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||
import { useExecutionContext } from "./ExecutionContext";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SlideOverTab = "details" | "logs";
|
||||
|
||||
interface TaskSlideOverProps {
|
||||
onOpenChat?: (target: ChatTarget) => void;
|
||||
}
|
||||
@@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
||||
const updateTaskMutation = trpc.updateTask.useMutation();
|
||||
|
||||
const [tab, setTab] = useState<SlideOverTab>("details");
|
||||
|
||||
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
|
||||
|
||||
// Reset tab when task changes
|
||||
useEffect(() => {
|
||||
setTab("details");
|
||||
}, [selectedEntry?.task?.id]);
|
||||
|
||||
// Escape key closes
|
||||
useEffect(() => {
|
||||
if (!selectedEntry) return;
|
||||
@@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-4 border-b border-border px-5">
|
||||
{(["details", "logs"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={cn(
|
||||
"relative pb-2 pt-3 text-sm font-medium transition-colors",
|
||||
tab === t
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{t === "details" ? "Details" : "Agent Logs"}
|
||||
{tab === t && (
|
||||
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Metadata grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<MetaField label="Status">
|
||||
<StatusBadge status={task.status} />
|
||||
</MetaField>
|
||||
<MetaField label="Category">
|
||||
<CategoryBadge category={task.category} />
|
||||
</MetaField>
|
||||
<MetaField label="Priority">
|
||||
<PriorityText priority={task.priority} />
|
||||
</MetaField>
|
||||
<MetaField label="Type">
|
||||
<span className="font-medium">{task.type}</span>
|
||||
</MetaField>
|
||||
<MetaField label="Agent" span={2}>
|
||||
<span className="font-medium">
|
||||
{selectedEntry.agentName ?? "Unassigned"}
|
||||
</span>
|
||||
</MetaField>
|
||||
</div>
|
||||
<div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
|
||||
{tab === "details" ? (
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* Metadata grid */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<MetaField label="Status">
|
||||
<StatusBadge status={task.status} />
|
||||
</MetaField>
|
||||
<MetaField label="Category">
|
||||
<CategoryBadge category={task.category} />
|
||||
</MetaField>
|
||||
<MetaField label="Priority">
|
||||
<PriorityText priority={task.priority} />
|
||||
</MetaField>
|
||||
<MetaField label="Type">
|
||||
<span className="font-medium">{task.type}</span>
|
||||
</MetaField>
|
||||
<MetaField label="Agent" span={2}>
|
||||
<span className="font-medium">
|
||||
{selectedEntry.agentName ?? "Unassigned"}
|
||||
</span>
|
||||
</MetaField>
|
||||
</div>
|
||||
|
||||
{/* Description — editable tiptap */}
|
||||
<Section title="Description">
|
||||
<TiptapEditor
|
||||
entityId={task.id}
|
||||
content={editorContent}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</Section>
|
||||
{/* Description — editable tiptap */}
|
||||
<Section title="Description">
|
||||
<TiptapEditor
|
||||
entityId={task.id}
|
||||
content={editorContent}
|
||||
onUpdate={handleDescriptionUpdate}
|
||||
enablePageLinks={false}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Dependencies */}
|
||||
<Section title="Blocked By">
|
||||
{dependencies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependencies.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{/* Dependencies */}
|
||||
<Section title="Blocked By">
|
||||
{dependencies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependencies.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Blocks */}
|
||||
<Section title="Blocks">
|
||||
{dependents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependents.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
{/* Blocks */}
|
||||
<Section title="Blocks">
|
||||
{dependents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">None</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{dependents.map((dep) => (
|
||||
<li
|
||||
key={dep.name}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<StatusDot status={dep.status} size="sm" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{dep.name}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
) : (
|
||||
<AgentLogsTab taskId={task.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -293,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Logs Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentLogsTab({ taskId }: { taskId: string }) {
|
||||
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
|
||||
{ taskId },
|
||||
{ refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
No agent has been assigned to this task yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentOutputViewer
|
||||
agentId={agent.id}
|
||||
agentName={agent.name ?? undefined}
|
||||
status={agent.status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
export { useAutoSave } from './useAutoSave.js';
|
||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||
export { useLiveUpdates, INITIATIVE_LIST_RULES } from './useLiveUpdates.js';
|
||||
export type { LiveUpdateRule } from './useLiveUpdates.js';
|
||||
export { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useConflictAgent } from './useConflictAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
@@ -15,6 +15,18 @@ export interface LiveUpdateRule {
|
||||
*
|
||||
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
|
||||
*/
|
||||
/**
|
||||
* Reusable rules for any page displaying initiative cards.
|
||||
* Covers all event prefixes that can change derived initiative activity state.
|
||||
*/
|
||||
export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [
|
||||
{ prefix: 'initiative:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'task:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'agent:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'merge:', invalidate: ['listInitiatives'] },
|
||||
];
|
||||
|
||||
export function useLiveUpdates(rules: LiveUpdateRule[]) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
|
||||
@@ -44,17 +44,21 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
spawnArchitectDiscuss: ["listAgents"],
|
||||
spawnArchitectPlan: ["listAgents"],
|
||||
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
|
||||
spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"],
|
||||
|
||||
// --- Initiatives ---
|
||||
createInitiative: ["listInitiatives"],
|
||||
updateInitiative: ["listInitiatives", "getInitiative"],
|
||||
updateInitiativeProjects: ["getInitiative"],
|
||||
approveInitiativeReview: ["listInitiatives", "getInitiative"],
|
||||
requestInitiativeChanges: ["listInitiatives", "getInitiative"],
|
||||
|
||||
// --- Phases ---
|
||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
|
||||
updatePhase: ["listPhases", "getPhase"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||
approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"],
|
||||
requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"],
|
||||
queuePhase: ["listPhases"],
|
||||
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
|
||||
@@ -71,7 +75,11 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
|
||||
|
||||
// --- Pages ---
|
||||
updatePage: ["listPages", "getPage", "getRootPage"],
|
||||
// NOTE: getPage omitted — useAutoSave handles optimistic updates for the
|
||||
// active page, and SSE `page:updated` events cover external changes.
|
||||
// Including getPage here caused double-invalidation (mutation + SSE) and
|
||||
// refetch storms that flickered the editor.
|
||||
updatePage: ["listPages", "getRootPage"],
|
||||
createPage: ["listPages", "getRootPage"],
|
||||
deletePage: ["listPages", "getRootPage"],
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ParsedMessage {
|
||||
| "session_end"
|
||||
| "error";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
isError?: boolean;
|
||||
@@ -60,108 +61,135 @@ export function getMessageStyling(type: ParsedMessage["type"]): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAgentOutput(raw: string): ParsedMessage[] {
|
||||
const lines = raw.split("\n").filter(Boolean);
|
||||
/**
|
||||
* A chunk of raw JSONL content with an optional timestamp from the DB.
|
||||
*/
|
||||
export interface TimestampedChunk {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
|
||||
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
|
||||
*/
|
||||
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
|
||||
const chunks: { content: string; timestamp?: Date }[] =
|
||||
typeof raw === "string"
|
||||
? [{ content: raw }]
|
||||
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
|
||||
|
||||
const parsedMessages: ParsedMessage[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
for (const chunk of chunks) {
|
||||
const lines = chunk.content.split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
// System initialization
|
||||
if (event.type === "system" && event.session_id) {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `Session started: ${event.session_id}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Assistant messages with text and tool calls
|
||||
else if (
|
||||
event.type === "assistant" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: block.text,
|
||||
});
|
||||
} else if (block.type === "tool_use") {
|
||||
parsedMessages.push({
|
||||
type: "tool_call",
|
||||
content: formatToolCall(block),
|
||||
meta: { toolName: block.name },
|
||||
});
|
||||
}
|
||||
// System initialization
|
||||
if (event.type === "system" && event.session_id) {
|
||||
parsedMessages.push({
|
||||
type: "system",
|
||||
content: `Session started: ${event.session_id}`,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// User messages with tool results
|
||||
else if (
|
||||
event.type === "user" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
const rawContent = block.content;
|
||||
const output =
|
||||
typeof rawContent === "string"
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.map((c: any) => c.text ?? JSON.stringify(c))
|
||||
.join("\n")
|
||||
: (event.tool_use_result?.stdout || "");
|
||||
const stderr = event.tool_use_result?.stderr;
|
||||
|
||||
if (stderr) {
|
||||
// Assistant messages with text and tool calls
|
||||
else if (
|
||||
event.type === "assistant" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: stderr,
|
||||
meta: { isError: true },
|
||||
type: "text",
|
||||
content: block.text,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
} else if (output) {
|
||||
const displayOutput =
|
||||
output.length > 1000
|
||||
? output.substring(0, 1000) + "\n... (truncated)"
|
||||
: output;
|
||||
} else if (block.type === "tool_use") {
|
||||
parsedMessages.push({
|
||||
type: "tool_result",
|
||||
content: displayOutput,
|
||||
type: "tool_call",
|
||||
content: formatToolCall(block),
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { toolName: block.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
// User messages with tool results
|
||||
else if (
|
||||
event.type === "user" &&
|
||||
Array.isArray(event.message?.content)
|
||||
) {
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "tool_result") {
|
||||
const rawContent = block.content;
|
||||
const output =
|
||||
typeof rawContent === "string"
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.map((c: any) => c.text ?? JSON.stringify(c))
|
||||
.join("\n")
|
||||
: (event.tool_use_result?.stdout || "");
|
||||
const stderr = event.tool_use_result?.stderr;
|
||||
|
||||
if (stderr) {
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: stderr,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { isError: true },
|
||||
});
|
||||
} else if (output) {
|
||||
const displayOutput =
|
||||
output.length > 1000
|
||||
? output.substring(0, 1000) + "\n... (truncated)"
|
||||
: output;
|
||||
parsedMessages.push({
|
||||
type: "tool_result",
|
||||
content: displayOutput,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy streaming format
|
||||
else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: event.event.delta.text,
|
||||
timestamp: chunk.timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
// Session completion
|
||||
else if (event.type === "result") {
|
||||
parsedMessages.push({
|
||||
type: "session_end",
|
||||
content: event.is_error ? "Session failed" : "Session completed",
|
||||
timestamp: chunk.timestamp,
|
||||
meta: {
|
||||
isError: event.is_error,
|
||||
cost: event.total_cost_usd,
|
||||
duration: event.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
parsedMessages.push({
|
||||
type: "text",
|
||||
content: event.event.delta.text,
|
||||
type: "error",
|
||||
content: line,
|
||||
timestamp: chunk.timestamp,
|
||||
meta: { isError: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Session completion
|
||||
else if (event.type === "result") {
|
||||
parsedMessages.push({
|
||||
type: "session_end",
|
||||
content: event.is_error ? "Session failed" : "Session completed",
|
||||
meta: {
|
||||
isError: event.is_error,
|
||||
cost: event.total_cost_usd,
|
||||
duration: event.duration_ms,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
parsedMessages.push({
|
||||
type: "error",
|
||||
content: line,
|
||||
meta: { isError: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
return parsedMessages;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { motion } from "motion/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
@@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { ReviewTab } from "@/components/review";
|
||||
import { PipelineTab } from "@/components/pipeline";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
import type { LiveUpdateRule } from "@/hooks";
|
||||
|
||||
type Tab = "content" | "plan" | "execution" | "review";
|
||||
const TABS: Tab[] = ["content", "plan", "execution", "review"];
|
||||
@@ -27,15 +29,17 @@ function InitiativeDetailPage() {
|
||||
const { tab: activeTab } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Single SSE stream for all live updates
|
||||
useLiveUpdates([
|
||||
// Single SSE stream for all live updates — memoized to avoid re-subscribe on render
|
||||
const liveUpdateRules = useMemo<LiveUpdateRule[]>(() => [
|
||||
{ prefix: 'initiative:', invalidate: ['getInitiative'] },
|
||||
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
|
||||
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
|
||||
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] },
|
||||
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
|
||||
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
|
||||
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
|
||||
]);
|
||||
], []);
|
||||
useLiveUpdates(liveUpdateRules);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InitiativeList } from "@/components/InitiativeList";
|
||||
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
|
||||
import { useLiveUpdates } from "@/hooks";
|
||||
import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/")({
|
||||
@@ -29,10 +29,7 @@ function DashboardPage() {
|
||||
const projectsQuery = trpc.listProjects.useQuery();
|
||||
|
||||
// Single SSE stream for live updates
|
||||
useLiveUpdates([
|
||||
{ prefix: 'task:', invalidate: ['listInitiatives'] },
|
||||
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
|
||||
]);
|
||||
useLiveUpdates(INITIATIVE_LIST_RULES);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
Reference in New Issue
Block a user