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:
WHC
2026-10-11 12:14:59 +08:00
parent da812cc7e8
commit f5d340bd2a
13 changed files with 691 additions and 13 deletions
+148 -1
View File
@@ -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;
}