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
82 changes: 82 additions & 0 deletions apps/server/src/chat/envelope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ChatMessageKind } from "@dispatch/shared";

/**
* The envelope's own markers, line-anchored exactly as they are emitted:
* `--- DISPATCH CHAT (id: …) ---` and `--- END DISPATCH CHAT ---`. Leading
Expand Down Expand Up @@ -83,6 +85,86 @@ export function buildChatEnvelope(
].join("\n");
}

/**
* How much of the reacted message the envelope quotes. The latest post needs
* little: "your latest" already names it, so the quote only confirms it. An
* older one has to be recognizable from the quote alone, so it gets enough
* to tell apart from its neighbours.
*/
export const REACTION_EXCERPT_LATEST_CHARS = 100;
export const REACTION_EXCERPT_EARLIER_CHARS = 300;

/**
* The opening of a message as one quotable line: leading markdown on each
* line (headings, bullets, quotes) and emphasis markers dropped, whitespace
* collapsed, cut at a word boundary within `maxChars`.
*/
export function reactionExcerpt(text: string, maxChars: number): string {
const line = text
.split(/\r?\n/)
.map((part) => part.replace(/^[\s#>*+-]+/, ""))
.join(" ")
.replace(/\*\*|__|`/g, "")
.replace(/\s+/g, " ")
.trim();
if (line.length <= maxChars) return line;
const cut = line.slice(0, maxChars);
const lastSpace = cut.lastIndexOf(" ");
// Break on a word only when that keeps most of the budget.
const head = lastSpace > maxChars / 2 ? cut.slice(0, lastSpace) : cut;
return `${head.trimEnd()}…`;
}

const KIND_NOUN: Record<ChatMessageKind, string> = {
reply: "message",
question: "question",
update: "progress update",
summary: "summary",
};

/**
* The pane-injection envelope for the user's emoji reaction. Its markers say
* REACTION on purpose: the agent must not read a reaction as the user typing
* a new request.
*
* It has to let the agent tell which post the user means without pasting
* the whole post back: the id (exact, and what `replyTo` takes), the kind of
* post, where it sits among the agent's posts ("latest", or "3 posts ago"),
* and a quote of its opening — short for the latest post, longer for an
* older one, which the agent can only place by its content.
*
* The body passes through `escapeEnvelopeMarkers` like a chat envelope's, so
* nothing taken from the message can open or close a block.
*/
export function buildReactionEnvelope(input: {
messageId: string;
emoji: string;
kind: ChatMessageKind;
text: string;
/** How many posts the agent has made on the feed since this one. */
postsSince: number;
}): string {
const { messageId, emoji, postsSince } = input;
const noun = KIND_NOUN[input.kind];
const latest = postsSince <= 0;
const target = latest
? `your latest ${noun}`
: `your ${noun} from ${postsSince} ${postsSince === 1 ? "post" : "posts"} ago`;
const excerpt = reactionExcerpt(
input.text,
latest ? REACTION_EXCERPT_LATEST_CHARS : REACTION_EXCERPT_EARLIER_CHARS
);
const body = excerpt
? `The user reacted ${emoji} to ${target}:\n> ${excerpt}`
: `The user reacted ${emoji} to ${target}.`;
return [
`--- DISPATCH CHAT REACTION (message id: ${messageId}) ---`,
escapeEnvelopeMarkers(body),
"--- END DISPATCH CHAT REACTION ---",
`A reaction, not a new message — reply only if it calls for one (dispatch_chat_post, replyTo: "${messageId}").`,
].join("\n");
}

/** `120 KB`, `3.4 MB`, `900 B` — for the attachment lines. */
export function formatAttachmentSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/chat/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,27 @@ async function listChatEntries(
SELECT message_id, jsonb_agg(attachment ORDER BY ord) AS attachments
FROM expanded
GROUP BY message_id
), rx AS (
SELECT r.message_id,
jsonb_agg(
jsonb_build_object(
'id', r.id,
'authorKind', r.author_kind,
'emoji', r.emoji,
'delivered', r.delivered,
'createdAt', r.created_at)
ORDER BY r.created_at, r.id) AS reactions
FROM page p
JOIN agent_chat_reactions r ON r.message_id = p.id
GROUP BY r.message_id
)
SELECT ${PAGE_COLUMNS_SQL},
p.at_key,
COALESCE(live.attachments, '[]'::jsonb) AS attachments
COALESCE(live.attachments, '[]'::jsonb) AS attachments,
rx.reactions
FROM page p
LEFT JOIN live ON live.message_id = p.id`,
LEFT JOIN live ON live.message_id = p.id
LEFT JOIN rx ON rx.message_id = p.id`,
params
);
return result.rows.map((row) => {
Expand Down
209 changes: 190 additions & 19 deletions apps/server/src/chat/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import type { Pool } from "pg";
import type {
ChatAnswerResponse,
ChatAttachment,
ChatAuthorKind,
ChatMessage,
ChatMessageKind,
ChatQuestion,
ChatReactionResponse,
ChatSendResponse,
ChatUserAttachmentInput,
ChatChangedEvent,
Expand All @@ -19,17 +21,23 @@ import {
CHAT_ATTACHMENTS_MAX,
CHAT_MESSAGE_MAX_CHARS,
CHAT_QUESTION_OPTIONS_MAX,
CHAT_REACTIONS_MAX,
} from "@dispatch/shared";

import type { AgentRecord, AgentTerminalAccess } from "../agents/types.js";
import { mimeType, resolveMediaDir } from "../shared/media.js";
import { buildChatEnvelope, formatAttachmentSize } from "./envelope.js";
import {
buildChatEnvelope,
buildReactionEnvelope,
formatAttachmentSize,
} from "./envelope.js";
import { loadChatMessageEntry } from "./feed.js";
import {
ChatStore,
isChatMessageId,
type UpdateChatMessageInput,
} from "./store.js";
import { normalizeReactionEmoji } from "./validation.js";

/**
* An attachment as an agent supplies it to dispatch_chat_post: `file` carries
Expand Down Expand Up @@ -472,6 +480,147 @@ export class ChatService {
return { question: answered, reply: replyMessage, delivered };
}

/**
* Add a reaction from `authorKind` to one of the other side's messages.
* The reaction is stored and published at once. A user reaction is then
* enqueued into the pane behind the quiet gate exactly like a user
* message, and its outcome lands on the reaction row (with no pane it is
* stored as not delivered). An agent reaction is only shown.
*
* Adding an emoji the author already put on the message changes nothing
* and delivers nothing, so a double click cannot inject it twice.
*/
async addReaction(
agentId: string,
messageId: string,
rawEmoji: unknown,
authorKind: ChatAuthorKind = "user"
): Promise<ChatReactionResponse> {
const { message, emoji } = await this.reactionTarget(
agentId,
messageId,
rawEmoji,
authorKind
);
const existing = await this.store.listReactions(message.id);
if (
existing.some(
(reaction) =>
reaction.authorKind === authorKind && reaction.emoji === emoji
)
) {
return { messageId: message.id, reactions: existing };
}
if (existing.length >= CHAT_REACTIONS_MAX) {
throw new ChatValidationError(
`A message can carry ${CHAT_REACTIONS_MAX} reactions at most.`
);
}
const sessionName =
authorKind === "user" ? await this.deliverySession(agentId, true) : null;
const reaction = await this.store.insertReaction({
agentId,
messageId: message.id,
authorKind,
emoji,
delivered: authorKind === "user" && sessionName === null ? false : null,
});
// Null means a concurrent add of the same emoji won; that one delivers.
if (reaction) {
await this.publishEntry(agentId, message.id);
if (sessionName !== null) {
const postsSince = await this.store.countLaterPostsBySameAuthor(
message.id
);
this.injectDetached({
agentId,
sessionName,
envelope: buildReactionEnvelope({
messageId: message.id,
emoji,
kind: message.kind,
text: message.text,
postsSince,
}),
record: async (delivered) => {
await this.store.setReactionDelivered(reaction.id, delivered);
await this.publishEntry(agentId, message.id);
},
logContext: { messageId: message.id, reactionId: reaction.id },
});
}
}
return {
messageId: message.id,
reactions: await this.store.listReactions(message.id),
};
}

/**
* Take an author's reaction back off a message. Only the chip goes: an
* agent already told about a user reaction stays told, and nothing is
* injected. Removing an emoji the author did not put there is a no-op.
*/
async removeReaction(
agentId: string,
messageId: string,
rawEmoji: unknown,
authorKind: ChatAuthorKind = "user"
): Promise<ChatReactionResponse> {
const { message, emoji } = await this.reactionTarget(
agentId,
messageId,
rawEmoji,
authorKind
);
if (await this.store.deleteReaction(message.id, authorKind, emoji)) {
await this.publishEntry(agentId, message.id);
}
return {
messageId: message.id,
reactions: await this.store.listReactions(message.id),
};
}

/**
* The message a reaction names, and the emoji as stored. Each side reacts
* only to the other's posts on this feed: the user to the agent's, the
* agent to the user's.
*/
private async reactionTarget(
agentId: string,
messageId: string,
rawEmoji: unknown,
authorKind: ChatAuthorKind
): Promise<{ message: ChatMessage; emoji: string }> {
if (!isChatMessageId(messageId)) {
throw new ChatValidationError(
authorKind === "agent"
? "messageId must be the id from a DISPATCH CHAT envelope."
: "messageId must be a UUID."
);
}
const emoji = normalizeReactionEmoji(rawEmoji);
if (emoji === null) {
throw new ChatValidationError(
"emoji must be a single emoji, such as 👍."
);
}
const message = await this.store.getById(messageId);
if (
!message ||
message.agentId !== agentId ||
message.authorKind === authorKind
) {
throw new ChatNotFoundError(
authorKind === "agent"
? "Message not found — you can react to the user's messages on your own Chat feed, by the id from their DISPATCH CHAT envelope."
: "Message not found."
);
}
return { message, emoji };
}

/** A real pane's session name, or null when this Chat flow permits inert. */
private async deliverySession(
agentId: string,
Expand Down Expand Up @@ -502,31 +651,47 @@ export class ChatService {
message: ChatMessage,
attachmentLines: string[] = []
): { held: boolean } {
return this.injectDetached({
agentId,
sessionName,
envelope: buildChatEnvelope(message.id, message.text, attachmentLines),
record: async (delivered) => {
await this.store.setDelivered(message.id, delivered);
await this.publishEntry(agentId, message.id);
},
logContext: { messageId: message.id },
});
}

/**
* The detached half every pane delivery shares: inject, then hand the
* outcome to `record`, tracked so shutdown can wait for it.
*/
private injectDetached(input: {
agentId: string;
sessionName: string;
envelope: string;
record: (delivered: boolean) => Promise<void>;
logContext: Record<string, string>;
}): { held: boolean } {
const { agentId, logContext } = input;
const delivery = this.delivery();
const envelope = buildChatEnvelope(
message.id,
message.text,
attachmentLines
);
const settlement = delivery
.inject(agentId, sessionName, envelope)
.inject(agentId, input.sessionName, input.envelope)
.then(
() => true,
(error: unknown) => {
this.log.warn(
{ err: error, agentId, messageId: message.id },
{ err: error, agentId, ...logContext },
"chat: pane delivery failed — agent may have exited"
);
return false;
}
)
.then(async (delivered) => {
await this.store.setDelivered(message.id, delivered);
await this.publishEntry(agentId, message.id);
})
.then(input.record)
.catch((error: unknown) => {
this.log.error(
{ err: error, agentId, messageId: message.id },
{ err: error, agentId, ...logContext },
"chat: failed to record delivery outcome"
);
});
Expand Down Expand Up @@ -683,14 +848,20 @@ export class ChatService {
}

/**
* Startup recovery for deliveries the previous process never settled: the
* quiet-gate queue is in-memory, so a restart abandons them while their
* rows still say pending. Mark them not-delivered (no replay — a resend
* is the user's call, a duplicate injection is not) and announce each
* affected feed. Returns the agent ids touched.
* Startup recovery for deliveries (messages and reactions) the previous
* process never settled: the quiet-gate queue is in-memory, so a restart
* abandons them while their rows still say pending. Mark them
* not-delivered (no replay — a resend is the user's call, a duplicate
* injection is not) and announce each affected feed. Returns the agent ids
* touched.
*/
async recoverPendingDeliveries(): Promise<string[]> {
const agentIds = await this.store.sweepPendingDeliveries();
const agentIds = [
...new Set([
...(await this.store.sweepPendingDeliveries()),
...(await this.store.sweepPendingReactions()),
]),
];
for (const agentId of agentIds) this.publishChanged(agentId);
return agentIds;
}
Expand Down
Loading
Loading