lib/assistant-turns.ts
Rama main · réplica de lectura del repositorio original.
import type { AssistantMessage } from "./assistant-types";
import { assistantMessageReasoning } from "./assistant-reasoning";
/**
* Breaking the Numo feed into TURN, on parity with the code agent feed
* (components/agent/agent-event-feed.tsx): everything Numo did BEFORE his
* response (intermediate narration, tool calls) folds into an accordion
* “Worked since X” / “Worked for X”, and only the final response
* remains visible below. The user tracks the work in progress, then reads the
* response — instead of receiving the entire turn of a block.
*
* One TURN = a user message, then everything Numo produces up to its
* response. User messages always remain visible and separate turns — with one
* exception: the ANSWER to an ask_user question continues the SAME round
* (Numo merely paused mid-work), so asking a question never spawns a second
* accordion.
*/
export type AssistantTurn = {
kind: "turn";
/**
* Stable key throughout the life of the round (derived from the user message that
* opens it) → the SAME accordion instance persists from work to response,
* hence the automatic closing animation at the end of the round. Prefixed: the
* message which opens it is rendered in BROTHER block, with its own id for key.
*/
key: string;
/** Unfolded to fold: the intermediate messages of the round. */
work: AssistantMessage[];
/** Response which ends the round, delivered UNDER the accordion (`null` = not there yet). */
summary: AssistantMessage | null;
/** ISO — start of the round (the user's submission, not the 1st step produced). */
startedAt: string;
/** ISO, or `null` as long as the trick is working (live clock). */
endedAt: string | null;
active: boolean;
};
export type AssistantBlock =
| { kind: "message"; message: AssistantMessage }
| AssistantTurn;
/** Messages that the thread does not return: neither work nor response. */
function isHidden(m: AssistantMessage): boolean {
return m.role === "tool" || m.role === "system";
}
/**
* Assistant message that ends a round by handing back to the user and WAITING
* for their gesture: a seed suggestion (`propose_backlog`, MIN-173) waits for
* the create action. What is answered does not fold into the unfolded: it
* remains on the screen, under the accordion.
*
* `ask_user` is deliberately NOT here: a question is a pause inside the work,
* not its end. The user's answer keeps riding the same round (see
* `answersAskUser`), so the whole exchange shares one accordion.
*/
const HANDS_BACK = new Set(["propose_backlog"]);
/**
* Does this user message ANSWER an `ask_user` question Numo just asked?
* Mirrors the shell's absorption rule (assistant-shell.tsx): a user message
* directly following an assistant message that carries an `ask_user` call IS
* its answer — the server loop resumes on it, and the shell hides its bubble
* behind the ask_user line.
*/
function answersAskUser(prev: AssistantMessage | null): boolean {
return (
prev?.role === "assistant" &&
(prev.tool_calls ?? []).some((tc) => tc.function.name === "ask_user")
);
}
function closesTurn(m: AssistantMessage): boolean {
if (m.role !== "assistant") return false;
if (m.content?.trim()) return true;
return (m.tool_calls ?? []).some((tc) => HANDS_BACK.has(tc.function.name));
}
/**
* Ids of messages which carry a "Copy" button: the RESPONSE of the round - the one
* which remains alone under the accordion once folded into "Worked for Everything that is FOLDED in the unfolding has none: it is
* intermediate work, not a takeaway answer.
*
* An ACTIVE turn carries NONE, not even under its tail: between two rounds (the
* while the tools are turning) the last text received is found temporarily
* in response position, while it is only the narration of the moment. The
* button flashed from message to message while Numo was working, then landed on the real answer. It therefore only appears once the round is over.
*/
export function copyableMessageIds(blocks: AssistantBlock[]): Set<string> {
const ids = new Set<string>();
for (const block of blocks) {
if (block.kind === "message") {
const m = block.message;
if (m.role === "assistant" && m.content) ids.add(m.id);
} else if (!block.active && block.summary?.content) {
ids.add(block.summary.id);
}
}
return ids;
}
export function buildAssistantBlocks(
messages: AssistantMessage[],
options: {
/** Numo is working → the last lap is ACTIVE (accordion open, live timer). */
active?: boolean;
/**
* The IN FLIGHT round has already produced something (text being written or
* tool call gone). The last message received is then no longer the queue of the
* turn: its text becomes the narration again and returns to the sequence, otherwise
* it would remain displayed UNDER a work more recent than it.
*/
pendingWork?: boolean;
} = {},
): AssistantBlock[] {
const { active = false, pendingWork = false } = options;
const blocks: AssistantBlock[] = [];
let work: AssistantMessage[] = [];
// User message who opened the current round: carry their key and now
// where the timer starts (sending — this is where the waiting begins).
let opener: AssistantMessage | null = null;
const flush = (isActive: boolean) => {
const last = work[work.length - 1];
const summary =
last && closesTurn(last) && !(isActive && pendingWork) ? last : null;
const folded = summary ? work.slice(0, -1) : work;
// Nothing to fold (direct response without work, or empty turn) → messages such
// what: an empty accordion would learn nothing. The ACTIVE tour keeps
// on the other hand, its block, which houses the timer for the work in progress.
if (
!isActive &&
folded.length === 0 &&
!assistantMessageReasoning(summary)
) {
for (const m of work) blocks.push({ kind: "message", message: m });
work = [];
return;
}
const startedAt =
opener?.created_at || folded[0]?.created_at || last?.created_at || "";
blocks.push({
kind: "turn",
key: `turn-${opener?.id ?? folded[0]?.id ?? last?.id ?? blocks.length}`,
work: folded,
summary,
startedAt,
endedAt: isActive ? null : (summary ?? last)?.created_at || startedAt,
active: isActive,
});
work = [];
};
// Last non-hidden message read — tells a fresh user message apart from an
// ask_user answer (same rule the shell uses to hide answer bubbles).
let prevVisible: AssistantMessage | null = null;
for (const m of messages) {
if (isHidden(m)) continue;
if (m.role === "user") {
if (answersAskUser(prevVisible)) {
// An answer to an ask_user question does NOT close the round: Numo
// merely paused mid-work. The message rides inside the ongoing turn
// (the shell maps it onto the ask_user line instead of showing a
// bubble), which keeps the turn's key, timer and accordion.
prevVisible = m;
continue;
}
// A fresh user message closes the previous round (finished, or
// interrupted without response — its work is then displayed as is) and
// opens the next one.
flush(false);
blocks.push({ kind: "message", message: m });
opener = m;
prevVisible = m;
continue;
}
work.push(m);
prevVisible = m;
}
flush(active);
return blocks;
}