v0.8.13: tool cards paired by call id, markdown rendered while it streams

Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.

Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
  forever and gave its result to the second card: both were paired by name.
  cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
  id travels with the signals now (toolEvent(phase, id, name, detail),
  toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
  same way AgentCore::history() fills it from tool_call_id / tool_calls_json
  for a replayed session. Only a call without an id -- an old text-protocol
  session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
  ends and settles every open row; a call that never came back shows "no
  result" (state "unfinished") and no longer claims an exit code.

Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
  when the turn ended. A 120 ms timer now re-renders the open answer with the
  engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
  list, a table or a code fence fills in formatted as it arrives. A row keeps
  its own "html" field; a replayed session renders once, where its rows are
  built.

QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
  pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
  (engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
  ...), which is also what lets --uitest drive the exact path; rowJson(i)
  returns a transcript row as JSON for it (an object would not survive
  QMetaObject::invokeMethod).

Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.

Version 0.8.13.
This commit is contained in:
WHC
2026-10-11 12:44:20 +08:00
parent cbd71b83c7
commit 8694afca94
9 changed files with 302 additions and 70 deletions
+29 -10
View File
@@ -306,8 +306,10 @@ QVariantMap sessionRow(const QString &kind, const QString &role) {
r["kind"] = kind;
r["role"] = role;
r["text"] = QString();
r["html"] = QString();
r["complete"] = false;
r["name"] = QString();
r["callId"] = QString();
r["state"] = QString();
r["detail"] = QString();
r["output"] = QString();
@@ -315,19 +317,29 @@ QVariantMap sessionRow(const QString &kind, const QString &role) {
return r;
}
/* The last tool row with this name that is still open, -1 when none: the
* pairing findTool() does for the live stream, so a replayed result lands on
* the card of its own call. An unmatched call keeps state "unfinished": an
* interrupted turn should not show a spinner that will never stop. */
int findOpenTool(const QVariantList &rows, const QString &name) {
/* The open tool row this result belongs to, -1 when none. Rows are paired by
* call id first: the same tool may run twice in a turn, and pairing by name
* would hang the first card on "running" forever. Only a call without an id
* (an old session written by a text protocol) falls back to the name. */
int findOpenTool(const QVariantList &rows, const QString &callId,
const QString &name) {
for (int i = rows.size() - 1; i >= 0; i--) {
const QVariantMap r = rows.at(i).toMap();
if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool"))
continue;
if (r.value(QStringLiteral("complete")).toBool())
return -1;
continue;
const QString id = r.value(QStringLiteral("callId")).toString();
if (!callId.isEmpty()) {
if (id == callId)
return i;
continue; /* another call: its card may be further back */
}
if (!id.isEmpty())
continue;
if (r.value(QStringLiteral("name")).toString() == name)
return i;
return -1;
}
return -1;
}
@@ -385,10 +397,12 @@ QVariantList AgentCore::history() const {
/* Tool output: role=tool in the native protocol, a user message in the
* text ones. Both belong on the card of the call they answer. */
const QString name = utf8(m->tool_name);
const int at = findOpenTool(rows, name);
const QString callId = utf8(m->tool_call_id);
const int at = findOpenTool(rows, callId, name);
if (at < 0) {
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
r["name"] = name;
r["callId"] = callId;
r["output"] = utf8(m->content);
r["state"] = "done";
r["complete"] = true;
@@ -441,9 +455,11 @@ QVariantList AgentCore::history() const {
const QJsonArray calls =
QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array();
for (const QJsonValue &v : calls) {
const QJsonObject fn = v.toObject().value(QStringLiteral("function")).toObject();
const QJsonObject o = v.toObject();
const QJsonObject fn = o.value(QStringLiteral("function")).toObject();
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
r["name"] = fn.value(QStringLiteral("name")).toString();
r["callId"] = o.value(QStringLiteral("id")).toString();
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
r["state"] = "unfinished"; /* until its result shows up below */
rows.append(r);
@@ -790,19 +806,22 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
const char *detail, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString p = QString::fromUtf8(phase ? phase : "");
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
const QString n = QString::fromUtf8(call ? call->name : "");
const QString d = QString::fromUtf8(detail ? detail : "");
self->post([self, p, n, d] { emit self->toolEvent(p, n, d); });
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); });
}
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
size_t len, int exit_code, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud);
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
const QString n = QString::fromUtf8(call ? call->name : "");
QString o = QString::fromUtf8(QByteArray(out, static_cast<int>(len)));
if (o.size() > 4000)
o = o.left(4000) + QStringLiteral("\n… (truncated for the UI)");
self->post([self, n, exit_code, o] { emit self->toolResult(n, exit_code, o); });
self->post(
[self, id, n, exit_code, o] { emit self->toolResult(id, n, exit_code, o); });
}
void AgentCore::hookTodo(const char *text, void *ud) {
+6 -2
View File
@@ -148,9 +148,13 @@ class AgentCore : public QObject {
void statusChanged(const QString &text);
void assistantDelta(const QString &text);
void thinkDelta(const QString &text);
void toolEvent(const QString &phase, const QString &name,
/* A call is identified by its id: the same tool can run twice in one turn,
* and only the id tells the two cards apart. Text protocols carry ids too
* (cst_tool_call_from_native, the ctl parser), so this is never empty. */
void toolEvent(const QString &phase, const QString &id, const QString &name,
const QString &detail);
void toolResult(const QString &name, int exitCode, const QString &output);
void toolResult(const QString &id, const QString &name, int exitCode,
const QString &output);
void todoChanged(const QString &text);
void permissionRequested(const QString &tool, const QString &detail,
const QString &preview);
+78
View File
@@ -12,6 +12,8 @@
#include <QClipboard>
#include <QFile>
#include <QImage>
#include <QJsonDocument>
#include <QJsonObject>
#include <QPointingDevice>
#include <QQuickItem>
#include <QQmlApplicationEngine>
@@ -372,6 +374,82 @@ int runUiTest(AgentCore &core, const QString &size) {
check(false, QStringLiteral("there is a clipboard to paste from"));
}
/* --- tool calls: two calls of one tool are two cards ------------------ */
const int rows0 = list ? list->property("count").toInt() : -1;
auto rowAt = [&](int i) {
QVariant v;
QVariantMap r;
if (!QMetaObject::invokeMethod(win, "rowJson", Q_RETURN_ARG(QVariant, v),
Q_ARG(QVariant, i)))
return r;
return QJsonDocument::fromJson(v.toString().toUtf8()).object().toVariantMap();
};
auto event4 = [&](const char *fn, const QVariant &a, const QVariant &b,
const QVariant &c, const QVariant &d) {
return QMetaObject::invokeMethod(win, fn, Q_ARG(QVariant, a),
Q_ARG(QVariant, b), Q_ARG(QVariant, c),
Q_ARG(QVariant, d));
};
check(event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"vendor\"}")) &&
list->property("count").toInt() == rows0 + 1,
QStringLiteral("a tool call opens a card"));
event4("engineToolEvent", QStringLiteral("run"), QStringLiteral("call-1"),
QStringLiteral("list_dir"), QString());
/* the same tool a second time, while the first call is still open: pairing
by name is what left the first card on "running" forever */
event4("engineToolEvent", QStringLiteral("open"), QStringLiteral("call-2"),
QStringLiteral("list_dir"), QStringLiteral("{\"path\":\"src\"}"));
event4("engineToolResult", QStringLiteral("call-1"), QStringLiteral("list_dir"),
QVariant(0), QStringLiteral("exit=0 bytes=532"));
check(list->property("count").toInt() == rows0 + 2,
QStringLiteral("two calls of one tool are two cards (%1 -> %2)")
.arg(rows0)
.arg(list->property("count").toInt()));
{
const QVariantMap first = rowAt(rows0), second = rowAt(rows0 + 1);
check(first.value(QStringLiteral("state")).toString() == QLatin1String("done") &&
first.value(QStringLiteral("output")).toString() ==
QLatin1String("exit=0 bytes=532"),
QStringLiteral("the result lands on the card of its own call "
"(state '%1', output '%2', keys [%3])")
.arg(first.value(QStringLiteral("state")).toString(),
first.value(QStringLiteral("output")).toString(),
first.keys().join(QLatin1Char(','))));
check(second.value(QStringLiteral("state")).toString() == QLatin1String("pending") &&
second.value(QStringLiteral("detail")).toString().contains(
QLatin1String("\"src\"")) &&
second.value(QStringLiteral("output")).toString().isEmpty(),
QStringLiteral("the other card is untouched (state '%1')")
.arg(second.value(QStringLiteral("state")).toString()));
}
/* and a turn that ends with the call still open settles it */
check(QMetaObject::invokeMethod(win, "engineTurnFinished", Q_ARG(QVariant, 0),
Q_ARG(QVariant, QString())),
QStringLiteral("the turn end is reachable too"));
{
const QVariantMap second = rowAt(rows0 + 1);
check(second.value(QStringLiteral("complete")).toBool() &&
second.value(QStringLiteral("state")).toString() ==
QLatin1String("unfinished"),
QStringLiteral("an open call is settled when the turn ends"));
}
/* --- the answer is rendered while it streams -------------------------- */
if (QMetaObject::invokeMethod(win, "engineAssistant", Q_ARG(QVariant, QStringLiteral("**bold** text")))) {
const int last = list->property("count").toInt() - 1;
QMetaObject::invokeMethod(win, "renderStreaming");
const QVariantMap row = rowAt(last);
check(row.value(QStringLiteral("kind")).toString() == QLatin1String("message") &&
!row.value(QStringLiteral("complete")).toBool(),
QStringLiteral("a delta opens a streaming answer"));
check(row.value(QStringLiteral("html")).toString().contains(QLatin1Char('<')) &&
row.value(QStringLiteral("text")).toString().contains(QLatin1String("bold")),
QStringLiteral("markdown is rendered while the answer streams"));
} else {
check(false, QStringLiteral("assistant deltas are reachable"));
}
/* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900);
check(docked && !docked->isVisible(),