v0.8.9: sessions in the Qt front end, transcript replay, UI test helpers
A conversation is now something the window can pick. The sidebar lists the
workspace's stored sessions and switching replays one into the view.
Sessions (app/src/core/AgentCore.*, app/qml/SessionRow.qml, Sidebar.qml):
- AgentCore::open() resolves the workspace's current session through
cst_agent_store_resolve_default() before the engine starts. Without it the
engine opened session.jsonl in the process's working directory, next to a
session list that stayed empty.
- sessionId property; sessions() (id, title, firstUser, messages, mtime in ms,
current); newSession(), switchSession(), deleteSession() (moves the engine
off a session it has open first), renameSession(); sessionChanged and
sessionListChanged signals. No mutation runs during a turn.
- history() turns the open session back into the rows the live signals build
(kind/role/text/name/state/detail/output/exitCode/complete): system prompt
skipped, notes and attachments shown, assistant reasoning as a think row,
tool_calls_json as one card per call, and each result paired with its call
the way findTool() pairs the live stream. A call with no result keeps state
"unfinished" ("no result"), so a replay never shows a spinner that cannot
stop. Main.qml replays on sessionChanged.
- SessionRow.qml is a file of its own: as an inline component it would not see
the Repeater's modelData. Session controls are disabled while a turn runs.
Tests (app/src/ui/UiTest.cpp, app/src/main.cpp):
- the fixture session is written through the store's own API (a tool call with
its result), then the checks click its row, assert the five replayed rows and
the paired result, start two new sessions (the view empties), delete one by
its button (the open one stays), and switch back. 28 checks in total.
- item() falls back to the visual tree: delegates a model owns are not always
in the window's QObject tree, which is why the rows were invisible to
findChild even though they were on screen.
- --uitest drives a scratch workspace unless --workspace is given; it creates
and deletes sessions. CST_APP_SHOT=file saves the window as a PNG when the
run ends and CST_APP_DUMP=1 prints the session rows seen; both are how the
layout gets looked at on a machine without a screen.
QML: ThinkBlock's click target is a TapHandler on its row (a MouseArea anchored
inside a layout is undefined behavior and Qt said so).
Verified: 28/28 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of a replayed session (cards, tool card with its result, the
think block, the sidebar list).
Version 0.8.9.
This commit is contained in:
+3
-2
@@ -102,8 +102,8 @@ if(CST_APP_GUI)
|
||||
# Sources live in qml/, but a module whose files sit in a subdirectory of
|
||||
# the URI makes Qt register the types with relative URLs ("qmlRegisterType
|
||||
# requires absolute URLs" on every start).
|
||||
foreach(_qml Main ChatView Sidebar MessageCard ToolCard ThinkBlock NoteLine
|
||||
TodoPanel Composer ConsentDialog AskDialog Theme)
|
||||
foreach(_qml Main ChatView Sidebar SessionRow MessageCard ToolCard ThinkBlock
|
||||
NoteLine TodoPanel Composer ConsentDialog AskDialog Theme)
|
||||
set_source_files_properties(qml/${_qml}.qml PROPERTIES QT_RESOURCE_ALIAS ${_qml}.qml)
|
||||
endforeach()
|
||||
qt_add_qml_module(cst-agent-app
|
||||
@@ -113,6 +113,7 @@ if(CST_APP_GUI)
|
||||
qml/Main.qml
|
||||
qml/ChatView.qml
|
||||
qml/Sidebar.qml
|
||||
qml/SessionRow.qml
|
||||
qml/MessageCard.qml
|
||||
qml/ToolCard.qml
|
||||
qml/ThinkBlock.qml
|
||||
|
||||
+35
-4
@@ -80,9 +80,9 @@ One shell, three sizes (everything is styled from `qml/Theme.qml`):
|
||||
|
||||
`--uitest` checks this without a human: it loads the window, clicks with
|
||||
synthesized **mouse** events, taps with a synthesized **touch** device, types
|
||||
into the composer, then narrows the window to 460 px to assert the dock →
|
||||
drawer switch and opens/closes the drawer. One line per check, non-zero exit
|
||||
on failure:
|
||||
into the composer, drives the session list, then narrows the window to 460 px
|
||||
to assert the dock → drawer switch and opens/closes the drawer. One line per
|
||||
check, non-zero exit on failure:
|
||||
|
||||
```sh
|
||||
QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
|
||||
@@ -92,6 +92,36 @@ QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest
|
||||
# ...
|
||||
```
|
||||
|
||||
## Sessions
|
||||
|
||||
A GUI session is a file in the workspace's store (`.agent/sessions/<id>.jsonl`),
|
||||
the same one the CLI, the TUI and the WebUI use — `AgentCore::open()` resolves
|
||||
the workspace's current session before the engine starts, so nothing is written
|
||||
next to the process's working directory.
|
||||
|
||||
- The sidebar lists the store (newest first): title or first prompt, message
|
||||
count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×`
|
||||
deletes one (the engine first moves off a session it has open, so it never
|
||||
sits on a file that is gone), and a click switches.
|
||||
- Switching **replays** the transcript: `AgentCore::history()` walks the stored
|
||||
messages and returns the same rows the live signals build — messages,
|
||||
thoughts, notes, and one card per tool call, with its result paired by name
|
||||
(a call with no result keeps state `unfinished`, so no spinner runs
|
||||
forever). A reopened session therefore renders like the turn that wrote it.
|
||||
- Session controls are disabled while a turn runs: switching would move the
|
||||
engine out from under it.
|
||||
|
||||
`--uitest` covers the flow end to end: `writeFixtureSession()` writes a small
|
||||
session through the store API (a tool call with its result), the checks click
|
||||
its row, assert the five replayed rows and the paired result, start two new
|
||||
sessions (the view empties), delete one by its `×` (the open one stays), and
|
||||
switch back.
|
||||
|
||||
`--uitest` uses a scratch workspace unless `--workspace` is given, since it
|
||||
creates and deletes sessions. On a machine without a screen,
|
||||
`CST_APP_SHOT=shot.png` makes the run save the window as a PNG when it is
|
||||
done; `CST_APP_DUMP=1` prints how many session rows the visual tree holds.
|
||||
|
||||
## Tools the app provides
|
||||
|
||||
A tool implemented in the front end is registered with `cst_tool_register()`
|
||||
@@ -147,7 +177,8 @@ The build flag that keeps Unix helpers out of the picture is
|
||||
- [x] QML shell: transcript cards, tool cards with diff coloring, thinking
|
||||
blocks, consent/ask dialogs, sidebar, responsive dock/drawer layout,
|
||||
`--uitest` for mouse, touch and small-screen checks
|
||||
- [x] session list in the sidebar: new / switch / delete, transcript replay
|
||||
- [ ] a real turn driven through the window (needs a provider key)
|
||||
- [ ] session list / switch, attachments, settings page
|
||||
- [ ] attachments (drag in, paste), settings page (provider, model, key)
|
||||
- [ ] Kirigami (Breeze look), tray, global shortcuts
|
||||
- [ ] Android APK (engine in-process, JNI platform tools)
|
||||
|
||||
@@ -12,6 +12,7 @@ Item {
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
objectName: "transcript"
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.gapL
|
||||
anchors.rightMargin: Theme.gapL
|
||||
|
||||
@@ -22,6 +22,7 @@ ApplicationWindow {
|
||||
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
|
||||
|
||||
property string statusText: ""
|
||||
property var sessionsView: [] // the store, for the sidebar
|
||||
property bool navOpen: false // the sliding sidebar, compact only
|
||||
property int sidebarWidth: 0 // 0 = follow the window width
|
||||
readonly property bool compact: width < Theme.compactBreakpoint
|
||||
@@ -41,7 +42,12 @@ ApplicationWindow {
|
||||
running: agent.running
|
||||
status: win.statusText
|
||||
todo: agent.todo
|
||||
sessions: win.sessionsView
|
||||
sessionId: agent.sessionId
|
||||
onModeRequested: function (m) { agent.setMode(m) }
|
||||
onSessionRequested: function (id) { win.switchSession(id) }
|
||||
onSessionNewRequested: win.newSession()
|
||||
onSessionDeleteRequested: function (id) { win.deleteSession(id) }
|
||||
onApprovalRequested: function (a) { agent.setApproval(a) }
|
||||
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
|
||||
}
|
||||
@@ -98,6 +104,38 @@ ApplicationWindow {
|
||||
agent.send(text)
|
||||
}
|
||||
|
||||
// ---- sessions --------------------------------------------------------
|
||||
function reloadSessions() { win.sessionsView = agent.sessions() }
|
||||
|
||||
// A stored session comes back as rows: the engine holds the transcript
|
||||
// (AgentCore::history()), these rows only mirror it.
|
||||
function replay() {
|
||||
transcript.clear()
|
||||
const rows = agent.history()
|
||||
for (let i = 0; i < rows.length; i++)
|
||||
transcript.append(rows[i])
|
||||
chat.stickBottom = true
|
||||
}
|
||||
|
||||
function switchSession(id) {
|
||||
if (agent.switchSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
if (agent.newSession()) {
|
||||
transcript.clear()
|
||||
win.reloadSessions()
|
||||
}
|
||||
}
|
||||
|
||||
function deleteSession(id) {
|
||||
if (agent.deleteSession(id))
|
||||
win.reloadSessions()
|
||||
}
|
||||
|
||||
Component.onCompleted: win.reloadSessions()
|
||||
|
||||
// ---- the shell -------------------------------------------------------
|
||||
header: ToolBar {
|
||||
id: appBar
|
||||
@@ -335,6 +373,9 @@ ApplicationWindow {
|
||||
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) }
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One stored session in the sidebar: its name (a rename, else its first
|
||||
// prompt), how much is in it and when it was last written, and a way to drop
|
||||
// it. The open one is marked. A file of its own because the sidebar lists them
|
||||
// through a Repeater -- an inline component would not see modelData there.
|
||||
Rectangle {
|
||||
id: srow
|
||||
property var entry: null
|
||||
readonly property bool open: entry !== null && entry.current === true
|
||||
|
||||
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
|
||||
implicitHeight: rowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: srow.open ? Theme.accentDim
|
||||
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
|
||||
border.width: 1
|
||||
border.color: srow.open ? Theme.accent : "transparent"
|
||||
|
||||
signal activated(string id)
|
||||
signal removeRequested(string id)
|
||||
|
||||
// "3h ago"; the entries carry ms since the epoch.
|
||||
function ago(ms) {
|
||||
if (!ms)
|
||||
return "never"
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60)
|
||||
return "just now"
|
||||
if (s < 3600)
|
||||
return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400)
|
||||
return Math.floor(s / 3600) + "h ago"
|
||||
return Math.floor(s / 86400) + "d ago"
|
||||
}
|
||||
|
||||
HoverHandler { id: rowHover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: srow.activated(srow.entry.id)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
ColumnLayout {
|
||||
id: rowCol
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
|
||||
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
|
||||
: "(no prompt yet)"
|
||||
color: srow.open ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: {
|
||||
if (srow.entry === null)
|
||||
return ""
|
||||
const n = srow.entry.messages
|
||||
const when = srow.ago(srow.entry.mtime)
|
||||
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
|
||||
: when
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Delete this session and its transcript"
|
||||
contentItem: Text {
|
||||
text: "×"
|
||||
color: parent.hovered ? Theme.err : Theme.textFaint
|
||||
font.pixelSize: 15
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: srow.removeRequested(srow.entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,15 @@ Rectangle {
|
||||
property bool running: false
|
||||
property string status: ""
|
||||
property string todo: ""
|
||||
property var sessions: [] // what the store holds (AgentCore::sessions)
|
||||
property string sessionId: ""
|
||||
|
||||
signal modeRequested(string mode)
|
||||
signal approvalRequested(int approval)
|
||||
signal planModeRequested(bool on)
|
||||
signal sessionRequested(string id)
|
||||
signal sessionNewRequested()
|
||||
signal sessionDeleteRequested(string id)
|
||||
|
||||
readonly property alias contentColumn: col
|
||||
|
||||
@@ -108,6 +113,14 @@ Rectangle {
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessionId.length > 0
|
||||
text: "session " + side.sessionId
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.status.length > 0
|
||||
@@ -118,6 +131,69 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// --- which conversation ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: "Sessions"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
// Starting a session while a turn runs would move the
|
||||
// engine out from under it: the whole block is off then.
|
||||
ToolButton {
|
||||
objectName: "newSessionButton"
|
||||
implicitWidth: 62
|
||||
implicitHeight: Theme.segmentHeight
|
||||
enabled: !side.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a new session"
|
||||
contentItem: Text {
|
||||
text: "+ new"
|
||||
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: side.sessionNewRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: side.sessions
|
||||
delegate: SessionRow {
|
||||
Layout.fillWidth: true
|
||||
entry: modelData
|
||||
onActivated: function (id) { side.sessionRequested(id) }
|
||||
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessions.length === 0
|
||||
text: "nothing stored here yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- how it frames itself ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
@@ -31,6 +31,12 @@ Rectangle {
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
/* A handler, not a MouseArea: a layout decides the geometry of its
|
||||
* children, and anchoring one of them is undefined behavior. */
|
||||
TapHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onTapped: block.expanded = !block.expanded
|
||||
}
|
||||
Text {
|
||||
text: block.expanded ? "▾" : "▸"
|
||||
color: Theme.textDim
|
||||
@@ -50,11 +56,6 @@ Rectangle {
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: block.expanded = !block.expanded
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
|
||||
@@ -8,6 +8,7 @@ Rectangle {
|
||||
id: card
|
||||
property string name: ""
|
||||
property string state: "pending" // pending | ready | running | done | denied
|
||||
// | unfinished (replayed, no result)
|
||||
property string detail: ""
|
||||
property string output: ""
|
||||
property int exitCode: 0
|
||||
@@ -16,6 +17,7 @@ Rectangle {
|
||||
|
||||
readonly property bool running: state === "pending" || state === "ready" || state === "running"
|
||||
readonly property color stateColor: state === "denied" ? Theme.err
|
||||
: state === "unfinished" ? Theme.textFaint
|
||||
: running ? Theme.warn
|
||||
: hasResult && exitCode !== 0 ? Theme.err
|
||||
: Theme.ok
|
||||
@@ -23,6 +25,7 @@ Rectangle {
|
||||
: state === "ready" ? "queued"
|
||||
: state === "running" ? "running"
|
||||
: state === "denied" ? "denied"
|
||||
: state === "unfinished" ? "no result"
|
||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||
|
||||
function lines() {
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QStringList>
|
||||
#include <QThread>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <cstdlib>
|
||||
|
||||
@@ -71,6 +76,14 @@ bool AgentCore::open(const QString &workspace, const QString &provider,
|
||||
if (!apiKey.isEmpty())
|
||||
snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s",
|
||||
apiKey.toUtf8().constData());
|
||||
/* Sessions live in the workspace's store -- the same files the CLI and the
|
||||
* WebUI use. Without this the engine would open session.jsonl in the
|
||||
* process's working directory, next to a session list that stays empty. */
|
||||
char sid[128] = "";
|
||||
if (cst_agent_store_resolve_default(m_cfg.workspace_root, m_cfg.session_path,
|
||||
sizeof(m_cfg.session_path), sid,
|
||||
sizeof(sid)) != 0)
|
||||
m_cfg.session_path[0] = 0; /* fall back to the engine's default */
|
||||
/* The GUI answers itself: never let the engine decide behind its back. */
|
||||
m_cfg.auto_approve = 0;
|
||||
m_cfg.headless = 0;
|
||||
@@ -243,6 +256,231 @@ void AgentCore::setPlanMode(bool on) {
|
||||
emit planModeChanged();
|
||||
}
|
||||
|
||||
/* --- sessions ------------------------------------------------------------ */
|
||||
|
||||
namespace {
|
||||
|
||||
QString utf8(const char *s) { return QString::fromUtf8(s ? s : ""); }
|
||||
|
||||
/* A transcript row in the shape Main.qml appends live (see addRow()). */
|
||||
QVariantMap sessionRow(const QString &kind, const QString &role) {
|
||||
QVariantMap r;
|
||||
r["kind"] = kind;
|
||||
r["role"] = role;
|
||||
r["text"] = QString();
|
||||
r["complete"] = false;
|
||||
r["name"] = QString();
|
||||
r["state"] = QString();
|
||||
r["detail"] = QString();
|
||||
r["output"] = QString();
|
||||
r["exitCode"] = 0;
|
||||
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) {
|
||||
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;
|
||||
if (r.value(QStringLiteral("name")).toString() == name)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QString AgentCore::sessionId() const {
|
||||
if (!m_agent)
|
||||
return QString();
|
||||
char id[128] = "";
|
||||
cst_agent_session_id(m_agent, id, sizeof(id));
|
||||
return utf8(id);
|
||||
}
|
||||
|
||||
QVariantList AgentCore::sessions() const {
|
||||
QVariantList out;
|
||||
if (m_workspace.isEmpty())
|
||||
return out;
|
||||
const QByteArray ws = m_workspace.toUtf8();
|
||||
const int kMax = 200;
|
||||
char(*ids)[128] = new char[kMax][128];
|
||||
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
||||
const QString current = sessionId();
|
||||
for (int i = 0; i < n; i++) {
|
||||
cst_session_info_t in;
|
||||
memset(&in, 0, sizeof(in));
|
||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||
continue;
|
||||
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
||||
QVariantMap m;
|
||||
m["id"] = id;
|
||||
m["title"] = utf8(in.title);
|
||||
m["firstUser"] = utf8(in.first_user);
|
||||
m["messages"] = in.messages;
|
||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||
m["current"] = (id == current);
|
||||
out.append(m);
|
||||
}
|
||||
delete[] ids;
|
||||
return out;
|
||||
}
|
||||
|
||||
QVariantList AgentCore::history() const {
|
||||
QVariantList rows;
|
||||
cst_session_t *s = m_agent ? cst_agent_session(m_agent) : nullptr;
|
||||
if (!s)
|
||||
return rows;
|
||||
cst_session_lock(s);
|
||||
const int n = cst_session_count(s);
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cst_message_t *m = cst_session_get(s, i);
|
||||
if (!m || m->role == CST_ROLE_SYSTEM)
|
||||
continue; /* the prompt is the engine's, not the conversation */
|
||||
if (m->role == CST_ROLE_TOOL || cst_message_is_tool_result(m)) {
|
||||
/* 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);
|
||||
if (at < 0) {
|
||||
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
||||
r["name"] = name;
|
||||
r["output"] = utf8(m->content);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
} else {
|
||||
QVariantMap r = rows.at(at).toMap();
|
||||
r["output"] = utf8(m->content);
|
||||
r["state"] = "done";
|
||||
r["complete"] = true;
|
||||
rows[at] = r;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cst_message_is_note(m)) {
|
||||
QVariantMap r = sessionRow(QStringLiteral("note"), QStringLiteral("engine"));
|
||||
r["text"] = utf8(m->content);
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
continue;
|
||||
}
|
||||
if (m->role == CST_ROLE_USER) {
|
||||
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("user"));
|
||||
QString text = utf8(m->content);
|
||||
if (m->nattach > 0) {
|
||||
QStringList names;
|
||||
for (int a = 0; a < m->nattach; a++)
|
||||
names << utf8(m->attach[a].name);
|
||||
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
|
||||
}
|
||||
r["text"] = text;
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
continue;
|
||||
}
|
||||
if (m->role != CST_ROLE_ASSISTANT)
|
||||
continue;
|
||||
if (m->reasoning && m->reasoning[0]) {
|
||||
QVariantMap r = sessionRow(QStringLiteral("think"), QStringLiteral("agent"));
|
||||
r["text"] = utf8(m->reasoning);
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
}
|
||||
if (m->content && m->content[0]) {
|
||||
QVariantMap r = sessionRow(QStringLiteral("message"), QStringLiteral("agent"));
|
||||
r["text"] = utf8(m->content);
|
||||
r["complete"] = true;
|
||||
rows.append(r);
|
||||
}
|
||||
if (m->tool_calls_json) {
|
||||
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();
|
||||
QVariantMap r = sessionRow(QStringLiteral("tool"), QStringLiteral("tool"));
|
||||
r["name"] = fn.value(QStringLiteral("name")).toString();
|
||||
r["detail"] = fn.value(QStringLiteral("arguments")).toString();
|
||||
r["state"] = "unfinished"; /* until its result shows up below */
|
||||
rows.append(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
cst_session_unlock(s);
|
||||
return rows;
|
||||
}
|
||||
|
||||
void AgentCore::afterSessionChange() {
|
||||
const QString todo = utf8(cst_agent_todo(m_agent));
|
||||
emit sessionChanged();
|
||||
emit sessionListChanged();
|
||||
if (todo != m_todo) {
|
||||
m_todo = todo;
|
||||
emit todoChanged(todo);
|
||||
}
|
||||
}
|
||||
|
||||
bool AgentCore::newSession() {
|
||||
if (!m_agent || m_running)
|
||||
return false;
|
||||
char id[128] = "";
|
||||
if (cst_agent_new_session(m_agent, id, sizeof(id)) != 0)
|
||||
return false;
|
||||
afterSessionChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AgentCore::switchSession(const QString &id) {
|
||||
if (!m_agent || m_running || id.isEmpty())
|
||||
return false;
|
||||
if (id == sessionId())
|
||||
return true;
|
||||
if (cst_agent_switch_session(m_agent, id.toUtf8().constData()) != 0)
|
||||
return false;
|
||||
afterSessionChange();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AgentCore::deleteSession(const QString &id) {
|
||||
if (!m_agent || m_running || id.isEmpty() || m_workspace.isEmpty())
|
||||
return false;
|
||||
const bool wasOpen = (id == sessionId());
|
||||
if (wasOpen) {
|
||||
/* The engine must not sit on a transcript that is about to vanish: it
|
||||
* moves to a fresh session first, exactly like the WebUI does. */
|
||||
char fresh[128] = "";
|
||||
if (cst_agent_new_session(m_agent, fresh, sizeof(fresh)) != 0)
|
||||
return false;
|
||||
}
|
||||
const QByteArray ws = m_workspace.toUtf8();
|
||||
const QByteArray sid = id.toUtf8();
|
||||
if (cst_agent_store_delete(ws.constData(), sid.constData()) != 0)
|
||||
return false;
|
||||
if (wasOpen)
|
||||
afterSessionChange();
|
||||
else
|
||||
emit sessionListChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
||||
if (id.isEmpty() || m_workspace.isEmpty())
|
||||
return false;
|
||||
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
||||
t = title.toUtf8();
|
||||
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
||||
0)
|
||||
return false;
|
||||
emit sessionListChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* --- tools the front end provides ---------------------------------------- */
|
||||
|
||||
bool AgentCore::registerTool(const cst_tool_def_t *def) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <QMutex>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QWaitCondition>
|
||||
|
||||
#include "cst_agent.h"
|
||||
@@ -29,6 +30,7 @@ class AgentCore : public QObject {
|
||||
Q_PROPERTY(int approval READ approval WRITE setApproval NOTIFY approvalChanged)
|
||||
Q_PROPERTY(bool planMode READ planMode WRITE setPlanMode NOTIFY planModeChanged)
|
||||
Q_PROPERTY(QString todo READ todo NOTIFY todoChanged)
|
||||
Q_PROPERTY(QString sessionId READ sessionId NOTIFY sessionChanged)
|
||||
|
||||
public:
|
||||
explicit AgentCore(QObject *parent = nullptr);
|
||||
@@ -48,11 +50,27 @@ class AgentCore : public QObject {
|
||||
int approval() const { return m_cfg.approval; }
|
||||
bool planMode() const { return m_cfg.plan_mode != 0; }
|
||||
QString todo() const { return m_todo; }
|
||||
QString sessionId() const;
|
||||
|
||||
/* Markdown -> HTML with the engine's own renderer (cst_web_md_html), so a
|
||||
* message looks the same here as it does in the WebUI. */
|
||||
Q_INVOKABLE QString renderMarkdown(const QString &text) const;
|
||||
|
||||
/* --- sessions (the workspace's .agent/sessions store) ------------------- */
|
||||
|
||||
/* Stored sessions, newest first; one map each: id, title (a rename, else
|
||||
* empty), firstUser, messages, mtime (ms since the epoch), current. */
|
||||
Q_INVOKABLE QVariantList sessions() const;
|
||||
/* The open session as transcript rows, in exactly the shape the live
|
||||
* signals build (kind, role, text, name, state, detail, output, exitCode,
|
||||
* complete), so a reopened session renders like the turn that wrote it. */
|
||||
Q_INVOKABLE QVariantList history() const;
|
||||
/* None of these run during a turn; all return false when one does. */
|
||||
Q_INVOKABLE bool newSession();
|
||||
Q_INVOKABLE bool switchSession(const QString &id);
|
||||
Q_INVOKABLE bool deleteSession(const QString &id);
|
||||
Q_INVOKABLE bool renameSession(const QString &id, const QString &title);
|
||||
|
||||
/* The engine, for the parts that need it (tool registration). */
|
||||
cst_agent_t *agent() const { return m_agent; }
|
||||
const cst_agent_config_t *config() const { return &m_cfg; }
|
||||
@@ -85,6 +103,8 @@ class AgentCore : public QObject {
|
||||
void configChanged();
|
||||
void approvalChanged();
|
||||
void planModeChanged();
|
||||
void sessionChanged(); /* another session is open now */
|
||||
void sessionListChanged(); /* the store gained, lost or renamed one */
|
||||
void statusChanged(const QString &text);
|
||||
void assistantDelta(const QString &text);
|
||||
void thinkDelta(const QString &text);
|
||||
@@ -104,6 +124,8 @@ class AgentCore : public QObject {
|
||||
friend class Turn;
|
||||
void finishTurn(int rc, const QString &err);
|
||||
void setRunning(bool on);
|
||||
/* Re-read what a session switch changed (id, todo) and say so. */
|
||||
void afterSessionChange();
|
||||
|
||||
/* Hook trampolines (C, run on the worker thread). */
|
||||
static void hookStatus(const char *msg, void *ud);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#include "core/AgentCore.h"
|
||||
#include "core/ToolProvider.h"
|
||||
@@ -143,6 +144,16 @@ int main(int argc, char **argv) {
|
||||
ws = QDir::currentPath();
|
||||
ws = QFileInfo(ws).absoluteFilePath();
|
||||
|
||||
/* --uitest drives a scratch workspace when none was given: the session
|
||||
* checks create and delete sessions, which is not something to do in
|
||||
* whatever directory the app happened to be started from. */
|
||||
std::unique_ptr<QTemporaryDir> scratch;
|
||||
if (parser.isSet(uitestOpt) && parser.value(wsOpt).isEmpty()) {
|
||||
scratch.reset(new QTemporaryDir);
|
||||
if (scratch->isValid())
|
||||
ws = scratch->path();
|
||||
}
|
||||
|
||||
AgentCore core;
|
||||
QString err;
|
||||
if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt),
|
||||
|
||||
+148
-1
@@ -15,7 +15,12 @@
|
||||
#include <QQuickWindow>
|
||||
#include <QTest>
|
||||
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
namespace app {
|
||||
namespace {
|
||||
@@ -29,8 +34,35 @@ void check(bool ok, const QString &what) {
|
||||
g_fail++;
|
||||
}
|
||||
|
||||
QQuickItem *byName(QQuickItem *root, const QString &name) {
|
||||
if (!root)
|
||||
return nullptr;
|
||||
if (root->objectName() == name)
|
||||
return root;
|
||||
const QList<QQuickItem *> kids = root->childItems();
|
||||
for (QQuickItem *c : kids)
|
||||
if (QQuickItem *hit = byName(c, name))
|
||||
return hit;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
int countNamed(QQuickItem *root, const QString &prefix) {
|
||||
if (!root)
|
||||
return 0;
|
||||
int n = root->objectName().startsWith(prefix) ? 1 : 0;
|
||||
const QList<QQuickItem *> kids = root->childItems();
|
||||
for (QQuickItem *c : kids)
|
||||
n += countNamed(c, prefix);
|
||||
return n;
|
||||
}
|
||||
|
||||
/* An item by objectName. The QObject tree is tried first (it holds the named
|
||||
* controls); a view's delegates can be owned by the model instead, so the
|
||||
* visual tree -- what a finger can actually reach -- is walked as well. */
|
||||
QQuickItem *item(QQuickWindow *win, const QString &name) {
|
||||
return win->findChild<QQuickItem *>(name);
|
||||
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
|
||||
return it;
|
||||
return byName(win->contentItem(), name);
|
||||
}
|
||||
|
||||
/* The centre of an item, in window coordinates. */
|
||||
@@ -64,6 +96,45 @@ void resizeTo(QQuickWindow *win, int w, int h) {
|
||||
QTest::qWait(250); /* let the layouts settle */
|
||||
}
|
||||
|
||||
/* A stored session with a tool call in it, written through the store's own
|
||||
* API, so the file is exactly what the engine reads back. */
|
||||
QString writeFixtureSession(const QString &ws, const QString &id) {
|
||||
const QByteArray w = ws.toUtf8();
|
||||
char path[4096] = "";
|
||||
if (cst_agent_store_path_for_id(w.constData(), id.toUtf8().constData(), path,
|
||||
sizeof(path)) != 0)
|
||||
return QString();
|
||||
cst_session_t *s = cst_session_open(path);
|
||||
if (!s)
|
||||
return QString();
|
||||
cst_message_t m;
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_USER;
|
||||
m.content = strdup("what is here?");
|
||||
cst_session_append(s, &m);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.content = strdup("let me look. ");
|
||||
m.tool_calls_json = strdup(
|
||||
"[{\"id\":\"call_1\",\"type\":\"function\",\"function\":"
|
||||
"{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":"
|
||||
"\\\"README.md\\\"}\"}}]");
|
||||
cst_session_append(s, &m);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_TOOL;
|
||||
m.content = strdup("hello\n");
|
||||
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_1");
|
||||
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
|
||||
cst_session_append(s, &m);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.reasoning = strdup("checked the file");
|
||||
m.content = strdup("a repo.");
|
||||
cst_session_append(s, &m);
|
||||
cst_session_close(s);
|
||||
return id;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int runUiTest(AgentCore &core, const QString &size) {
|
||||
@@ -141,6 +212,72 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(false, QStringLiteral("composer and send button are reachable"));
|
||||
}
|
||||
|
||||
/* --- sessions: the sidebar is the store's face ------------------------ */
|
||||
QQuickItem *list = item(win, QStringLiteral("transcript"));
|
||||
const QString fixture =
|
||||
writeFixtureSession(core.workspace(), QStringLiteral("20260101-120000-fix"));
|
||||
check(!fixture.isEmpty() && list != nullptr,
|
||||
QStringLiteral("a session fixture with a tool call is in the store"));
|
||||
QMetaObject::invokeMethod(win, "reloadSessions");
|
||||
QTest::qWait(150);
|
||||
int rowsSeen = countNamed(win->contentItem(), QStringLiteral("sessionRow:"));
|
||||
if (qEnvironmentVariableIsSet("CST_APP_DUMP"))
|
||||
std::printf("note: %d session rows in the visual tree\n", rowsSeen);
|
||||
const int listed = win->property("sessionsView").toList().size();
|
||||
check(item(win, QStringLiteral("sessionRow:") + fixture) != nullptr,
|
||||
QStringLiteral("the stored session shows up in the sidebar (%1 rows, %2 listed)")
|
||||
.arg(rowsSeen)
|
||||
.arg(listed));
|
||||
check(core.sessions().size() >= 2,
|
||||
QStringLiteral("the store lists it next to the open one"));
|
||||
|
||||
/* the fixture is: user, agent + call, result, thought, agent */
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture,
|
||||
QStringLiteral("a click on a row switches the engine"));
|
||||
check(list && list->property("count").toInt() == 5,
|
||||
QStringLiteral("the reopened transcript is replayed into the view"));
|
||||
{
|
||||
bool toolOk = false;
|
||||
const QVariantList rows = core.history();
|
||||
for (const QVariant &v : rows) {
|
||||
const QVariantMap r = v.toMap();
|
||||
if (r.value(QStringLiteral("kind")).toString() == QLatin1String("tool") &&
|
||||
r.value(QStringLiteral("name")).toString() == QLatin1String("read_file"))
|
||||
toolOk = r.value(QStringLiteral("complete")).toBool() &&
|
||||
r.value(QStringLiteral("output")).toString() ==
|
||||
QLatin1String("hello\n");
|
||||
}
|
||||
check(toolOk, QStringLiteral("a tool result is paired with its call"));
|
||||
}
|
||||
|
||||
/* new sessions: the engine moves, the view empties, the store grows */
|
||||
const int before = core.sessions().size();
|
||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||
const QString first = core.sessionId();
|
||||
check(first != fixture, QStringLiteral("New starts another session"));
|
||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||
const QString second = core.sessionId();
|
||||
check(second != first, QStringLiteral("New starts another one each time"));
|
||||
check(core.sessions().size() == before + 2,
|
||||
QStringLiteral("both new sessions are in the store"));
|
||||
check(list && list->property("count").toInt() == 0,
|
||||
QStringLiteral("a fresh session starts with an empty view"));
|
||||
|
||||
/* the x drops a stored session and leaves the open one alone */
|
||||
QTest::qWait(150);
|
||||
clickMouse(win, QStringLiteral("sessionDelete:") + first);
|
||||
check(core.sessions().size() == before + 1,
|
||||
QStringLiteral("the x drops a stored session"));
|
||||
check(core.sessionId() == second,
|
||||
QStringLiteral("deleting another session leaves the open one alone"));
|
||||
|
||||
/* and back to the fixture, which still has its five rows */
|
||||
QTest::qWait(150);
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
|
||||
QStringLiteral("switching back replays it again"));
|
||||
|
||||
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||
resizeTo(win, 460, 900);
|
||||
check(docked && !docked->isVisible(),
|
||||
@@ -158,6 +295,16 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(docked && docked->isVisible(),
|
||||
QStringLiteral("widening again docks the sidebar back"));
|
||||
|
||||
/* A picture of the last state on request: the only way to look at the
|
||||
* layout on a machine without a screen
|
||||
* (CST_APP_SHOT=shot.png cst-agent-app --uitest). */
|
||||
if (qEnvironmentVariableIsSet("CST_APP_SHOT")) {
|
||||
const QString shot = qEnvironmentVariable("CST_APP_SHOT");
|
||||
std::printf("%s %s\n",
|
||||
win->grabWindow().save(shot) ? "note wrote" : "note cannot write",
|
||||
qPrintable(shot));
|
||||
}
|
||||
|
||||
std::printf("--- %s\n", g_fail ? "FAILED" : "all UI checks passed");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user