diff --git a/app/README.md b/app/README.md index 04f2a11..6b2ee30 100644 --- a/app/README.md +++ b/app/README.md @@ -175,6 +175,31 @@ is refused with a reason, the composer shows one chip, "clear all" empties the list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and `attachClipboard()` behave. +## Transcript, tool calls, streaming text + +- A tool call is a card of its own, and the call **id** is what pairs it with + its result: the same tool may run twice in a turn, and pairing by name (what + the window did before) hung the first card on "running" forever while the + second one took its result. `AgentCore::toolEvent()` and `toolResult()` carry + the id (`ctl_tool_call_t.id`), `AgentCore::history()` fills it from the + session (`tool_call_id`, `tool_calls_json`), and only an id-less call from an + old text-protocol session still falls back to the name. +- Nothing may keep spinning after a turn: `closeAllRows()` settles every open + row when the turn ends, and a call with no result becomes "no result" + (`unfinished`) instead of a spinner that runs forever. +- The answer is rendered **while it streams**: a 120 ms timer re-renders the + open answer with the engine's own markdown renderer (`cst_web_md_html`, via + `AgentCore::renderMarkdown()`), so a list or a code fence fills in formatted + instead of turning into markdown when the turn is over. A replayed session + renders once, when its rows are built. +- Every signal handler is a plain function on the window + (`engineToolEvent`, `engineToolResult`, `engineTurnFinished`, + `engineAssistant`, ...) so `--uitest` can drive the same path: it opens two + calls of one tool, delivers one result, and asserts that the result landed on + its own card and that the turn end settled the other one. `rowJson(i)` exists + for that (a row as JSON — an object would not survive + `QMetaObject::invokeMethod`). + ## Tools the app provides A tool implemented in the front end is registered with `cst_tool_register()` diff --git a/app/qml/ChatView.qml b/app/qml/ChatView.qml index 083a5ab..aaa3464 100644 --- a/app/qml/ChatView.qml +++ b/app/qml/ChatView.qml @@ -40,9 +40,11 @@ Item { role: model.role text: model.text complete: model.complete - html: (model.role === "agent" || model.role === "error") && - (model.complete || model.text.length > 12000) && - model.text.length > 0 + /* html comes from the streaming renderer (Main.qml) or, + * for a replayed session, from here once. */ + html: model.html.length > 0 ? model.html + : (model.role === "agent" || model.role === "error") && + model.complete && model.text.length > 0 ? agent.renderMarkdown(model.text) : "" } } @@ -56,6 +58,7 @@ Item { Component { id: toolComp ToolCard { + objectName: "toolCard" name: model.name state: model.state detail: model.detail diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 91de097..1ea0570 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -59,8 +59,9 @@ ApplicationWindow { function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null } function addRow(kind, role, state) { - transcript.append({ kind: kind, role: role, text: "", complete: false, - name: "", state: state, detail: "", output: "", exitCode: 0 }) + transcript.append({ kind: kind, role: role, text: "", html: "", complete: false, + name: "", callId: "", state: state, detail: "", output: "", + exitCode: 0 }) } // The engine streams one kind at a time: close whatever was open before. @@ -77,33 +78,72 @@ ApplicationWindow { transcript.setProperty(lastIndex(), "text", lastRow().text + text) } - // The last tool row with this name that is still open, -1 when none. - function findTool(name) { + // The open card of this call, -1 when none. The id is what tells two calls + // of the same tool apart (pairing by name left the first card running + // forever); a call without one -- an old text-protocol session -- falls + // back to the name. + function findTool(callId, name) { for (let i = transcript.count - 1; i >= 0; i--) { const r = transcript.get(i) - if (r.kind !== "tool") + if (r.kind !== "tool" || r.complete) continue - if (!r.complete && r.name === name) + if (callId.length) { + if (r.callId === callId) + return i + continue /* another call: its card is further back */ + } + if (r.callId.length) + continue + if (r.name === name) return i - if (r.complete) - return -1 + return -1 } return -1 } - function openTool(name, state) { + function openTool(callId, name, state) { closeOpenRows("tool") addRow("tool", "tool", state) transcript.setProperty(lastIndex(), "name", name) + transcript.setProperty(lastIndex(), "callId", callId) return lastIndex() } function submit(text) { - transcript.append({ kind: "message", role: "user", text: text, complete: true, - name: "", state: "", detail: "", output: "", exitCode: 0 }) + transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true, + name: "", callId: "", state: "", detail: "", output: "", + exitCode: 0 }) agent.send(text) } + // A turn is over: nothing else is coming, so nothing may keep spinning. + function closeAllRows() { + for (let i = 0; i < transcript.count; i++) { + const r = transcript.get(i) + if (r.complete) + continue + if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" || + r.state === "running")) + transcript.setProperty(i, "state", "unfinished") + transcript.setProperty(i, "complete", true) + } + } + + // Markdown while it streams: the open answer is re-rendered with the + // engine's own renderer (the timer below), so the text fills in formatted + // instead of turning into markdown when the turn ends. + function renderStreaming() { + for (let i = transcript.count - 1; i >= 0; i--) { + const r = transcript.get(i) + if (r.kind !== "message" || r.role !== "agent" || r.complete) + continue + const html = agent.renderMarkdown(r.text) + if (html !== r.html) + transcript.setProperty(i, "html", html) + return + } + } + // ---- sessions -------------------------------------------------------- function reloadSessions() { win.sessionsView = agent.sessions() } @@ -453,51 +493,94 @@ ApplicationWindow { } // ---- engine -> UI ---------------------------------------------------- + /* The signal handlers only forward: every one of them has a function + * here, so the same path can be driven from --uitest (which is how the + * two-calls-of-one-tool case stays fixed). */ + function engineAssistant(text) { win.appendDelta("message", "agent", text) } + function engineThink(text) { win.appendDelta("think", "agent", text) } + + function engineToolEvent(phase, id, name, detail) { + win.closeOpenRows("tool") + let i = win.findTool(id, name) + if (phase === "open" || phase === "arg") { + if (i < 0) + i = win.openTool(id, name, "pending") + if (detail.length) + transcript.setProperty(i, "detail", transcript.get(i).detail + detail) + return + } + if (phase === "ready" || phase === "run") { + if (i < 0) + i = win.openTool(id, name, phase) + transcript.setProperty(i, "state", phase === "run" ? "running" : "queued") + transcript.setProperty(i, "detail", detail) + return + } + // done | denied + if (i < 0) + i = win.openTool(id, name, phase) + transcript.setProperty(i, "state", phase) + if (detail.length && transcript.get(i).output.length === 0) + transcript.setProperty(i, "output", detail) + } + + function engineToolResult(id, name, exitCode, output) { + let i = win.findTool(id, name) + if (i < 0) /* a result whose call is not in the view: its own card */ + i = win.openTool(id, name, "done") + transcript.setProperty(i, "output", output) + transcript.setProperty(i, "exitCode", exitCode) + transcript.setProperty(i, "state", "done") + transcript.setProperty(i, "complete", true) + } + + function engineTurnFinished(rc, err) { + win.renderStreaming(); /* one last render while the row is still open */ + win.closeAllRows() + if (err.length) + transcript.append({ kind: "note", role: "error", text: err, html: "", + complete: true, name: "", callId: "", state: "error", + detail: "", output: "", exitCode: 0 }) + win.statusText = "" + } + + /* For --uitest: one transcript row, or null. */ + function rowAt(i) { + return (i >= 0 && i < transcript.count) ? transcript.get(i) : null + } + + /* For --uitest: the same row as JSON (a QObject's return value does not + * survive QMetaObject::invokeMethod, a string does). */ + function rowJson(i) { + return JSON.stringify(rowAt(i)) + } + + /* While a turn runs the open answer is re-rendered a few times a second: + * markdown text fills in formatted rather than at the end of the turn. */ + Timer { + interval: 120 + repeat: true + running: agent.running + onTriggered: win.renderStreaming() + } + Connections { target: agent function onSessionChanged() { win.reloadSessions(); win.replay() } function onSessionListChanged() { win.reloadSessions() } - function onAssistantDelta(text) { win.appendDelta("message", "agent", text) } - function onThinkDelta(text) { win.appendDelta("think", "agent", text) } + function onAssistantDelta(text) { win.engineAssistant(text) } + function onThinkDelta(text) { win.engineThink(text) } function onStatusChanged(text) { win.statusText = text } - function onToolEvent(phase, name, detail) { - win.closeOpenRows("tool") - let i = win.findTool(name) - if (phase === "open" || phase === "arg") { - if (i < 0) - i = win.openTool(name, "pending") - if (detail.length) - transcript.setProperty(i, "detail", transcript.get(i).detail + detail) - return - } - if (phase === "ready" || phase === "run") { - if (i < 0) - i = win.openTool(name, phase) - transcript.setProperty(i, "state", phase === "run" ? "running" : "queued") - transcript.setProperty(i, "detail", detail) - return - } - // done | denied - if (i < 0) - i = win.openTool(name, phase) - transcript.setProperty(i, "state", phase) - if (detail.length && transcript.get(i).output.length === 0) - transcript.setProperty(i, "output", detail) + function onToolEvent(phase, id, name, detail) { + win.engineToolEvent(phase, id, name, detail) } - function onToolResult(name, exitCode, output) { - let i = win.findTool(name) - if (i < 0) - i = transcript.count - 1 - if (i < 0 || transcript.get(i).kind !== "tool") - return - transcript.setProperty(i, "output", output) - transcript.setProperty(i, "exitCode", exitCode) - transcript.setProperty(i, "complete", true) + function onToolResult(id, name, exitCode, output) { + win.engineToolResult(id, name, exitCode, output) } function onPermissionRequested(tool, detail, preview) { @@ -513,17 +596,11 @@ ApplicationWindow { ask.open() } - function onTurnFinished(rc, err) { - win.closeOpenRows("") - if (err.length) - transcript.append({ kind: "note", role: "error", text: err, complete: true, - name: "", state: "error", detail: "", output: "", exitCode: 0 }) - win.statusText = "" - } + function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) } function onRunningChanged() { if (!agent.running) - win.closeOpenRows("") + win.closeAllRows() } } } diff --git a/app/qml/Sidebar.qml b/app/qml/Sidebar.qml index 276d659..3a0534f 100644 --- a/app/qml/Sidebar.qml +++ b/app/qml/Sidebar.qml @@ -273,6 +273,30 @@ Rectangle { checked: side.planMode implicitHeight: Theme.touchTarget onClicked: side.planModeRequested(checked) + /* The Basic style's indicator is a dark pill with a dark + * knob here, so it is drawn: thumb-sized, accent when + * on, clearly off when not. */ + indicator: Rectangle { + implicitWidth: 44 + implicitHeight: 22 + x: planSwitch.leftPadding + y: parent.height / 2 - height / 2 + radius: 11 + color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt + border.width: 1 + border.color: planSwitch.checked ? Theme.accent : Theme.border + Rectangle { + width: 16 + height: 16 + y: 2 + x: planSwitch.checked ? parent.width - width - 3 : 3 + radius: 8 + color: planSwitch.checked ? Theme.text : Theme.textDim + Behavior on x { + NumberAnimation { duration: 120; easing.type: Easing.OutCubic } + } + } + } } Text { text: "Plan mode" diff --git a/app/qml/ToolCard.qml b/app/qml/ToolCard.qml index 03e3723..682bf87 100644 --- a/app/qml/ToolCard.qml +++ b/app/qml/ToolCard.qml @@ -84,7 +84,9 @@ Rectangle { Text { id: chip anchors.centerIn: parent - text: card.stateText + (card.hasResult ? " · exit " + card.exitCode : "") + /* "no result" is not an outcome: no exit code belongs on it. */ + text: card.stateText + (card.hasResult && card.state !== "unfinished" + ? " · exit " + card.exitCode : "") color: card.stateColor font.pixelSize: Theme.smallSize } diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 89fc512..364c6a7 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -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(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(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(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) { diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index 4472ecc..f1179fd 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -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); diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index a321c5a..900c314 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -12,6 +12,8 @@ #include #include #include +#include +#include #include #include #include @@ -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(), diff --git a/include/cst_version.h b/include/cst_version.h index e2fdb70..556a768 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 12 +#define CST_VERSION_PATCH 13 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x)