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:
@@ -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
|
list, and — with an 8×8 image put on the clipboard — `clipboardHasImage()` and
|
||||||
`attachClipboard()` behave.
|
`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
|
## Tools the app provides
|
||||||
|
|
||||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
A tool implemented in the front end is registered with `cst_tool_register()`
|
||||||
|
|||||||
@@ -40,9 +40,11 @@ Item {
|
|||||||
role: model.role
|
role: model.role
|
||||||
text: model.text
|
text: model.text
|
||||||
complete: model.complete
|
complete: model.complete
|
||||||
html: (model.role === "agent" || model.role === "error") &&
|
/* html comes from the streaming renderer (Main.qml) or,
|
||||||
(model.complete || model.text.length > 12000) &&
|
* for a replayed session, from here once. */
|
||||||
model.text.length > 0
|
html: model.html.length > 0 ? model.html
|
||||||
|
: (model.role === "agent" || model.role === "error") &&
|
||||||
|
model.complete && model.text.length > 0
|
||||||
? agent.renderMarkdown(model.text) : ""
|
? agent.renderMarkdown(model.text) : ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ Item {
|
|||||||
Component {
|
Component {
|
||||||
id: toolComp
|
id: toolComp
|
||||||
ToolCard {
|
ToolCard {
|
||||||
|
objectName: "toolCard"
|
||||||
name: model.name
|
name: model.name
|
||||||
state: model.state
|
state: model.state
|
||||||
detail: model.detail
|
detail: model.detail
|
||||||
|
|||||||
+130
-53
@@ -59,8 +59,9 @@ ApplicationWindow {
|
|||||||
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
|
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
|
||||||
|
|
||||||
function addRow(kind, role, state) {
|
function addRow(kind, role, state) {
|
||||||
transcript.append({ kind: kind, role: role, text: "", complete: false,
|
transcript.append({ kind: kind, role: role, text: "", html: "", complete: false,
|
||||||
name: "", state: state, detail: "", output: "", exitCode: 0 })
|
name: "", callId: "", state: state, detail: "", output: "",
|
||||||
|
exitCode: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// The engine streams one kind at a time: close whatever was open before.
|
// 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)
|
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The last tool row with this name that is still open, -1 when none.
|
// The open card of this call, -1 when none. The id is what tells two calls
|
||||||
function findTool(name) {
|
// 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--) {
|
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||||
const r = transcript.get(i)
|
const r = transcript.get(i)
|
||||||
if (r.kind !== "tool")
|
if (r.kind !== "tool" || r.complete)
|
||||||
continue
|
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
|
return i
|
||||||
if (r.complete)
|
return -1
|
||||||
return -1
|
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTool(name, state) {
|
function openTool(callId, name, state) {
|
||||||
closeOpenRows("tool")
|
closeOpenRows("tool")
|
||||||
addRow("tool", "tool", state)
|
addRow("tool", "tool", state)
|
||||||
transcript.setProperty(lastIndex(), "name", name)
|
transcript.setProperty(lastIndex(), "name", name)
|
||||||
|
transcript.setProperty(lastIndex(), "callId", callId)
|
||||||
return lastIndex()
|
return lastIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit(text) {
|
function submit(text) {
|
||||||
transcript.append({ kind: "message", role: "user", text: text, complete: true,
|
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
|
||||||
name: "", state: "", detail: "", output: "", exitCode: 0 })
|
name: "", callId: "", state: "", detail: "", output: "",
|
||||||
|
exitCode: 0 })
|
||||||
agent.send(text)
|
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 --------------------------------------------------------
|
// ---- sessions --------------------------------------------------------
|
||||||
function reloadSessions() { win.sessionsView = agent.sessions() }
|
function reloadSessions() { win.sessionsView = agent.sessions() }
|
||||||
|
|
||||||
@@ -453,51 +493,94 @@ ApplicationWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- engine -> UI ----------------------------------------------------
|
// ---- 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 {
|
Connections {
|
||||||
target: agent
|
target: agent
|
||||||
|
|
||||||
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
||||||
function onSessionListChanged() { win.reloadSessions() }
|
function onSessionListChanged() { win.reloadSessions() }
|
||||||
|
|
||||||
function onAssistantDelta(text) { win.appendDelta("message", "agent", text) }
|
function onAssistantDelta(text) { win.engineAssistant(text) }
|
||||||
function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
|
function onThinkDelta(text) { win.engineThink(text) }
|
||||||
|
|
||||||
function onStatusChanged(text) { win.statusText = text }
|
function onStatusChanged(text) { win.statusText = text }
|
||||||
|
|
||||||
function onToolEvent(phase, name, detail) {
|
function onToolEvent(phase, id, name, detail) {
|
||||||
win.closeOpenRows("tool")
|
win.engineToolEvent(phase, id, name, detail)
|
||||||
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 onToolResult(name, exitCode, output) {
|
function onToolResult(id, name, exitCode, output) {
|
||||||
let i = win.findTool(name)
|
win.engineToolResult(id, name, exitCode, output)
|
||||||
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 onPermissionRequested(tool, detail, preview) {
|
function onPermissionRequested(tool, detail, preview) {
|
||||||
@@ -513,17 +596,11 @@ ApplicationWindow {
|
|||||||
ask.open()
|
ask.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTurnFinished(rc, err) {
|
function onTurnFinished(rc, err) { win.engineTurnFinished(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 onRunningChanged() {
|
function onRunningChanged() {
|
||||||
if (!agent.running)
|
if (!agent.running)
|
||||||
win.closeOpenRows("")
|
win.closeAllRows()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,30 @@ Rectangle {
|
|||||||
checked: side.planMode
|
checked: side.planMode
|
||||||
implicitHeight: Theme.touchTarget
|
implicitHeight: Theme.touchTarget
|
||||||
onClicked: side.planModeRequested(checked)
|
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 {
|
||||||
text: "Plan mode"
|
text: "Plan mode"
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ Rectangle {
|
|||||||
Text {
|
Text {
|
||||||
id: chip
|
id: chip
|
||||||
anchors.centerIn: parent
|
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
|
color: card.stateColor
|
||||||
font.pixelSize: Theme.smallSize
|
font.pixelSize: Theme.smallSize
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-10
@@ -306,8 +306,10 @@ QVariantMap sessionRow(const QString &kind, const QString &role) {
|
|||||||
r["kind"] = kind;
|
r["kind"] = kind;
|
||||||
r["role"] = role;
|
r["role"] = role;
|
||||||
r["text"] = QString();
|
r["text"] = QString();
|
||||||
|
r["html"] = QString();
|
||||||
r["complete"] = false;
|
r["complete"] = false;
|
||||||
r["name"] = QString();
|
r["name"] = QString();
|
||||||
|
r["callId"] = QString();
|
||||||
r["state"] = QString();
|
r["state"] = QString();
|
||||||
r["detail"] = QString();
|
r["detail"] = QString();
|
||||||
r["output"] = QString();
|
r["output"] = QString();
|
||||||
@@ -315,19 +317,29 @@ QVariantMap sessionRow(const QString &kind, const QString &role) {
|
|||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The last tool row with this name that is still open, -1 when none: the
|
/* The open tool row this result belongs to, -1 when none. Rows are paired by
|
||||||
* pairing findTool() does for the live stream, so a replayed result lands on
|
* call id first: the same tool may run twice in a turn, and pairing by name
|
||||||
* the card of its own call. An unmatched call keeps state "unfinished": an
|
* would hang the first card on "running" forever. Only a call without an id
|
||||||
* interrupted turn should not show a spinner that will never stop. */
|
* (an old session written by a text protocol) falls back to the name. */
|
||||||
int findOpenTool(const QVariantList &rows, const QString &name) {
|
int findOpenTool(const QVariantList &rows, const QString &callId,
|
||||||
|
const QString &name) {
|
||||||
for (int i = rows.size() - 1; i >= 0; i--) {
|
for (int i = rows.size() - 1; i >= 0; i--) {
|
||||||
const QVariantMap r = rows.at(i).toMap();
|
const QVariantMap r = rows.at(i).toMap();
|
||||||
if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool"))
|
if (r.value(QStringLiteral("kind")).toString() != QLatin1String("tool"))
|
||||||
continue;
|
continue;
|
||||||
if (r.value(QStringLiteral("complete")).toBool())
|
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)
|
if (r.value(QStringLiteral("name")).toString() == name)
|
||||||
return i;
|
return i;
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
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
|
/* 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. */
|
* text ones. Both belong on the card of the call they answer. */
|
||||||
const QString name = utf8(m->tool_name);
|
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) {
|
if (at < 0) {
|
||||||
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
||||||
r["name"] = name;
|
r["name"] = name;
|
||||||
|
r["callId"] = callId;
|
||||||
r["output"] = utf8(m->content);
|
r["output"] = utf8(m->content);
|
||||||
r["state"] = "done";
|
r["state"] = "done";
|
||||||
r["complete"] = true;
|
r["complete"] = true;
|
||||||
@@ -441,9 +455,11 @@ QVariantList AgentCore::history() const {
|
|||||||
const QJsonArray calls =
|
const QJsonArray calls =
|
||||||
QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array();
|
QJsonDocument::fromJson(QByteArray(m->tool_calls_json)).array();
|
||||||
for (const QJsonValue &v : calls) {
|
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"));
|
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
||||||
r["name"] = fn.value(QStringLiteral("name")).toString();
|
r["name"] = fn.value(QStringLiteral("name")).toString();
|
||||||
|
r["callId"] = o.value(QStringLiteral("id")).toString();
|
||||||
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
|
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
|
||||||
r["state"] = "unfinished"; /* until its result shows up below */
|
r["state"] = "unfinished"; /* until its result shows up below */
|
||||||
rows.append(r);
|
rows.append(r);
|
||||||
@@ -790,19 +806,22 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
|||||||
const char *detail, void *ud) {
|
const char *detail, void *ud) {
|
||||||
AgentCore *self = static_cast<AgentCore *>(ud);
|
AgentCore *self = static_cast<AgentCore *>(ud);
|
||||||
const QString p = QString::fromUtf8(phase ? phase : "");
|
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 n = QString::fromUtf8(call ? call->name : "");
|
||||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
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,
|
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||||
size_t len, int exit_code, void *ud) {
|
size_t len, int exit_code, void *ud) {
|
||||||
AgentCore *self = static_cast<AgentCore *>(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 : "");
|
const QString n = QString::fromUtf8(call ? call->name : "");
|
||||||
QString o = QString::fromUtf8(QByteArray(out, static_cast<int>(len)));
|
QString o = QString::fromUtf8(QByteArray(out, static_cast<int>(len)));
|
||||||
if (o.size() > 4000)
|
if (o.size() > 4000)
|
||||||
o = o.left(4000) + QStringLiteral("\n… (truncated for the UI)");
|
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) {
|
void AgentCore::hookTodo(const char *text, void *ud) {
|
||||||
|
|||||||
@@ -148,9 +148,13 @@ class AgentCore : public QObject {
|
|||||||
void statusChanged(const QString &text);
|
void statusChanged(const QString &text);
|
||||||
void assistantDelta(const QString &text);
|
void assistantDelta(const QString &text);
|
||||||
void thinkDelta(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);
|
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 todoChanged(const QString &text);
|
||||||
void permissionRequested(const QString &tool, const QString &detail,
|
void permissionRequested(const QString &tool, const QString &detail,
|
||||||
const QString &preview);
|
const QString &preview);
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
#include <QPointingDevice>
|
#include <QPointingDevice>
|
||||||
#include <QQuickItem>
|
#include <QQuickItem>
|
||||||
#include <QQmlApplicationEngine>
|
#include <QQmlApplicationEngine>
|
||||||
@@ -372,6 +374,82 @@ int runUiTest(AgentCore &core, const QString &size) {
|
|||||||
check(false, QStringLiteral("there is a clipboard to paste from"));
|
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 --- */
|
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||||
resizeTo(win, 460, 900);
|
resizeTo(win, 460, 900);
|
||||||
check(docked && !docked->isVisible(),
|
check(docked && !docked->isVisible(),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define CST_VERSION_MAJOR 0
|
#define CST_VERSION_MAJOR 0
|
||||||
#define CST_VERSION_MINOR 8
|
#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) #x
|
||||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||||
|
|||||||
Reference in New Issue
Block a user