Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 commits
Select commit Hold shift + click to select a range
617af54
Declare what may be attached to a message, and how much
guidovizoso Sep 11, 2026
7e4e845
Cover the two functions the defer-to-the-server design rests on
guidovizoso Sep 11, 2026
b58d5c6
Store an attachment beside the channel it was staged in
guidovizoso Sep 11, 2026
b3340c7
Add the attachments table
guidovizoso Sep 11, 2026
c5958fc
Group staged rows by the composer session that made them
guidovizoso Sep 11, 2026
1ef9885
Index the staged rows the sweeper and the cap both scan
guidovizoso Sep 11, 2026
b4bd77c
Index by uploader, for the per-person staged backstop
guidovizoso Sep 11, 2026
eabbbdf
Decide a file's type from its bytes, not from what the browser said
guidovizoso Sep 11, 2026
9fbc2cc
Pin the handshake between the sniffer and the refusal
guidovizoso Sep 11, 2026
6e1b909
Take an upload, serve it back, and let it be withdrawn
guidovizoso Sep 11, 2026
cb5ce30
Drive the upload door, the fetch route and the withdrawal end to end
guidovizoso Sep 11, 2026
933dbd0
Pin what the store does with a row nobody sent
guidovizoso Sep 11, 2026
c89a556
Put a stored attachment in front of the model, or say why not
guidovizoso Sep 11, 2026
1549bb7
Pin the budget, the truncation and the modality
guidovizoso Sep 11, 2026
49e88ea
Resolve a turn's attachments, and record the send
guidovizoso Sep 11, 2026
b27bc22
Pin when a send is recorded, and when it is not
guidovizoso Sep 11, 2026
3e69374
Mount the attachment routes behind the upload body limit
guidovizoso Sep 11, 2026
fa0f733
Hand the turn path a reader scoped to the run's own thread
guidovizoso Sep 11, 2026
523374a
Delete the files nobody came back to send
guidovizoso Sep 11, 2026
03139b7
Pin the window, the batching and the arguments the sweep refuses
guidovizoso Sep 11, 2026
b17082d
Name the one URL an attachment is fetched from
guidovizoso Sep 11, 2026
d951076
Upload a picked file to its channel, and report what came back
guidovizoso Sep 11, 2026
fc804e3
Screen a pick before it is uploaded, and say why one was refused
guidovizoso Sep 11, 2026
2407255
Pin the order the screen refuses in
guidovizoso Sep 11, 2026
5fc6de5
Park a message whose turn cannot run yet, and release what it held
guidovizoso Sep 11, 2026
f7fdbd7
Pin what a parked message holds and what it lets go
guidovizoso Sep 11, 2026
fd1c2a4
Keep a draft's words and its attachments together
guidovizoso Sep 11, 2026
1d29aa6
Pin the draft's shape across a send
guidovizoso Sep 11, 2026
9dc76a8
Open and close by growing, so the composer does not jump
guidovizoso Sep 11, 2026
be21ce9
Draw what is attached as its own row across the top
guidovizoso Sep 11, 2026
177ba8e
Say out loud which file was refused, and why
guidovizoso Sep 11, 2026
ead8aad
Attach, screen, upload and send, from one door
guidovizoso Sep 11, 2026
cb0ba21
Export the composer's public surface
guidovizoso Sep 11, 2026
52a4e6a
Turn a stored message into the rows the transcript draws
guidovizoso Sep 11, 2026
9cd41c1
Draw a sent attachment, and open it full size
guidovizoso Sep 11, 2026
6a0d19a
Carry an attachment into the message that is sent
guidovizoso Sep 11, 2026
80fbfff
Release the rows a queued message no longer holds
guidovizoso Sep 11, 2026
f546508
Refuse a file dropped where nothing claimed it
guidovizoso Sep 11, 2026
c077710
Let the welcome composer sit behind the root guard
guidovizoso Sep 11, 2026
ff5f668
Pin the upload client's contract with the server
guidovizoso Sep 11, 2026
d93af04
Pin what the composer offers, and what it does not
guidovizoso Sep 11, 2026
d80b7aa
Pin the strip's tiles and their labels
guidovizoso Sep 11, 2026
491371c
Pin that the box measures before it animates
guidovizoso Sep 11, 2026
87747e1
Pin which pastes are ours and which are typed
guidovizoso Sep 11, 2026
91e04b2
Pin that a file the browser could not name still uploads
guidovizoso Sep 11, 2026
da699e7
Pin the refusal strip's wording and dismissal
guidovizoso Sep 11, 2026
f5faf75
Pin that the refusal's spacing is inside the measured box
guidovizoso Sep 11, 2026
37e29fa
Pin the cap the composer screens against
guidovizoso Sep 11, 2026
a6f1f13
Pin removing an attachment while its upload is in flight
guidovizoso Sep 11, 2026
f34d961
Pin that ids are minted without a secure context
guidovizoso Sep 11, 2026
02430d4
Pin that a dropped attachment is reported, not swallowed
guidovizoso Sep 11, 2026
1ddc64c
Pin that the composer answers a drop it cannot take
guidovizoso Sep 11, 2026
c40e372
Pin that an unclaimed file drop never reaches the browser
guidovizoso Sep 11, 2026
0c9be8d
Pin what parking and draining do to staged rows
guidovizoso Sep 11, 2026
c76d182
Pin an attachment from pick to sent
guidovizoso Sep 11, 2026
06d1c99
Pin what a failed send gives back
guidovizoso Sep 11, 2026
efbf4f9
Pin the rows a stored message becomes
guidovizoso Sep 11, 2026
cb4d877
Pin the sent tiles, the probe and the lightbox
guidovizoso Sep 11, 2026
cabd972
Pin the wire format an attachment message is sent in
guidovizoso Sep 11, 2026
fc1110f
Give these fixtures the shapes their production callers require
guidovizoso Sep 11, 2026
27346d2
Give serve.ts the WebSocket type Bun actually hands it
guidovizoso Sep 11, 2026
9fffea7
Run the staged-attachment sweep on a schedule
guidovizoso Sep 11, 2026
b629ed2
Give the sweep a policy, and close a rule that let everything out
guidovizoso Sep 11, 2026
75ecd0b
Name the sweep's objects, and honour every spelling of enabled
guidovizoso Sep 11, 2026
ae82c5e
Refuse a deadline the culler cannot honour
guidovizoso Sep 11, 2026
ee3345b
Refuse an empty schedule
guidovizoso Sep 11, 2026
43b043c
Ship the sweep on by default, and say what bounds growth
guidovizoso Sep 11, 2026
4c0fe07
Name the API range the sandbox target now requires
guidovizoso Sep 11, 2026
91e0c89
Document the sweep, and a database role that can migrate
guidovizoso Sep 11, 2026
f8b8cee
Check the keys the sweep added
guidovizoso Sep 11, 2026
df02b04
Say what reclaims unsent attachments, and what bounds them
guidovizoso Sep 11, 2026
12a2ffa
Load the test preload whichever directory bun is run from
guidovizoso Sep 11, 2026
bcca0d9
Read the handoff defaults from the row that documents them
guidovizoso Sep 11, 2026
0dfe680
Ignore node_modules whether it is a directory or a link
guidovizoso Sep 11, 2026
8e21403
Type-check the app's tests
guidovizoso Sep 11, 2026
7394f3d
Drain React before taking the document away
guidovizoso Sep 11, 2026
15d1b90
Charge the inline budget for every copy, not every distinct id
guidovizoso Sep 11, 2026
11ee973
Measure the inline budget on output bytes rather than on its counter
guidovizoso Sep 11, 2026
3bf9462
Prove a send was recorded before reporting one
guidovizoso Sep 11, 2026
d38bf17
Let a send that was never recorded refuse the turn
guidovizoso Sep 11, 2026
f89af89
Race a withdrawal against the send that records it
guidovizoso Sep 11, 2026
fee4c6b
Refuse at the seam, before the model is handed the file
guidovizoso Sep 11, 2026
0c304a0
Merge branch 'fix/g3-durable-send-record' into guido-composer
guidovizoso Sep 11, 2026
9a51c68
Hand a failed run its messages back instead of naming its files
guidovizoso Sep 11, 2026
a465f14
Put a failed turn back in the queue rather than deleting its files
guidovizoso Sep 11, 2026
1c3a78d
Fail the send when the turn behind it never happened
guidovizoso Sep 11, 2026
611d15a
Refuse to send a strip holding more files than a message may carry
guidovizoso Sep 11, 2026
3bce562
Drive a failed send through the real channel, not a stub submitter
guidovizoso Sep 11, 2026
98aa4ec
Merge branch 'fix/g2-failed-send-attachments' into guido-composer
guidovizoso Sep 11, 2026
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ docs/plans/
!.env.example
# Per-Bot egress proxies. Carries credentials in the URL, like .env does.
egress.env
node_modules/
# No trailing slash, deliberately. With one, this pattern matches a DIRECTORY only, and a symlink
# named node_modules is not a directory — so a sandbox that links its dependencies at the repo root
# leaves three links git will happily commit. That happened.
node_modules
**/dist/
app/src/lib/generated/application-config.ts
.logs/
Expand Down
29 changes: 24 additions & 5 deletions app/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,33 @@ export function upstreamWebSocketHeaders(requestHeaders: Headers): Headers {
return headers;
}

/**
* Bun's WebSocket client: the DOM one, plus the two things Bun adds and the browser does not.
*
* `bun-types` hands the global `WebSocket` over to `lib.dom` whenever DOM is in `lib`, and this
* project has DOM in `lib` for the browser code that is the rest of `app/`. What is left describes
* the BROWSER's client, which has no `terminate()` and takes subprotocols where Bun takes options.
* This file only ever runs under Bun and uses both.
*
* Stated as an extension of the DOM type rather than by reaching for `Bun.WebSocket`, because that
* interface is written for projects WITHOUT DOM in `lib` and degrades to `{}` here — its events
* come back untyped, which costs more than the two members it would buy. Same socket either way;
* this is a type-level correction, not a different client.
*/
export type BunWebSocket = WebSocket & { terminate(): void };
export const BunWebSocket = globalThis.WebSocket as unknown as {
new (url: string | URL, options?: { headers?: HeadersInit }): BunWebSocket;
readonly OPEN: number;
};

type WebSocketBridge = {
upstream: WebSocket;
upstream: BunWebSocket;
attach: (downstream: ServerWebSocket<WebSocketBridge>) => void;
dispose: () => void;
};

/** Own the upstream before awaiting its handshake, including any immediate welcome frames. */
function prepareWebSocketBridge(upstream: WebSocket, signal: AbortSignal) {
function prepareWebSocketBridge(upstream: BunWebSocket, signal: AbortSignal) {
upstream.binaryType = "arraybuffer";
let downstream: ServerWebSocket<WebSocketBridge> | undefined;
const pending: (string | ArrayBuffer)[] = [];
Expand Down Expand Up @@ -185,7 +204,7 @@ if (import.meta.main) {
},
message(ws, message) {
const { upstream } = ws.data;
if (upstream.readyState === WebSocket.OPEN) {
if (upstream.readyState === BunWebSocket.OPEN) {
upstream.send(message);
} else {
ws.close(1011, "Upstream connection closed");
Expand All @@ -202,13 +221,13 @@ if (import.meta.main) {
if (isApiCall(url.pathname)) {
const target = SERVER + url.pathname + url.search;
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
const upstream = new WebSocket(target.replace(/^http/, "ws"), {
const upstream = new BunWebSocket(target.replace(/^http/, "ws"), {
headers: upstreamWebSocketHeaders(request.headers),
});
const bridge = prepareWebSocketBridge(upstream, request.signal);
if (
!(await bridge.opened) ||
upstream.readyState !== WebSocket.OPEN
upstream.readyState !== BunWebSocket.OPEN
) {
bridge.data.dispose();
return new Response("Could not connect to the upstream WebSocket", {
Expand Down
194 changes: 183 additions & 11 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Message } from "@ag-ui/core";
import {
type Attachment,
UseAgentUpdate,
useAgent,
useCopilotKit,
} from "@copilotkit/react-core/v2";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { attachmentModality } from "@/components/channels/chat-messages";
import { toAgentOptions } from "@/components/channels/composer";
import { ConversationView } from "@/components/channels/conversation-view";
import {
Expand All @@ -14,6 +16,7 @@ import {
transcriptMessages,
} from "@/components/channels/transcript-messages";
import { agentListQueryOptions } from "@/lib/agents/queries";
import { attachmentUrl } from "@/lib/channels/attachments";
import {
recordChannelActivityMutationOptions,
setChannelBusy,
Expand Down Expand Up @@ -126,6 +129,95 @@ function mergeStoredMessages(local: Message[], stored: Message[]): Message[] {
];
}

/**
* The uploaded id and filename an `Attachment` carries once it is `ready`, read from the
* `metadata` the composer's `onUpload` stamped on it — see `composer/attachments.ts`. Not the
* SDK's own `attachment.id`, which is a client-side handle for the upload placeholder rather than
* the id this deployment stored the file under.
*/
function uploadedAttachment(attachment: Attachment): {
attachmentId: string;
filename?: string;
} {
const metadata = attachment.metadata as
| { attachmentId?: unknown; filename?: unknown }
| undefined;
const attachmentId = metadata?.attachmentId;
if (typeof attachmentId !== "string") {
throw new Error("Attachment is missing its uploaded id.");
}
const filename =
typeof metadata?.filename === "string" ? metadata.filename : undefined;
return filename ? { attachmentId, filename } : { attachmentId };
}

/**
* One attachment, turned into the part shape a stored message carries.
*
* THE MODALITY COMES FROM THE BYTES, NOT FROM `attachment.type`, AND THIS IS THE ONLY PLACE IT CAN.
*
* `attachment.type` is the browser's claim, fixed before the upload and never reconciled with what
* the file turned out to be. The server stopped trusting it — `resolvePart` decides an attachment's
* modality with `classifyAttachment` on its own sniffed `mimeType` — but that correction lives on
* the server and never comes back here. What this function writes IS the stored message, so a
* `document` written here is what every later render of that message reads, for ever: a screenshot
* whose part the browser mislabelled drew a grey file card over the picture, and the transcript's
* document probe then paid a whole-file read per render for the privilege.
*
* Narrowed through the source union rather than read straight off, for the reason `parkedTiles` in
* `chat-transcript.tsx` narrows the same field: a `data` source's `mimeType` is `file.type`, the
* very claim being refused, and only a `url` source has been past the server. `attachmentModality`
* falls back to the declared type when there is no corroborated one, so an attachment that somehow
* arrives unuploaded is written exactly as it used to be.
*
* The comment this replaces said only "image" and "document" reach here because the composer's
* upload config accepts no other kind of file. That reason is no longer true — the config's
* `accept` is now the wildcard, and it is `screenPickedFiles` that holds the line. The conclusion still
* holds; the justification had rotted, which is why the modality is now derived rather than cast.
*/
function toAttachmentPart(attachment: Attachment) {
const { attachmentId, filename } = uploadedAttachment(attachment);
const { source } = attachment;
const mimeType =
source.type === "url" && source.mimeType ? source.mimeType : undefined;
return {
type: attachmentModality(attachment.type, mimeType),
source: { type: "url" as const, value: attachmentUrl(attachmentId) },
metadata: filename ? { attachmentId, filename } : { attachmentId },
};
}

/**
* A plain string when there is nothing attached, exactly as every message in every channel has
* always been sent — never a single-element array wrapping the same text, which every existing
* reader would take a different path for no gain. With attachments, the text goes first as its
* own part and is left out entirely when empty, since an empty text part is noise the model has
* to read past.
*
* Exported for the test that pins this wire format. Reaching it through `deliver`/`say` would mean
* standing up `useAgent`'s runtime, the thread join and the ready/join gates around it just to
* observe a pure string-in-object-out mapping — none of that machinery bears on what this function
* decides, so a narrow export is the honest way to test the contract without restructuring the
* module around a test.
*/
export function toMessageContent(
trimmed: string,
attachments: readonly Attachment[],
) {
if (attachments.length === 0) return trimmed;
const refs = attachments.map(toAttachmentPart);
return trimmed ? [{ type: "text" as const, text: trimmed }, ...refs] : refs;
}

/** What the roster's "last thing said" reads when a message carried no caption. */
function describeAttachments(attachments: readonly Attachment[]): string {
if (attachments.length === 1) {
const { filename } = uploadedAttachment(attachments[0]);
return filename ? `Sent ${filename}` : "Sent an attachment";
}
return `Sent ${attachments.length} attachments`;
}

/**
* One channel's conversation with one coworker.
*
Expand Down Expand Up @@ -408,6 +500,36 @@ export function ChannelChat({
// Run failures arrive as events and are reported only for turns started in this mount.
const [runError, setRunError] = useState<string | null>(null);
const awaitingReply = useRef(false);
/**
* WHY THIS TURN ENDED WITHOUT AN ANSWER, KEPT WHERE `deliver` CAN STILL SEE IT — because the one
* thing that knows is a subscriber, and the one thing that has to act on it is an `await`.
*
* `copilotkit.runAgent` DOES NOT REJECT ON A FAILED RUN. `CopilotKitCore.runAgent` catches
* everything the agent throws, reports it through `emitError` as `AGENT_RUN_FAILED`, and returns
* `{ result: undefined, newMessages: [] }` — a value indistinguishable from a run that finished
* with nothing to say. So a gateway 503, a stream that dies, a model that refuses the request:
* every one of them arrived here as a resolved promise, and `say` reported success for a turn
* that never reached the server.
*
* WHAT THAT COST, WHICH IS THE REASON THIS EXISTS. `say` resolving is what every caller reads as
* "it went". The composer clears the box and gives up the chips it was riding; the queue empties
* into a draft nothing retries; `conversation-view.tsx` never runs either of the failure paths it
* has written for exactly this. The person is left with the failed turn in the transcript and a
* notice under it, the words unretryable, and the files behind them staged rows that nothing on
* any screen points at any more. The notice is honest and everything under it was not.
*
* READ OFF THE SAME `fail` THE NOTICE IS, and deliberately not from a second subscription of its
* own. `fail` already answers the one question a separate subscriber would get wrong: a turn the
* PERSON stopped also reaches `onRunFailed`, with an abort, and `onStop` clears `awaitingReply`
* before it — so Stop is not a failure here and nothing restores a draft somebody chose to end.
*
* ONE SLOT FOR ONE TURN AT A TIME, the same assumption `awaitingReply` beside it already makes.
* Two overlapping turns — a component button pressed during a composer send — would have the
* second clear the first's reason, which reports the earlier turn as successful. That is the
* pre-existing shape of `awaitingReply`, not a new one, and narrowing it means giving a run a
* handle that `copilotkit.runAgent` does not hand back.
*/
const turnFailure = useRef<string | null>(null);
const assistantMessagesBeforeRun = useRef<Set<string>>(new Set());

/*
Expand Down Expand Up @@ -466,7 +588,11 @@ export function ChannelChat({
* Everything `say` does once it has something worth sending, split out so the counter it is
* wrapped in covers every way out of here, a throw included.
*/
const deliver = async (trimmed: string, skillInstructions: string[]) => {
const deliver = async (
trimmed: string,
skillInstructions: string[],
attachments: Attachment[],
) => {
// Wait briefly for the runtime agent instance before adding the message.
if (!isReadyRef.current) {
await Promise.race([
Expand All @@ -490,6 +616,7 @@ export function ChannelChat({
const target = agentRef.current;

setRunError(null);
turnFailure.current = null;
assistantMessagesBeforeRun.current = new Set(
target.messages
.filter((message) => message.role === "assistant")
Expand Down Expand Up @@ -518,11 +645,11 @@ export function ChannelChat({
}

target.addMessage({
content: trimmed,
content: toMessageContent(trimmed, attachments),
id: newId(),
role: "user",
});
report(trimmed, null);
report(trimmed || describeAttachments(attachments), null);

// Providers reject later turns if prior tool calls have no result; repair before sending.
const repaired = repairUnansweredToolCalls(target.messages);
Expand All @@ -536,6 +663,30 @@ export function ChannelChat({
} finally {
setRunsInFlight((count) => count - 1);
}

/*
* A TURN THAT DID NOT HAPPEN FAILS THE SEND, which is the only way anything upstream can tell.
* See `turnFailure` for why the resolved promise above says nothing about that.
*
* AFTER the `finally`, not inside the `try`: the run is over either way, so the counter that
* draws the Stop button must come down before this throws. Throwing from inside would leave
* `runsInFlight` high for a run that has already ended.
*
* WHAT THE THROW REACHES, so it is clear this is a message and not a crash. The composer's
* `catch` puts the words and the chips back; `conversation-view.tsx` puts a drained queue back
* as retryable entries carrying their files. Nothing here reports the failure — `runError` was
* already set from the same `fail` that set this, and the transcript already draws it — so this
* adds a retry, not a second sentence.
*
* THE MESSAGE STAYS ON SCREEN. `deliver` added it above and nothing takes it away: it is what
* the failed turn WAS, it is what the notice under it is about, and removing it would delete a
* partial answer that a mid-stream failure had already produced. The restored draft beside it
* is the retry, the same way a failed composer send has always put its words back while the
* transcript kept the turn.
*/
if (turnFailure.current !== null) {
throw new Error(turnFailure.current);
}
};

/**
Expand All @@ -546,17 +697,24 @@ export function ChannelChat({
* keeping here rather than in the view: the view sees only the turns it started itself, and a
* queue that drains on the wrong one of those posts a correction into the middle of an answer.
*/
const say = async (text: string, skillInstructions: string[] = []) => {
const say = async (
text: string,
skillInstructions: string[] = [],
attachments: Attachment[] = [],
) => {
const trimmed = text.trim();
if (!trimmed) return;
// A pasted screenshot with no caption is still a message to send: `canSendDraft` already
// unlocks the button for exactly this case, so refusing it here would leave the button
// enabled and inert.
if (!trimmed && attachments.length === 0) return;

turnsRef.current += 1;
setTurnsInFlight(turnsRef.current);
if (turnsRef.current === 1) {
void setChannelBusy({ channelId: channel.id, busy: true });
}
try {
await deliver(trimmed, skillInstructions);
await deliver(trimmed, skillInstructions, attachments);
} finally {
turnsRef.current -= 1;
setTurnsInFlight(turnsRef.current);
Expand All @@ -572,6 +730,9 @@ export function ChannelChat({
const fail = (message: string) => {
if (!awaitingReply.current) return;
awaitingReply.current = false;
// Both halves of one fact: the sentence the transcript shows, and the reason `deliver` throws
// so the draft behind the turn is restored rather than counted as sent. See `turnFailure`.
turnFailure.current = message;
setRunError(message);
};
const subscription = agent.subscribe?.({
Expand Down Expand Up @@ -604,9 +765,16 @@ export function ChannelChat({

/**
* Component buttons speak as user turns without forcing every transcript card to re-render.
*
* The rejection is swallowed HERE rather than left to the void, and that is not a style choice:
* `say` throws on a failed turn now (see `turnFailure`), and a voided promise with nothing on the
* end of it is an unhandled rejection — in this repository's test runner, a failure attributed to
* whichever test happened to be running when it surfaced. There is nothing to restore for this
* caller either way: the words came from a button inside a rendered card, not from a box somebody
* is still holding, and the failed turn is already reported by `runError` under the transcript.
*/
const askFromComponent = useCallback((text: string) => {
void sayRef.current(text);
void sayRef.current(text).catch(() => undefined);
}, []);

/**
Expand All @@ -618,9 +786,12 @@ export function ChannelChat({
if (!pending) return;
seedRef.current = null;

void sayRef.current(
typeof pending.content === "string" ? pending.content : "",
);
// Swallowed for the reason `askFromComponent` above records: `say` throws on a failed turn, and
// the seed has no box to go back into — it was typed on a screen that has already navigated
// away. The transcript keeps the seeded message and the notice under it says what happened.
void sayRef
.current(typeof pending.content === "string" ? pending.content : "")
.catch(() => undefined);

// Keep `seed` in state; transcriptMessages gives it up once the agent holds a user turn.
}, []);
Expand All @@ -629,6 +800,7 @@ export function ChannelChat({
<ConversationProvider ask={askFromComponent}>
<ConversationView
agents={toAgentOptions(agentProfiles, channel.agentIds)}
channelId={channel.id}
/*
* THE TURN, not the run. `say` waits for the runtime agent and the join before a run starts,
* and `agent.isRunning` alone leaves that gap unmarked — which is the one moment the
Expand Down Expand Up @@ -677,7 +849,7 @@ export function ChannelChat({
Boolean(instruction),
);

await say(draft.text, skillInstructions);
await say(draft.text, skillInstructions, draft.attachments);
}}
/**
* Stop through the core so the abort signal reaches frontend tools; `say` repairs any
Expand Down
Loading