Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 46 additions & 24 deletions packages/server/src/agents/AgentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,21 @@ import { writeFileSync, existsSync, readFileSync } from 'node:fs';
import { RUNTIME_REGISTRY } from './runtimes.js';
import { join, resolve, dirname } from 'node:path';
import { log, truncate } from '../utils/logger.js';
import { formatTs } from '../utils/time.js';
import { fileURLToPath } from 'node:url';

/** Who a steered message is from — controls envelope + DM attribution. */
export type MessageSender =
| { type: 'user'; id?: string | null; source?: string }
| { type: 'agent'; id: string; source?: string }
| { type: 'system'; id?: string | null; source?: string };

export interface SendToAgentOptions {
sender?: MessageSender;
/** Skip persisting the DM (caller already stored it with richer context). */
skipStore?: boolean;
}

export interface SpawnAgentOptions {
role: AgentRole;
model?: string;
Expand Down Expand Up @@ -498,7 +511,34 @@ export class AgentManager {
}


async interruptAgent(agentId: AgentId, message: string): Promise<void> {
/**
* Wrap a message in the same attributed envelope the Lead receives, so the
* agent can tell a user message from a system notice. Without this, agent
* messages from the web dashboard arrived as bare text — indistinguishable
* from system notifications.
*/
private buildSteerEnvelope(message: string, sender?: MessageSender): string {
if (!sender || sender.type === 'system') return message;
const header = sender.type === 'user'
? `[${formatTs()}] [USER]\nsource: ${sender.source ?? 'web-dashboard'}`
: `[${formatTs()}] [AGENT ${sender.id}]\nsource: ${sender.source ?? 'direct_message'}`;
return `${header}\n---\n${message}`;
}

/** Persist + broadcast the outgoing DM with proper author attribution. */
private storeOutgoingDm(agentId: AgentId, message: string, sender?: MessageSender): void {
if (!this.messageStore) return;
const dmMsg = this.messageStore.createMessage({
parentId: null, taskId: null,
authorType: sender?.type ?? 'system',
authorId: sender?.type === 'user' ? (sender.id ?? 'user') : sender?.id ?? null,
content: message.length > 4000 ? message.slice(0, 4000) + '\n…[truncated]' : message,
metadata: null, channel: `dm:${agentId}`, recipient: agentId,
});
if (this.onDmMessage) this.onDmMessage(this.projectName, dmMsg);
}

async interruptAgent(agentId: AgentId, message: string, opts?: SendToAgentOptions): Promise<void> {
log('AgentMgr', `Steer (interrupt) ${agentId}: "${truncate(message)}"`);
const agent = this.store.getAgent(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
Expand All @@ -511,22 +551,13 @@ export class AgentManager {
const sessionId = this.agentToSession.get(agentId) ?? agent.acpSessionId;
if (!sessionId) throw new Error(`No active session for agent: ${agentId}`);

// Store the outgoing steer message
if (this.messageStore) {
const dmMsg = this.messageStore.createMessage({
parentId: null, taskId: null,
authorType: 'system', authorId: null,
content: message.length > 4000 ? message.slice(0, 4000) + '\n…[truncated]' : message,
metadata: null, channel: `dm:${agentId}`, recipient: agentId,
});
if (this.onDmMessage) this.onDmMessage(this.projectName, dmMsg);
}
if (!opts?.skipStore) this.storeOutgoingDm(agentId, message, opts?.sender);

await this.adapter.steer(sessionId, { content: message, urgent: true });
await this.adapter.steer(sessionId, { content: this.buildSteerEnvelope(message, opts?.sender), urgent: true });
}

/** Send a non-urgent message to an agent (queued, delivered after current turn) */
async sendToAgent(agentId: AgentId, message: string): Promise<void> {
async sendToAgent(agentId: AgentId, message: string, opts?: SendToAgentOptions): Promise<void> {
log('AgentMgr', `Send to ${agentId}: "${truncate(message)}"`);
const agent = this.store.getAgent(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
Expand All @@ -536,18 +567,9 @@ export class AgentManager {
const sessionId = this.agentToSession.get(agentId) ?? agent.acpSessionId;
if (!sessionId) throw new Error(`No active session for agent: ${agentId}`);

// Store the outgoing steer message
if (this.messageStore) {
const dmMsg = this.messageStore.createMessage({
parentId: null, taskId: null,
authorType: 'system', authorId: null,
content: message.length > 4000 ? message.slice(0, 4000) + '\n…[truncated]' : message,
metadata: null, channel: `dm:${agentId}`, recipient: agentId,
});
if (this.onDmMessage) this.onDmMessage(this.projectName, dmMsg);
}
if (!opts?.skipStore) this.storeOutgoingDm(agentId, message, opts?.sender);

await this.adapter.steer(sessionId, { content: message, urgent: false });
await this.adapter.steer(sessionId, { content: this.buildSteerEnvelope(message, opts?.sender), urgent: false });
this.audit(agentId, agent.role, 'agent:steer', `Steered ${agent.role}`, { messageLength: message.length });
}

Expand Down
22 changes: 20 additions & 2 deletions packages/server/src/api/routes/agents.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { AgentRole } from '@flightdeck-ai/shared';
import type { ProjectScopedDeps } from './types.js';

/** Normalize a possibly-duplicated header value to a single string (or undefined). */
function headerValue(v: string | string[] | undefined): string | undefined {
return Array.isArray(v) ? v[0] : v;
}

export async function handleAgentRoutes(
subPath: string, method: string,
deps: ProjectScopedDeps,
Expand Down Expand Up @@ -92,7 +97,14 @@ export async function handleAgentRoutes(
try {
const body = await readBody();
if (!body.message) { json(400, { error: 'Missing required field: message' }); return true; }
await am.interruptAgent(agentId as import('@flightdeck-ai/shared').AgentId, body.message);
// Attribute correctly: agent-originated calls (MCP tools) carry
// X-Agent-Id; everything else is the user on the web dashboard.
// Previously the message arrived as a bare system notice.
const callerId = headerValue(req.headers['x-agent-id']);
const sender = callerId
? { type: 'agent' as const, id: callerId, source: 'direct_message' }
: { type: 'user' as const, source: 'web-dashboard' };
await am.interruptAgent(agentId as import('@flightdeck-ai/shared').AgentId, body.message, { sender });
json(200, { success: true });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
Expand All @@ -109,7 +121,13 @@ export async function handleAgentRoutes(
try {
const body = await readBody();
if (!body.message) { json(400, { error: 'Missing required field: message' }); return true; }
await am.sendToAgent(agentId as import('@flightdeck-ai/shared').AgentId, body.message);
// Attribute correctly: agent-originated calls (MCP tools) carry
// X-Agent-Id; everything else is the user on the web dashboard.
const sendCallerId = headerValue(req.headers['x-agent-id']);
const sendSender = sendCallerId
? { type: 'agent' as const, id: sendCallerId, source: 'direct_message' }
: { type: 'user' as const, source: 'web-dashboard' };
await am.sendToAgent(agentId as import('@flightdeck-ai/shared').AgentId, body.message, { sender: sendSender });
json(200, { success: true });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/api/routes/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ export async function handleMessageRoutes(
if (am) {
const targetAgent = fd.sqlite.getAgent(targetTo as import('@flightdeck-ai/shared').AgentId);
if (targetAgent?.acpSessionId) {
am.sendToAgent(targetTo as import('@flightdeck-ai/shared').AgentId, body.content as string).catch(() => {});
// skipStore: the DM was already persisted above with reply context —
// sendToAgent used to store a SECOND copy attributed to 'system'
am.sendToAgent(targetTo as import('@flightdeck-ai/shared').AgentId, body.content as string,
{ sender: { type: 'agent', id: agentId, source: 'direct_message' }, skipStore: true }).catch(() => {});
}
}
}
Expand All @@ -298,7 +301,10 @@ export async function handleMessageRoutes(
if (sub === agentId) continue; // don't echo to sender
const subAgent = fd.sqlite.getAgent(sub as import('@flightdeck-ai/shared').AgentId);
if (subAgent?.acpSessionId) {
am.sendToAgent(sub as import('@flightdeck-ai/shared').AgentId, `[#${body.channel} from ${agentId}]: ${body.content}`).catch(() => {});
// Keep the [#channel from x] prefix (it is the stored DM's channel
// context) but attribute the author correctly instead of 'system'
am.sendToAgent(sub as import('@flightdeck-ai/shared').AgentId, `[#${body.channel} from ${agentId}]: ${body.content}`,
{ sender: { type: 'agent', id: agentId, source: `#${body.channel}` } }).catch(() => {});
}
}
}
Expand Down
22 changes: 1 addition & 21 deletions packages/server/src/lead/LeadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,7 @@ import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { log, truncate } from '../utils/logger.js';
import { loadGlobalConfig } from '../config/GlobalConfig.js';

/** Format timestamp in user's timezone as ISO with offset */
function formatTs(): string {
try {
const gc = loadGlobalConfig() as any;
if (gc.timezone) {
const tz = gc.timezone;
const d = new Date();
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
timeZoneName: 'longOffset',
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const offset = get('timeZoneName').replace('GMT', '') || '+00:00';
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset}`;
}
} catch {}
return new Date().toISOString().slice(0, 19) + 'Z';
}
import { formatTs } from '../utils/time.js';

/**
* Events that can trigger a Lead steer.
Expand Down
21 changes: 1 addition & 20 deletions packages/server/src/orchestrator/Orchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { TaskId, SideEffect, ProjectConfig, AgentId } from '@flightdeck-ai/shared';
import { loadGlobalConfig } from '../config/GlobalConfig.js';
import { type TaskDAG } from '../dag/TaskDAG.js';
import { type SqliteStore } from '../storage/SqliteStore.js';
import { type GovernanceEngine } from '../governance/GovernanceEngine.js';
Expand All @@ -21,25 +20,7 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { log, truncate } from '../utils/logger.js';

/** Format timestamp in user's timezone as ISO with offset */
function formatTs(): string {
try {
const gc = loadGlobalConfig() as any;
if (gc.timezone) {
const tz = gc.timezone;
const d = new Date();
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
timeZoneName: 'longOffset',
}).formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const offset = get('timeZoneName').replace('GMT', '') || '+00:00';
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset}`;
}
} catch {}
return new Date().toISOString().slice(0, 19) + 'Z';
}
import { formatTs } from '../utils/time.js';

export interface GovernanceConfig {
costThresholdPerDay?: number;
Expand Down
36 changes: 36 additions & 0 deletions packages/server/src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { loadGlobalConfig } from '../config/GlobalConfig.js';

// Resolve the timezone + formatter once. formatTs() is a hot path (every
// user/agent DM envelope), and loadGlobalConfig() does synchronous filesystem
// reads — so we cache the result at module scope instead of re-reading per call.
let resolved = false;
let cachedFormatter: Intl.DateTimeFormat | null = null;

function getFormatter(): Intl.DateTimeFormat | null {
if (!resolved) {
resolved = true;
try {
const gc = loadGlobalConfig() as { timezone?: string };
if (gc.timezone) {
cachedFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: gc.timezone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
timeZoneName: 'longOffset',
});
}
} catch { /* fall through to UTC */ }
}
return cachedFormatter;
}

/** Format the current timestamp in the user's configured timezone as ISO with offset. */
export function formatTs(): string {
const formatter = getFormatter();
if (formatter) {
const parts = formatter.formatToParts(new Date());
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const offset = get('timeZoneName').replace('GMT', '') || '+00:00';
return `${get('year')}-${get('month')}-${get('day')}T${get('hour')}:${get('minute')}:${get('second')}${offset}`;
}
return new Date().toISOString().slice(0, 19) + 'Z';
}
51 changes: 51 additions & 0 deletions packages/server/tests/agents/agent-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

Check failure on line 1 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'vi' is defined but never used
import { existsSync, rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';

Check failure on line 4 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'tmpdir' is defined but never used
Expand All @@ -7,7 +7,7 @@
import { RoleRegistry } from '../../src/roles/RoleRegistry.js';
import { MessageStore } from '../../src/comms/MessageStore.js';
import type { AgentAdapter, SpawnOptions, SteerMessage, AgentMetadata } from '../../src/agents/AgentAdapter.js';
import type { AgentId, AgentRuntime, MessageId } from '@flightdeck-ai/shared';

Check failure on line 10 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'MessageId' is defined but never used

/** Minimal mock adapter for testing */
class MockAdapter implements AgentAdapter {
Expand Down Expand Up @@ -260,7 +260,7 @@

// Spawn a NEW agent (same role) — it gets a DIFFERENT ID
// so DMs to old agent won't be delivered to new agent (correct behavior)
const newAgent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });

Check failure on line 263 in packages/server/tests/agents/agent-manager.test.ts

View workflow job for this annotation

GitHub Actions / lint

'newAgent' is assigned a value but never used
await new Promise(r => setTimeout(r, 50));

// New agent should NOT receive old agent's DMs
Expand Down Expand Up @@ -323,4 +323,55 @@
expect(dmSteers).toHaveLength(1);
expect(dmSteers[0].message.content).toContain('New priority task available');
});

describe('message sender attribution', () => {
// Regression: agent messages from the web dashboard arrived as bare text,
// indistinguishable from system notices, and were stored as authorType
// 'system' — agents treated user messages as system notifications.

it('user messages get a [USER] envelope and user-attributed DM', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });
adapter.steerCalls = [];
await manager.sendToAgent(agent.id, 'please summarize progress', { sender: { type: 'user', source: 'web-dashboard' } });

expect(adapter.steerCalls).toHaveLength(1);
const content = adapter.steerCalls[0].message.content;
expect(content).toMatch(/^\[\d{4}-\d\d-\d\dT[^\]]*\] \[USER\]\nsource: web-dashboard\n---\nplease summarize progress$/);

const dms = messageStore.getUnreadDMs(agent.id).filter(m => m.content.includes('summarize'));
expect(dms).toHaveLength(1);
expect(dms[0].authorType).toBe('user');
});

it('agent-to-agent messages get an [AGENT <id>] envelope', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });
adapter.steerCalls = [];
await manager.interruptAgent(agent.id, 'need your branch', { sender: { type: 'agent', id: 'reviewer-abc' } });

const content = adapter.steerCalls[0].message.content;
expect(content).toContain('[AGENT reviewer-abc]');
expect(content).toContain('source: direct_message');
expect(content).toContain('need your branch');
});

it('system/default messages pass through unchanged (orchestrator callers)', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });
adapter.steerCalls = [];
await manager.sendToAgent(agent.id, '[SYSTEM] Continue working on your assigned task.');

expect(adapter.steerCalls[0].message.content).toBe('[SYSTEM] Continue working on your assigned task.');
const dms = messageStore.getUnreadDMs(agent.id).filter(m => m.content.includes('Continue working'));
expect(dms[0].authorType).toBe('system');
});

it('skipStore avoids double-persisting DMs the caller already stored', async () => {
const agent = await manager.spawnAgent({ role: 'worker', cwd: '/tmp', autoResolve: true });
adapter.steerCalls = [];
await manager.sendToAgent(agent.id, 'already stored elsewhere', { sender: { type: 'agent', id: 'lead' }, skipStore: true });

expect(adapter.steerCalls).toHaveLength(1);
const dms = messageStore.getUnreadDMs(agent.id).filter(m => m.content.includes('already stored'));
expect(dms).toHaveLength(0);
});
});
});
8 changes: 6 additions & 2 deletions packages/server/tests/api/agent-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,9 @@ describe('Agent relay HTTP endpoints', () => {
const { status, data } = await req(port, 'POST', '/api/projects/test/agents/agent-1/interrupt', { message: 'stop' });
expect(status).toBe(200);
expect(data.success).toBe(true);
expect(mockAm.interruptAgent).toHaveBeenCalledWith('agent-1', 'stop');
// No X-Agent-Id header → attributed to the dashboard user
expect(mockAm.interruptAgent).toHaveBeenCalledWith('agent-1', 'stop',
{ sender: { type: 'user', source: 'web-dashboard' } });
});

it('POST /agents/:id/interrupt returns 400 without message', async () => {
Expand All @@ -132,7 +134,9 @@ describe('Agent relay HTTP endpoints', () => {
const { status, data } = await req(port, 'POST', '/api/projects/test/agents/agent-1/send', { message: 'hello' });
expect(status).toBe(200);
expect(data.success).toBe(true);
expect(mockAm.sendToAgent).toHaveBeenCalledWith('agent-1', 'hello');
// No X-Agent-Id header → attributed to the dashboard user
expect(mockAm.sendToAgent).toHaveBeenCalledWith('agent-1', 'hello',
{ sender: { type: 'user', source: 'web-dashboard' } });
});

it('POST /agents/:id/send returns 400 without message', async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/server/tests/api/dm-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ describe('DM routing via /messages/send', () => {
it('routes DM to worker via agentManager.sendToAgent()', async () => {
const { status } = await req(port, 'POST', '/api/projects/test/messages/send', { to: 'worker-5', content: 'do stuff' }, { 'x-agent-id': 'lead-main' });
expect(status).toBe(200);
expect(sendToAgentFn).toHaveBeenCalledWith('worker-5', 'do stuff');
expect(sendToAgentFn).toHaveBeenCalledWith('worker-5', 'do stuff',
expect.objectContaining({ sender: expect.objectContaining({ type: 'agent', id: 'lead-main' }), skipStore: true }));
});

it('stores DM in MessageStore with dm:{to} channel', async () => {
Expand Down
Loading