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
102 changes: 87 additions & 15 deletions apps/server/src/chat/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ChatFeedEntry,
ChatFeedResponse,
ChatMediaEntry,
ChatReviewEntry,
ChatStatusEntry,
} from "@dispatch/shared";

Expand All @@ -24,7 +25,7 @@ export type ComposeChatFeedOptions = {

/**
* Feed ordering is (created_at desc, source rank desc, id desc) — a total
* order across the four tables, so a page boundary that falls on rows with
* order across the five tables, so a page boundary that falls on rows with
* identical timestamps never drops or repeats a row. The cursor names the
* last entry of the previous page in that order. `at` is Postgres microsecond
* text (`to_char(..., 'YYYY-MM-DD HH24:MI:SS.US')`), not the millisecond ISO
Expand All @@ -37,6 +38,7 @@ export type FeedCursor = {
};

const SOURCE_RANK: Record<ChatFeedEntry["type"], number> = {
review: 4,
chat: 3,
status: 2,
agent_message: 1,
Expand All @@ -60,6 +62,7 @@ function isValidCursorId(type: ChatFeedEntry["type"], id: string): boolean {
return isChatMessageId(id);
case "status":
case "media":
case "review":
return SERIAL_ID_RE.test(id) && Number(id) <= 2_147_483_647;
}
}
Expand Down Expand Up @@ -121,23 +124,26 @@ type Keyed<E extends ChatFeedEntry> = {
* "Older than the cursor" for one source. `$1` is the agent id; the clause
* appends its own parameters. Sources ranked below the cursor's include the
* cursor timestamp itself; those above it exclude it; the cursor's own
* source breaks the tie on id.
* source breaks the tie on id. `alias` qualifies the columns for a source
* whose query joins other tables that have `id`/`created_at` of their own.
*/
function cursorClause(
type: ChatFeedEntry["type"],
idCast: "int" | "uuid",
cursor: FeedCursor | null,
params: unknown[]
params: unknown[],
alias = ""
): string {
if (!cursor) return "";
const col = (name: string) => (alias ? `${alias}.${name}` : name);
params.push(cursor.at);
const ts = `($${params.length}::timestamp AT TIME ZONE 'UTC')`;
const rank = SOURCE_RANK[type];
const cursorRank = SOURCE_RANK[cursor.type];
if (rank > cursorRank) return `AND created_at < ${ts}`;
if (rank < cursorRank) return `AND created_at <= ${ts}`;
if (rank > cursorRank) return `AND ${col("created_at")} < ${ts}`;
if (rank < cursorRank) return `AND ${col("created_at")} <= ${ts}`;
params.push(idCast === "int" ? Number(cursor.id) : cursor.id);
return `AND (created_at < ${ts} OR (created_at = ${ts} AND id < $${params.length}::${idCast}))`;
return `AND (${col("created_at")} < ${ts} OR (${col("created_at")} = ${ts} AND ${col("id")} < $${params.length}::${idCast}))`;
}

const intKey = (id: number) => String(id).padStart(20, "0");
Expand Down Expand Up @@ -313,6 +319,69 @@ async function listMediaEntries(
}));
}

/**
* Reviews left on this agent's work. Counts and status are read live rather
* than frozen at submission time, so the card in the feed says the same
* thing as the row in the Reviews sidebar it links to.
*/
async function listReviewEntries(
db: Queryable,
agentId: string,
cursor: FeedCursor | null,
limit: number
): Promise<Keyed<ChatReviewEntry>[]> {
const params: unknown[] = [agentId];
const clause = cursorClause("review", "int", cursor, params, "r");
params.push(limit);
const result = await db.query<{
id: number;
reviewer_type: string;
reviewer_agent_id: string | null;
reviewer_name: string | null;
summary: string | null;
status: string;
item_count: number;
resolved_count: number;
created_at: Date;
at_key: string;
}>(
`SELECT r.id, r.reviewer_type, r.reviewer_agent_id, r.summary, r.status,
r.created_at,
COALESCE(reviewer.persona, reviewer.name) AS reviewer_name,
COUNT(fi.id)::int AS item_count,
COUNT(fi.id) FILTER (WHERE fi.status = 'resolved')::int
AS resolved_count,
to_char(r.created_at AT TIME ZONE 'UTC',
'YYYY-MM-DD HH24:MI:SS.US') AS at_key
FROM reviews r
LEFT JOIN agents reviewer ON reviewer.id = r.reviewer_agent_id
LEFT JOIN review_feedback_items fi ON fi.review_id = r.id
WHERE r.agent_id = $1 ${clause}
GROUP BY r.id, reviewer.persona, reviewer.name
ORDER BY r.created_at DESC, r.id DESC
LIMIT $${params.length}`,
params
);
return result.rows.map((row) => ({
entry: {
type: "review",
id: `review:${row.id}`,
reviewId: row.id,
reviewerType: row.reviewer_type === "agent" ? "agent" : "human",
reviewerAgentId: row.reviewer_agent_id,
reviewerName: row.reviewer_name,
summary: row.summary,
status: row.status,
itemCount: row.item_count,
resolvedCount: row.resolved_count,
at: row.created_at.toISOString(),
},
atKey: row.at_key,
rawId: String(row.id),
idKey: intKey(row.id),
}));
}

/** Newest first: (atKey, source rank, id) descending. */
function compareNewestFirst(a: Keyed<ChatFeedEntry>, b: Keyed<ChatFeedEntry>) {
if (a.atKey !== b.atKey) return a.atKey < b.atKey ? 1 : -1;
Expand All @@ -324,8 +393,8 @@ function compareNewestFirst(a: Keyed<ChatFeedEntry>, b: Keyed<ChatFeedEntry>) {

/**
* Compose one agent's Chat feed at read time from chat messages, status
* events, cross-agent messages, and shared media. Each source contributes
* its newest `limit + 1` rows past the cursor; the merge keeps the newest
* events, cross-agent messages, shared media, and reviews. Each source
* contributes its newest `limit + 1` rows past the cursor; the merge keeps the newest
* `limit` overall, so any row that belongs on the page is present (a row in
* the top `limit` overall is in the top `limit` of its source), and anything
* left over proves an older page exists.
Expand All @@ -338,19 +407,22 @@ export async function composeChatFeed(
const limit = clampFeedLimit(opts.limit);
const cursor = opts.cursor ?? null;
const { db } = store;
const [chat, status, agentMessages, media, unreadCount] = await Promise.all([
listChatEntries(db, agentId, cursor, limit + 1),
listStatusEntries(db, agentId, cursor, limit + 1),
listAgentMessageEntries(db, agentId, cursor, limit + 1),
listMediaEntries(db, agentId, cursor, limit + 1),
store.countUnread(agentId),
]);
const [chat, status, agentMessages, media, reviews, unreadCount] =
await Promise.all([
listChatEntries(db, agentId, cursor, limit + 1),
listStatusEntries(db, agentId, cursor, limit + 1),
listAgentMessageEntries(db, agentId, cursor, limit + 1),
listMediaEntries(db, agentId, cursor, limit + 1),
listReviewEntries(db, agentId, cursor, limit + 1),
store.countUnread(agentId),
]);

const merged: Keyed<ChatFeedEntry>[] = [
...chat,
...status,
...agentMessages,
...media,
...reviews,
].sort(compareNewestFirst);
const hasMore = merged.length > limit;
const page = merged.slice(0, limit);
Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/chat/user-prompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Prompts the user fires from a UI control rather than typing into the Chat
* composer: a quick phrase, a shortcut pin. They inject the same kind of
* thing a composed message does — the user's words, addressed to the agent —
* so with the Chat surface on they take the same path: a user row, the
* envelope carrying its id, and a post in the feed the agent's reply can
* thread onto. See docs/chat-surface-plan.md, "Injection envelope".
*/

export type UserPromptRouting = {
/** Whether the Chat surface is switched on for this installation. */
chatSurfaceEnabled: boolean;
/**
* The target agent's type. Only `"terminal"` is excluded; an older row
* with no type is a CLI agent like any other. A caller that could not
* find the agent at all does not reach here — it keeps the pane path so
* its own 404 stands.
*/
agentType: string | null | undefined;
/**
* Whether the prompt is being submitted. A quick phrase pasted for the
* user to edit first (`submit: false`) is not a message yet — there is
* nothing to post, and the text still has to land in the pane's composer.
*/
submit: boolean;
};

/**
* Whether a user-fired prompt goes out as a Chat message instead of straight
* into the pane. Everything this decision needs is in `UserPromptRouting`, so
* the rule lives in one place rather than being restated at each route.
*
* A terminal session is excluded for the same reason it gets no launch post:
* there is no CLI behind it to read the envelope or to answer with
* `dispatch_chat_post`, so the envelope would be noise in the pane.
*/
export function routesUserPromptThroughChat(
routing: UserPromptRouting
): boolean {
if (!routing.chatSurfaceEnabled) return false;
if (!routing.submit) return false;
return routing.agentType !== "terminal";
}

/** What `deliverUserPrompt` needs; a subset of the agent routes' deps. */
export type UserPromptDeps = {
isChatSurfaceEnabled: () => Promise<boolean>;
getAgent: (agentId: string) => Promise<{ type?: string | null } | null>;
sendUserMessage: (agentId: string, text: string) => Promise<unknown>;
};

/**
* Deliver a prompt the user fired from a UI control, returning whether Chat
* took it. `false` means the caller writes to the pane itself, which is all
* that ever happened before the surface existed.
*
* The failure modes line up with the pane path's on purpose: an agent with
* no live session makes `sendUserMessage` throw before any row is written,
* so a click that could not be delivered leaves nothing behind in the feed.
*/
export async function deliverUserPrompt(
deps: UserPromptDeps,
agentId: string,
text: string,
submit: boolean
): Promise<boolean> {
if (!(await deps.isChatSurfaceEnabled())) return false;
// A missing agent keeps the pane path so the route's own 404 stands.
const agent = await deps.getAgent(agentId).catch(() => null);
if (!agent) return false;
if (
!routesUserPromptThroughChat({
chatSurfaceEnabled: true,
agentType: agent.type,
submit,
})
) {
return false;
}
await deps.sendUserMessage(agentId, text);
return true;
}
8 changes: 8 additions & 0 deletions apps/server/src/routes/agents/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from "../../terminal/copy-mode-observer.js";
import type { CopyModeAssistManager } from "../../terminal/copy-mode-assist-manager.js";
import type { InjectionCoordinator } from "../../terminal/injection-coordinator.js";
import type { ChatService } from "../../chat/service.js";

export const AGENT_INITIAL_PROMPT_MAX_CHARS = 16_000;
export const CODEX_FULL_ACCESS_ARG =
Expand Down Expand Up @@ -51,6 +52,13 @@ export type AgentRouteDeps = {
trackArchivePromise: (agentId: string, archivePromise: Promise<void>) => void;
sendAgentPrompt: (agentId: string, prompt: string) => Promise<void>;
onAgentStarted: (agentId: string) => Promise<void>;
/**
* Delivers a user-fired prompt (quick phrase, shortcut pin) as a Chat
* message when the Chat surface is on — see `chat/user-prompt.ts`.
*/
chat: ChatService;
/** Read per click: the flag is a cold path and must not be cached stale. */
isChatSurfaceEnabled: () => Promise<boolean>;
};

export function escapeHtml(s: string): string {
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/routes/agents/terminal-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,28 @@ import { substituteArgs } from "../../templates/arg-parser.js";
import { TmuxTerminal } from "../../terminal/tmux-terminal.js";
import { errorMessage } from "../../shared/lib/error-message.js";
import { resolveShortcutRun } from "../../agents/pin-run.js";
import { ChatServiceError } from "../../chat/service.js";
import {
deliverUserPrompt,
type UserPromptDeps,
} from "../../chat/user-prompt.js";
import { decodeClientMessage, type AgentRouteDeps } from "./shared.js";

/**
* The routes' deps, narrowed to what a user-fired prompt's delivery needs.
* With the Chat surface on it becomes a Chat message — a user post in the
* feed, wrapped in the envelope — so the agent's reply threads back to it
* exactly as it would for a message typed in the composer.
*/
function promptDeps(deps: AgentRouteDeps): UserPromptDeps {
return {
isChatSurfaceEnabled: deps.isChatSurfaceEnabled,
getAgent: (agentId) => deps.agentManager.getAgent(agentId),
sendUserMessage: (agentId, text) =>
deps.chat.sendUserMessage(agentId, text),
};
}

export async function registerAgentTerminalRoutes(
app: FastifyInstance,
deps: AgentRouteDeps
Expand Down Expand Up @@ -184,6 +204,10 @@ export async function registerAgentTerminalRoutes(
}

try {
if (await deliverUserPrompt(promptDeps(deps), agentId, text, submit)) {
return reply.code(204).send();
}

const access = await deps.agentManager.getTerminalAccess(agentId);
if (access.mode !== "tmux") {
return reply.code(409).send({ error: access.message });
Expand All @@ -200,6 +224,9 @@ export async function registerAgentTerminalRoutes(
);
return reply.code(204).send();
} catch (error) {
if (error instanceof ChatServiceError) {
return reply.code(error.statusCode).send({ error: error.message });
}
return deps.handleAgentError(reply, error);
}
}
Expand Down Expand Up @@ -275,6 +302,17 @@ export async function registerAgentTerminalRoutes(
return reply.code(target.status).send({ error: target.error });
}

if (
await deliverUserPrompt(
promptDeps(deps),
agentId,
target.prompt,
true
)
) {
return reply.code(204).send();
}

const access = await deps.agentManager.getTerminalAccess(agentId);
if (access.mode !== "tmux") {
return reply.code(409).send({ error: access.message });
Expand All @@ -288,6 +326,9 @@ export async function registerAgentTerminalRoutes(
);
return reply.code(204).send();
} catch (error) {
if (error instanceof ChatServiceError) {
return reply.code(error.statusCode).send({ error: error.message });
}
return deps.handleAgentError(reply, error);
}
}
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import { registerMediaRoutes } from "./routes/media.js";
import { registerMessagesRoutes } from "./routes/messages.js";
import { registerChatRoutes } from "./routes/chat.js";
import { ChatService } from "./chat/service.js";
import { isChatSurfaceEnabled } from "./chat-surface-settings.js";
import { registerSurfaceRoutes } from "./routes/surfaces.js";
import { registerWhiteboardRoutes } from "./routes/whiteboard.js";
import { registerMcpRoutes } from "./routes/mcp.js";
Expand Down Expand Up @@ -837,6 +838,8 @@ async function registerRoutes() {
injectAgentPrompt(agentId, prompt, { swallowFailure: false }),
onAgentStarted: (agentId) =>
surfaceService.notifyQueuedAfterResume(agentId),
chat: chatService,
isChatSurfaceEnabled: () => isChatSurfaceEnabled(pool),
});

// --- Personas ---
Expand Down
Loading
Loading