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
8 changes: 7 additions & 1 deletion apps/web/src/instant-agent-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@ describe("createWorkbench", () => {
participants: [],
});
}
if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) {
if (path.endsWith("/chat/workbenches/chan-recoverable/invite")) {
return json({ address: "def-assistant@tnt-1.corbits.dev", definitionId: "def-assistant" });
}
// CL-8175: the opening message now posts through `@corbits/mailbox`'s
// `POST /me/inbox/send` rather than the chat route.
if (path.endsWith("/mailbox/me/inbox/send")) {
return json({ error: "agent launch failed" }, 409);
}
throw new Error(`unexpected fetch: ${path}`);
Expand All @@ -181,6 +186,7 @@ describe("createWorkbench", () => {
() => undefined,
newQueryClient(),
"Research our next partner",
["def-assistant"],
);
} catch (error) {
cause = error;
Expand Down
18 changes: 9 additions & 9 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import type { QueryClient } from "@tanstack/react-query";
import {
createWorkbench as createWorkbenchChannel,
inviteAgent,
partsForSend,
patchWorkbenchSettings,
sendMessage,
sendInboxMessage,
workbenchesQueryKeyPrefix,
} from "@corbits/chat-ui";
import { reportError } from "@corbits/error-sink";
Expand Down Expand Up @@ -125,14 +124,15 @@ export async function createWorkbench(
});

if (firstMessage !== undefined && firstMessage.trim() !== "") {
const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map((definitionId) => ({
kind: "agent" as const,
definitionId,
}));
try {
await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []), {
...(selectedAgentInvites.length > 0 ? { invite: selectedAgentInvites } : {}),
});
const invitedAgentAddresses: string[] = [];
for (const definitionId of new Set(selectedAgentDefinitionIds)) {
const invited = await inviteAgent(tenantId, workbench.id, definitionId);
invitedAgentAddresses.push(invited.address);
}
if (invitedAgentAddresses.length > 0) {
await sendInboxMessage(tenantId, invitedAgentAddresses, firstMessage);
}
} catch (cause) {
throw new WorkbenchPostCreateError(workbench.id, "opening-message", cause);
}
Expand Down
86 changes: 33 additions & 53 deletions packages/chat-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,12 @@ const MessageItem = type({
// root feed and every open thread — see `./thread-feed.ts`. Absent
// on a host that mounts no thread store, matching `rootThreadId: ""`.
"threadId?": "string",
// The RFC 5322 `Message-ID` this row's mail carries (CL-8175) — absent
// for a row with no mail original. This is what a reply's own
// `inReplyTo` threads onto, never this item's `id`, which for a
// mail-derived row is the mailbox's own local uid (see
// `mailbox-timeline.ts`'s `threadTreeToTimeline`).
"messageId?": "string",
});
export type MessageItem = typeof MessageItem.infer;

Expand All @@ -156,13 +162,6 @@ const MessagesResponse = type({
});
export type MessagesResponse = typeof MessagesResponse.infer;

const SentMessage = type({
id: "string",
createdAt: "string",
"threadId?": "string",
"clientId?": "string",
});

const ReadState = type({
"lastSeenCreatedAt?": "string | null",
"lastSeenId?": "string | null",
Expand Down Expand Up @@ -430,55 +429,36 @@ export function fetchWorkbenchBlob(
).then((body) => body.contentBase64);
}

/** A message-send's pre-invite entry — the wire shape of a picked
* "Bring in…" candidate's `MentionInviteIntent` (see `mentions.ts`),
* mirroring `packages/chat/src/routes.ts`'s `MessageInviteEntry`. */
export type MessageInviteInput =
| { readonly kind: "agent"; readonly definitionId: string }
| {
readonly kind: "person";
readonly principalId: string;
readonly name?: string;
};

export type SendMessageOptions = {
readonly threadId?: string;
readonly inReplyToMessageId?: string;
/** Every not-yet-participant a mention in this message names, invited
* server-side before the send so the mention fans out normally. */
readonly invite?: readonly MessageInviteInput[];
/** This composer submit's own client-generated send identity
* (CL-6251) — the pending bubble's `nonce`, carried on the wire so
* the server can echo it back (in this call's own response, and on
* every later `GET .../messages` page) and the pending bubble can
* reconcile with the confirmed message by identity rather than by
* guessing from content or timing. */
readonly clientId?: string;
};
// The wire response of `@corbits/mailbox`'s `POST /me/inbox/send`
// (CL-8175): the Sent-folder copy's own RFC 5322 `Message-ID` and its
// mailbox uid, the same uid `mailbox-timeline.ts`'s `threadTreeToTimeline`
// keys every timeline row by.
const SentInboxMessage = type({
messageId: "string",
uid: "number",
});
export type SentInboxMessage = typeof SentInboxMessage.infer;

export function sendMessage(
/**
* A human composer send, straight onto the tenant's own mailbox
* (`POST /me/inbox/send`, CL-8175) — no chat route in between. `to` is
* every other recipient address this send goes to; `inReplyTo` is the
* RFC `Message-ID` of the message this one threads under (the thread
* root when replying inside an open thread, or the specific message being
* replied to when starting a new one) — never a mailbox uid, which is a
* local, per-mailbox integer the wire format has no room for.
*/
export function sendInboxMessage(
tenantId: string,
workbenchId: string,
parts: readonly Part[],
options?: SendMessageOptions,
): Promise<{
readonly id: string;
readonly createdAt: string;
readonly threadId?: string;
readonly clientId?: string;
}> {
const body: Record<string, unknown> = { parts };
if (options?.threadId !== undefined) body["threadId"] = options.threadId;
if (options?.inReplyToMessageId !== undefined) {
body["inReplyToMessageId"] = options.inReplyToMessageId;
}
if (options?.invite !== undefined && options.invite.length > 0) {
body["invite"] = options.invite;
}
if (options?.clientId !== undefined) body["clientId"] = options.clientId;
return request(`/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, SentMessage, {
to: readonly string[],
body: string,
options?: { readonly inReplyTo?: string },
): Promise<SentInboxMessage> {
const requestBody: Record<string, unknown> = { to, body };
if (options?.inReplyTo !== undefined) requestBody["inReplyTo"] = options.inReplyTo;
return request(`/api/tenants/${tenantId}/mailbox/me/inbox/send`, SentInboxMessage, {
method: "POST",
body: JSON.stringify(body),
body: JSON.stringify(requestBody),
});
}

Expand Down
9 changes: 9 additions & 0 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -984,13 +984,22 @@ function ChatWorkspaceInner({
setResumeAttempt((attempt) => attempt + 1);
}, []);

// Every other participant's address (CL-8175) — the signed-in sender's
// own entry (matched by local-part against `principalId`, the same
// convention `typingLabel` above reads) never belongs in its own `to`.
const recipientAddresses = (activeWorkbench?.participants ?? [])
.map((participant) => participant.address)
.filter((address) => localPartOf(address) !== currentUser?.principalId);

const { pendingSends, handleSend, retryPendingSend, discardPendingSend } = useOptimisticSends({
tenantId,
activeWorkbenchId,
currentUserPrincipalId: currentUser?.principalId,
openThreadId,
pendingParentMessageId,
openThreadById,
recipientAddresses,
messages: feed.loadedMessages,
noteAwaitingReply,
hasAgentParticipant,
restoreDraft: (text) => composerRef.current?.insertText(text),
Expand Down
2 changes: 1 addition & 1 deletion packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export {
applyStreamWorkbenchesMutated,
createWorkbench,
listMessages,
sendMessage,
sendInboxMessage,
fetchWorkbenchBlob,
listThreads,
putReadState,
Expand Down
1 change: 1 addition & 0 deletions packages/chat-ui/src/mailbox-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function threadTreeToTimeline(root: MailboxThreadNode): TimelineItem[] {
createdAt: envelope.date,
parts: [{ kind: "text", text }],
sender: { name: null, address: envelope.from },
messageId: envelope.messageId,
...(parentUid !== undefined ? { threadId: String(parentUid) } : {}),
});
for (const child of node.children) visit(child, node.uid);
Expand Down
Loading
Loading