mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
Place unanchored turn activity by time instead of pinning it last
Turn blocks attach to their prompt message by id; when the id can't be matched (replayed backlog outside the fetched window, a prompt that didn't carry the id) the block fell into the trailing bucket rendered after ALL messages — so old "Thought for Xs" summaries sat stuck at the bottom while newer messages and turns rendered above them. Unmatched blocks now attach to the latest message at or before the block's start time (5s skew tolerance for the agent-host clock), so they render at their chronological spot and move up naturally as the conversation continues. The trailing bucket remains only for blocks with no usable timestamp or an empty message list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -283,3 +283,117 @@ test("buildChatActivityPlacement keeps separate agent turns visible", () => {
|
||||
|
||||
assert.deepEqual([...placement.hiddenAgentMessageIds], []);
|
||||
});
|
||||
|
||||
// ── Time-based fallback placement ────────────────────────────────────────
|
||||
|
||||
function unmatchedTurnTranscript(promptIso, toolIso) {
|
||||
return [
|
||||
{
|
||||
id: "prompt-x",
|
||||
type: "message",
|
||||
renderClass: "message",
|
||||
role: "user",
|
||||
title: "User",
|
||||
text: "A replayed ask",
|
||||
timestamp: promptIso,
|
||||
turnId: "turn-x",
|
||||
channelId: "chat-1",
|
||||
sessionId: "session-1",
|
||||
// Not present in the fetched message window — the id can't match.
|
||||
messageId: "missing-from-window",
|
||||
acpSource: "session/prompt:user",
|
||||
authorPubkey: self,
|
||||
},
|
||||
{
|
||||
id: "tool-x",
|
||||
type: "tool",
|
||||
renderClass: "shell",
|
||||
descriptor: {
|
||||
renderClass: "shell",
|
||||
label: "Ran command",
|
||||
preview: "pnpm test",
|
||||
},
|
||||
title: "pnpm test",
|
||||
toolName: "shell",
|
||||
buzzToolName: null,
|
||||
status: "completed",
|
||||
args: { command: "pnpm test" },
|
||||
result: "ok",
|
||||
isError: false,
|
||||
timestamp: toolIso,
|
||||
startedAt: toolIso,
|
||||
completedAt: toolIso,
|
||||
turnId: "turn-x",
|
||||
channelId: "chat-1",
|
||||
sessionId: "session-1",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const seconds = (iso) => Math.floor(Date.parse(iso) / 1_000);
|
||||
|
||||
test("a turn with an unmatched prompt id places by time, not at the bottom", () => {
|
||||
const messages = [
|
||||
message({
|
||||
id: "user-1",
|
||||
content: "First ask",
|
||||
created_at: seconds("2026-07-02T07:00:00Z"),
|
||||
}),
|
||||
message({
|
||||
id: "user-2",
|
||||
content: "Second ask",
|
||||
created_at: seconds("2026-07-02T08:00:00Z"),
|
||||
}),
|
||||
];
|
||||
const placement = buildChatActivityPlacement({
|
||||
agentPubkey: agent,
|
||||
messages,
|
||||
transcript: unmatchedTurnTranscript(
|
||||
"2026-07-02T07:00:05.000Z",
|
||||
"2026-07-02T07:00:06.000Z",
|
||||
),
|
||||
});
|
||||
|
||||
const attached = placement.blocksByMessageId.get("user-1") ?? [];
|
||||
assert.equal(attached.length, 1, "the turn must follow its era's message");
|
||||
assert.equal(
|
||||
attached[0].suppressPromptMessage,
|
||||
false,
|
||||
"a time-placed block did not match the message by id",
|
||||
);
|
||||
assert.equal(placement.unplacedBlocks.length, 0);
|
||||
});
|
||||
|
||||
test("an unmatched turn newer than every message follows the latest one", () => {
|
||||
const messages = [
|
||||
message({
|
||||
id: "user-1",
|
||||
content: "First ask",
|
||||
created_at: seconds("2026-07-02T07:00:00Z"),
|
||||
}),
|
||||
];
|
||||
const placement = buildChatActivityPlacement({
|
||||
agentPubkey: agent,
|
||||
messages,
|
||||
transcript: unmatchedTurnTranscript(
|
||||
"2026-07-02T09:00:00.000Z",
|
||||
"2026-07-02T09:00:01.000Z",
|
||||
),
|
||||
});
|
||||
|
||||
assert.equal((placement.blocksByMessageId.get("user-1") ?? []).length, 1);
|
||||
assert.equal(placement.unplacedBlocks.length, 0);
|
||||
});
|
||||
|
||||
test("an unmatched turn with no messages stays in the trailing bucket", () => {
|
||||
const placement = buildChatActivityPlacement({
|
||||
agentPubkey: agent,
|
||||
messages: [],
|
||||
transcript: unmatchedTurnTranscript(
|
||||
"2026-07-02T09:00:00.000Z",
|
||||
"2026-07-02T09:00:01.000Z",
|
||||
),
|
||||
});
|
||||
|
||||
assert.equal(placement.unplacedBlocks.length, 1);
|
||||
});
|
||||
|
||||
@@ -41,15 +41,22 @@ export function buildChatActivityPlacement({
|
||||
|
||||
for (const block of blocks) {
|
||||
const promptMessageId = getPromptMessageId(block);
|
||||
const attachedMessageId =
|
||||
const exactMessageId =
|
||||
promptMessageId && messageIds.has(promptMessageId)
|
||||
? promptMessageId
|
||||
: null;
|
||||
// A block whose prompt message can't be matched (replayed backlog id
|
||||
// outside the fetch window, prompt text that didn't carry the id, …)
|
||||
// must NOT drop to the trailing bucket — that pins it below every
|
||||
// message that arrives later, forever. Place it at its chronological
|
||||
// spot instead: after the latest message that predates the block.
|
||||
const attachedMessageId =
|
||||
exactMessageId ?? findMessageIdByTime(messages, getBlockStartMs(block));
|
||||
const renderBlock = {
|
||||
id: getBlockId(block),
|
||||
block,
|
||||
attachedMessageId,
|
||||
suppressPromptMessage: attachedMessageId !== null,
|
||||
suppressPromptMessage: exactMessageId !== null,
|
||||
};
|
||||
|
||||
if (attachedMessageId) {
|
||||
@@ -211,6 +218,74 @@ function normalizeMessageText(text: string) {
|
||||
return normalizeAssistantMessageTextForMatching(text);
|
||||
}
|
||||
|
||||
// Transcript timestamps come from the agent host's clock while message
|
||||
// created_at comes from the sender's; a little leeway keeps a block from
|
||||
// slipping one message early when the agent clock runs slightly behind.
|
||||
const BLOCK_TIME_SKEW_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Latest message at or before the block's start (with skew tolerance), for
|
||||
* blocks whose prompt message couldn't be matched by id. A block older than
|
||||
* every message attaches to the earliest one; null only when there are no
|
||||
* messages or the block has no usable timestamp.
|
||||
*/
|
||||
function findMessageIdByTime(
|
||||
messages: RelayEvent[],
|
||||
blockStartMs: number | null,
|
||||
): string | null {
|
||||
if (blockStartMs === null || messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let bestId: string | null = null;
|
||||
let bestMs = Number.NEGATIVE_INFINITY;
|
||||
let earliestId: string | null = null;
|
||||
let earliestMs = Number.POSITIVE_INFINITY;
|
||||
for (const message of messages) {
|
||||
const ms = message.created_at * 1_000;
|
||||
if (ms < earliestMs) {
|
||||
earliestMs = ms;
|
||||
earliestId = message.id;
|
||||
}
|
||||
if (ms <= blockStartMs + BLOCK_TIME_SKEW_MS && ms >= bestMs) {
|
||||
bestMs = ms;
|
||||
bestId = message.id;
|
||||
}
|
||||
}
|
||||
return bestId ?? earliestId;
|
||||
}
|
||||
|
||||
function getBlockStartMs(block: TranscriptDisplayBlock): number | null {
|
||||
if (block.kind === "single") {
|
||||
return parseTimestampMs(block.item.timestamp);
|
||||
}
|
||||
for (const segment of block.segments) {
|
||||
let ms: number | null = null;
|
||||
if (segment.kind === "prompt") {
|
||||
ms = parseTimestampMs(segment.user.timestamp);
|
||||
} else if (segment.kind === "item") {
|
||||
ms = parseTimestampMs(segment.item.timestamp);
|
||||
} else if (segment.kind === "summary") {
|
||||
ms =
|
||||
parseTimestampMs(segment.summary.items[0]?.timestamp) ??
|
||||
parseTimestampMs(segment.summary.timestamp);
|
||||
} else if (segment.kind === "setup") {
|
||||
ms = parseTimestampMs(segment.items[0]?.timestamp);
|
||||
}
|
||||
if (ms !== null) {
|
||||
return ms;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseTimestampMs(timestamp: string | undefined): number | null {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
const ms = Date.parse(timestamp);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function getBlockId(block: TranscriptDisplayBlock) {
|
||||
if (block.kind === "single") {
|
||||
return block.item.id;
|
||||
|
||||
Reference in New Issue
Block a user