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
36 changes: 19 additions & 17 deletions assets/dist/roundtable.js

Large diffs are not rendered by default.

92 changes: 68 additions & 24 deletions assets/src/components.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,16 @@ export async function sendTurn(text, {
onDone: () => {
clearTimeout(timer);
// Belt-and-suspenders: if the stream ended without a `result` event,
// applyStreamEvent never got a chance to fall back to a captured
// finalText (e.g. an assistant_text refusal). Reconcile here so the
// turn still shows something instead of rendering empty.
// applyStreamEvent never got a chance to fall back to the captured
// assistant_text blocks (e.g. a refusal). Reconcile here so the turn
// still shows something instead of rendering empty.
setTurns((t) => {
if (!t.length) return t;
const last = t[t.length - 1];
if (last.finalText && last.finalText.length > (last.reply || '').length) {
const composed = composeFinal(last);
if (composed && composed.length > (last.reply || '').length) {
const next = t.slice();
next[next.length - 1] = { ...last, reply: last.finalText, done: true };
next[next.length - 1] = { ...last, reply: composed, done: true };
return next;
}
return t;
Expand All @@ -137,16 +138,46 @@ export async function sendTurn(text, {
});
}

/**
* Compose kept text segments plus the still-open one into the rendered reply.
* @param {Array<string>} kept Closed segments that survived (openers).
* @param {{text:string}|null} curSeg The segment currently accumulating, if any.
* @returns {string}
*/
function renderedReply(kept, curSeg) {
return [...kept, curSeg ? curSeg.text : ''].filter(Boolean).join('\n\n');
}

/**
* Compose the reply from a turn's complete `assistant_text` blocks, dropping
* interim ones. A block is interim when real steps happened both before it
* (`startStep > 0`) and after it (more steps than when it started) — that's
* research narration, which the agent's identity bar bans from replies. The
* opener (a block before any real step, e.g. an empathy acknowledgement) and
* the closing answer are kept.
* @param {{finalBlocks?:Array<{text:string,startStep:number}>, steps:Array}} turn
* @returns {string}
*/
export function composeFinal(turn) {
const total = visibleSteps(turn.steps).length;
return (turn.finalBlocks || [])
.filter((b) => b.startStep === 0 || b.startStep >= total)
.map((b) => b.text)
.filter(Boolean)
.join('\n\n');
}

/**
* Fold one streamed hub event onto the last (in-flight agent) turn.
* guarded_text_delta keeps accumulating onto `reply` even after `result` —
* the guard flushes a trailing delta after `result`, so accumulation must not
* stop there. assistant_text is stored as `finalText` without touching
* `reply`, so it never overwrites (and duplicates) a delta-built reply. On
* `result`, if no deltas arrived (`reply` is still empty) but a `finalText`
* was captured — e.g. an off-topic refusal, or a turn the model didn't
* stream partial-message deltas for — it becomes the reply, so the turn
* doesn't render as empty.
* guarded_text_delta accumulates into the current text segment (kept
* accumulating even after `result` — the guard flushes a trailing delta).
* A `progress` step closes the current segment: an opener (text streamed
* before any real step) is kept, a middle segment (thinking-out-loud between
* research steps) is dropped from the rendered reply. assistant_text blocks
* are collected as `finalBlocks` without touching `reply`; on `result`, if no
* deltas arrived (`reply` is empty) the same opener/closer composition of
* those blocks becomes the reply — e.g. an off-topic refusal, or a turn the
* model didn't stream partial-message deltas for.
* @param {Array} turns
* @param {{type:string, text?:string, summary?:string}} ev
* @param {number} now current time (ms), used to stamp step/completion timestamps
Expand All @@ -156,21 +187,34 @@ export function applyStreamEvent(turns, ev, now) {
if (!turns.length) return turns;
const i = turns.length - 1;
const turn = turns[i];
const kept = turn.keptSegs || [];
let patch;
if (ev.type === 'guarded_text_delta') patch = { reply: turn.reply + (ev.text || '') };
else if (ev.type === 'assistant_text') {
// One event per complete text block: blocks join with a blank line so they
// never concatenate mid-sentence.
patch = { finalText: turn.finalText ? `${turn.finalText}\n\n${ev.text}` : ev.text };
}
else if (ev.type === 'progress') patch = { steps: [...turn.steps, { summary: ev.summary, at: now }] };
else if (ev.type === 'result') {
if (ev.type === 'guarded_text_delta') {
const curSeg = turn.curSeg
? { ...turn.curSeg, text: turn.curSeg.text + (ev.text || '') }
: { text: ev.text || '', startStep: visibleSteps(turn.steps).length };
patch = { curSeg, reply: renderedReply(kept, curSeg) };
} else if (ev.type === 'assistant_text') {
patch = {
finalBlocks: [
...(turn.finalBlocks || []),
{ text: ev.text, startStep: visibleSteps(turn.steps).length },
],
};
} else if (ev.type === 'progress') {
patch = { steps: [...turn.steps, { summary: ev.summary, at: now }] };
if (turn.curSeg) {
const keepIt = turn.curSeg.startStep === 0;
patch.keptSegs = keepIt ? [...kept, turn.curSeg.text] : kept;
patch.curSeg = null;
patch.reply = renderedReply(patch.keptSegs, null);
}
} else if (ev.type === 'result') {
const base = ev.is_error === true
? { done: true, error: true, doneAt: now }
: { done: true, doneAt: now };
patch = (turn.reply === '' && turn.finalText)
? { ...base, reply: turn.finalText }
: base;
const fallback = turn.reply === '' ? composeFinal(turn) : '';
patch = fallback ? { ...base, reply: fallback } : base;
} else if (ev.type === 'error') patch = { error: true, errorKind: ev.message };
else return turns;
const next = turns.slice();
Expand Down
22 changes: 15 additions & 7 deletions assets/src/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,26 @@ import { collectSteps } from './steps.js';
export function eventsToTurn(events) {
const turn = { reply: '', steps: [], outcome: null, error: false };
const list = Array.isArray(events) ? events : [];
for (const ev of list) {
// Each assistant_text event is one complete text block; join blocks with a
// blank line so consecutive blocks never concatenate mid-sentence.
// Tool activity indices ("Working on it" is a placeholder frame, not a real
// step). An assistant_text block BETWEEN tool activity is interim research
// narration and is dropped; the opener (before any tool) and the closing
// answer (after the last tool) are kept, joined with a blank line.
const isTool = (ev) =>
ev.type === 'tool_step' || (ev.type === 'progress' && ev.data?.summary !== 'Working on it');
const toolIndices = list.flatMap((ev, i) => (isTool(ev) ? [i] : []));
const firstTool = toolIndices.length ? toolIndices[0] : Infinity;
const lastTool = toolIndices.length ? toolIndices[toolIndices.length - 1] : -1;
list.forEach((ev, i) => {
if (ev.type === 'assistant_text' && typeof ev.data?.text === 'string') {
turn.reply = turn.reply ? `${turn.reply}\n\n${ev.data.text}` : ev.data.text;
}
else if (ev.type === 'error') turn.error = true;
if (i < firstTool || i > lastTool) {
turn.reply = turn.reply ? `${turn.reply}\n\n${ev.data.text}` : ev.data.text;
}
} else if (ev.type === 'error') turn.error = true;
else if (ev.type === 'result') {
turn.outcome = ev.data || null;
if (ev.data?.is_error) turn.error = true;
}
}
});
turn.steps = collectSteps(list);
return turn;
}
39 changes: 34 additions & 5 deletions assets/test/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { h } from 'preact';
import { renderToString } from 'preact-render-to-string';
import {
Thread, applyStreamEvent, sendTurn, visibleSteps, stepDuration, reduceSignal, computeShowDraftPrompt, reduceScrollEvent,
Thread, applyStreamEvent, sendTurn, visibleSteps, stepDuration, reduceSignal, computeShowDraftPrompt, reduceScrollEvent, composeFinal,
} from '../src/components.jsx';

// Node's test runner has no DOM; components.jsx reads window.RoundtableConfig
Expand Down Expand Up @@ -131,7 +131,7 @@ test('applyStreamEvent marks done with doneAt on result without stopping later d
turns = applyStreamEvent(turns, { type: 'result' }, 120);
turns = applyStreamEvent(turns, { type: 'guarded_text_delta', text: ' run late' }, 130);
assert.equal(turns[0].reply, 'because shortcodes run late');
assert.equal(turns[0].finalText, 'TOTALLY DIFFERENT TEXT');
assert.deepEqual(turns[0].finalBlocks, [{ text: 'TOTALLY DIFFERENT TEXT', startStep: 0 }]);
assert.equal(turns[0].done, true);
assert.equal(turns[0].doneAt, 120);
assert.equal(turns[0].error, false);
Expand All @@ -142,7 +142,6 @@ test('applyStreamEvent falls back to assistant_text as reply when a turn ends wi
turns = applyStreamEvent(turns, { type: 'assistant_text', text: 'I can only help with questions about this community.' }, 100);
turns = applyStreamEvent(turns, { type: 'result' }, 110);
assert.equal(turns[0].reply, 'I can only help with questions about this community.');
assert.equal(turns[0].reply, turns[0].finalText);
assert.equal(turns[0].done, true);
assert.equal(turns[0].doneAt, 110);
});
Expand Down Expand Up @@ -464,11 +463,41 @@ test('computeShowDraftPrompt in newTopicMode ignores topicWorthy and defers to c
}), false);
});

test('applyStreamEvent joins consecutive assistant_text blocks with a blank line in finalText', () => {
test('applyStreamEvent joins consecutive assistant_text blocks with a blank line at result', () => {
const turns = [{ role: 'agent', reply: '', steps: [], error: false }];
let t = applyStreamEvent(turns, { type: 'assistant_text', text: 'Part one.' }, 1000);
t = applyStreamEvent(t, { type: 'assistant_text', text: 'Part two.' }, 1010);
assert.equal(t[0].finalText, 'Part one.\n\nPart two.');
t = applyStreamEvent(t, { type: 'result' }, 1020);
assert.equal(t[0].reply, 'Part one.\n\nPart two.');
});

test('applyStreamEvent drops a mid-research text segment but keeps the opener and the answer', () => {
// Opener streams before any real step, narration streams between steps,
// the answer streams after the last step. Only the narration is dropped.
let t = [{ role: 'agent', reply: '', steps: [{ summary: 'Working on it', at: 1 }], done: false, error: false }];
t = applyStreamEvent(t, { type: 'guarded_text_delta', text: 'I hear you — on it.' }, 10);
t = applyStreamEvent(t, { type: 'progress', summary: 'Searching the code' }, 20);
t = applyStreamEvent(t, { type: 'guarded_text_delta', text: 'Good, that confirms it. Now let me check more.' }, 30);
t = applyStreamEvent(t, { type: 'progress', summary: 'Reading a file' }, 40);
t = applyStreamEvent(t, { type: 'guarded_text_delta', text: 'Here is the answer.' }, 50);
t = applyStreamEvent(t, { type: 'result' }, 60);
assert.equal(t[0].reply, 'I hear you — on it.\n\nHere is the answer.');
});

test('composeFinal drops interim assistant_text blocks the same way', () => {
const turn = {
steps: [
{ summary: 'Working on it', at: 1 },
{ summary: 'Searching the code', at: 2 },
{ summary: 'Reading a file', at: 3 },
],
finalBlocks: [
{ text: 'Opener.', startStep: 0 },
{ text: 'Interim narration.', startStep: 1 },
{ text: 'The answer.', startStep: 2 },
],
};
assert.equal(composeFinal(turn), 'Opener.\n\nThe answer.');
});

test('reduceScrollEvent consumes a pending programmatic scroll as still sticking', () => {
Expand Down
21 changes: 21 additions & 0 deletions assets/test/events.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,24 @@ test('flags an error turn', () => {
const turn = eventsToTurn([{ type: 'error', data: { message: 'boom' } }]);
assert.equal(turn.error, true);
});

test('drops assistant_text between tool activity, keeps opener and closing answer', () => {
const turn = eventsToTurn([
{ type: 'assistant_text', data: { text: 'Opener.' } },
{ type: 'tool_step', data: { name: 'read' } },
{ type: 'assistant_text', data: { text: 'Interim narration.' } },
{ type: 'progress', data: { summary: 'Reading a file' } },
{ type: 'assistant_text', data: { text: 'The answer.' } },
{ type: 'result', data: { subtype: 'success' } },
]);
assert.equal(turn.reply, 'Opener.\n\nThe answer.');
});

test('the Working on it placeholder does not count as tool activity', () => {
const turn = eventsToTurn([
{ type: 'progress', data: { summary: 'Working on it' } },
{ type: 'assistant_text', data: { text: 'Only text.' } },
{ type: 'result', data: { subtype: 'success' } },
]);
assert.equal(turn.reply, 'Only text.');
});
Loading