v0.9.2: files the agent delivers come back as cards, and the saved key is used
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
This commit is contained in:
+140
-12
@@ -45,16 +45,18 @@ void check(bool ok, const QString &what) {
|
||||
g_fail++;
|
||||
}
|
||||
|
||||
QQuickItem *byName(QQuickItem *root, const QString &name) {
|
||||
/* Every item with this objectName in the visual tree. A view that has just
|
||||
* been replayed can still hold the delegates it is about to drop, so a name
|
||||
* can match more than one item for a moment. */
|
||||
void collectNamed(QQuickItem *root, const QString &name,
|
||||
QList<QQuickItem *> *out) {
|
||||
if (!root)
|
||||
return nullptr;
|
||||
return;
|
||||
if (root->objectName() == name)
|
||||
return root;
|
||||
out->append(root);
|
||||
const QList<QQuickItem *> kids = root->childItems();
|
||||
for (QQuickItem *c : kids)
|
||||
if (QQuickItem *hit = byName(c, name))
|
||||
return hit;
|
||||
return nullptr;
|
||||
collectNamed(c, name, out);
|
||||
}
|
||||
|
||||
int countNamed(QQuickItem *root, const QString &prefix) {
|
||||
@@ -71,9 +73,16 @@ int countNamed(QQuickItem *root, const QString &prefix) {
|
||||
* 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) {
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(win->contentItem(), name, &hits);
|
||||
/* What a finger can reach is the one the test means: a delegate on its way
|
||||
* out is still in the tree but invisible. */
|
||||
for (QQuickItem *it : hits)
|
||||
if (it->isVisible())
|
||||
return it;
|
||||
if (QQuickItem *it = win->findChild<QQuickItem *>(name))
|
||||
return it;
|
||||
return byName(win->contentItem(), name);
|
||||
return hits.isEmpty() ? nullptr : hits.first();
|
||||
}
|
||||
|
||||
/* The centre of an item, in window coordinates. */
|
||||
@@ -156,6 +165,33 @@ QString writeFixtureSession(const QString &ws, const QString &id) {
|
||||
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);
|
||||
/* A delivered file (deliver_file): the copy the agent hands to the person,
|
||||
* marked for_user -- the row a file card is drawn from. */
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.tool_calls_json = strdup(
|
||||
"[{\"id\":\"call_2\",\"type\":\"function\",\"function\":"
|
||||
"{\"name\":\"deliver_file\",\"arguments\":\"{\\\"path\\\":"
|
||||
"\\\"report.md\\\"}\"}}]");
|
||||
cst_session_append(s, &m);
|
||||
memset(&m, 0, sizeof(m));
|
||||
{
|
||||
const QString src = ws + QStringLiteral("/report.md");
|
||||
QFile rf(src);
|
||||
if (rf.open(QIODevice::WriteOnly))
|
||||
rf.write("# report\n");
|
||||
rf.close();
|
||||
char aerr[256] = "";
|
||||
m.attach = cst_attach_import_artifact(path, src.toUtf8().constData(), NULL,
|
||||
aerr, sizeof(aerr));
|
||||
m.nattach = m.attach ? 1 : 0;
|
||||
}
|
||||
m.role = CST_ROLE_TOOL;
|
||||
m.content = strdup("delivered report.md\n");
|
||||
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "%s", "call_2");
|
||||
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "deliver_file");
|
||||
cst_session_append(s, &m);
|
||||
cst_attachments_free(m.attach, m.nattach);
|
||||
memset(&m, 0, sizeof(m));
|
||||
m.role = CST_ROLE_ASSISTANT;
|
||||
m.reasoning = strdup("checked the file");
|
||||
@@ -200,6 +236,9 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
* session of the store (cst_agent_open()), so the window is created with a
|
||||
* transcript already in the engine. Put one there before the QML loads,
|
||||
* which is exactly what a restart looks like. */
|
||||
/* A file card hands its file to the system: in a test run nothing may open
|
||||
* for real, and the platform layer itself honours this (see ToolProvider.h). */
|
||||
qputenv("CST_APP_NO_OPEN", "1");
|
||||
const QString resumed =
|
||||
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
|
||||
check(!resumed.isEmpty() && core.switchSession(resumed),
|
||||
@@ -232,7 +271,7 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
{
|
||||
QQuickItem *tr = item(win, QStringLiteral("transcript"));
|
||||
const int rows = tr ? tr->property("count").toInt() : -1;
|
||||
check(rows == 5,
|
||||
check(rows == 6,
|
||||
QStringLiteral("the open session is on screen at startup (%1 rows)")
|
||||
.arg(rows));
|
||||
}
|
||||
@@ -304,11 +343,12 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(core.sessions().size() >= 2,
|
||||
QStringLiteral("the store lists it next to the open one"));
|
||||
|
||||
/* the fixture is: user, agent + call, result, thought, agent */
|
||||
/* the fixture is: user, agent + call, result, agent + call, delivered
|
||||
* file, 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,
|
||||
check(list && list->property("count").toInt() == 6,
|
||||
QStringLiteral("the reopened transcript is replayed into the view"));
|
||||
{
|
||||
bool toolOk = false;
|
||||
@@ -324,6 +364,58 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(toolOk, QStringLiteral("a tool result is paired with its call"));
|
||||
}
|
||||
|
||||
/* --- a delivered file: a card with open and share --------------------- */
|
||||
{
|
||||
/* The view is re-laid out off the click; give it the frame it needs. */
|
||||
QTest::qWait(150);
|
||||
const int cards =
|
||||
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||
check(cards == 1,
|
||||
QStringLiteral("a delivered file is a card in the tool row (%1)")
|
||||
.arg(cards));
|
||||
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
|
||||
item(win, QStringLiteral("fileShare0")) != nullptr,
|
||||
QStringLiteral("the card carries open and share"));
|
||||
win->setProperty("statusText", QString());
|
||||
clickMouse(win, QStringLiteral("fileOpen0"));
|
||||
const QString opened = win->property("statusText").toString();
|
||||
check(opened.startsWith(QLatin1String("opened ")),
|
||||
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
|
||||
clickMouse(win, QStringLiteral("fileShare0"));
|
||||
const QString shared = win->property("statusText").toString();
|
||||
check(shared.contains(QLatin1String("report.md")),
|
||||
QStringLiteral("Share names the file (%1)").arg(shared));
|
||||
const QString refused =
|
||||
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
|
||||
check(refused.contains(QLatin1String("no such file")),
|
||||
QStringLiteral("a name this session does not own is refused (%1)")
|
||||
.arg(refused));
|
||||
/* And the card says it: a tap that cannot work must not look dead. */
|
||||
QQuickItem *strip = nullptr;
|
||||
{
|
||||
QList<QQuickItem *> hits;
|
||||
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
|
||||
for (QQuickItem *it : hits)
|
||||
if (it->isVisible())
|
||||
strip = it;
|
||||
}
|
||||
bool called = false;
|
||||
if (strip)
|
||||
called = QMetaObject::invokeMethod(
|
||||
strip, "openFile",
|
||||
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
|
||||
const QVariant err = strip ? strip->property("errorText") : QVariant();
|
||||
check(called && err.toString().contains(QLatin1String("no such file")),
|
||||
QStringLiteral("the card shows why it could not open it (%1)")
|
||||
.arg(err.toString()));
|
||||
QTest::qWait(150);
|
||||
QQuickItem *errText = item(win, QStringLiteral("fileError"));
|
||||
check(errText && errText->isVisible() &&
|
||||
errText->property("text").toString().contains(
|
||||
QLatin1String("no such file")),
|
||||
QStringLiteral("and the line is where the card is"));
|
||||
}
|
||||
|
||||
/* new sessions: the engine moves, the view empties, the store grows */
|
||||
const int before = core.sessions().size();
|
||||
clickMouse(win, QStringLiteral("newSessionButton"));
|
||||
@@ -345,10 +437,11 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
check(core.sessionId() == second,
|
||||
QStringLiteral("deleting another session leaves the open one alone"));
|
||||
|
||||
/* and back to the fixture, which still has its five rows */
|
||||
/* and back to the fixture, which still has its six rows */
|
||||
QTest::qWait(150);
|
||||
clickMouse(win, QStringLiteral("sessionRow:") + fixture);
|
||||
check(core.sessionId() == fixture && list && list->property("count").toInt() == 5,
|
||||
check(core.sessionId() == fixture && list &&
|
||||
list->property("count").toInt() == 6,
|
||||
QStringLiteral("switching back replays it again"));
|
||||
{
|
||||
bool promptImage = false;
|
||||
@@ -703,6 +796,41 @@ int runUiTest(AgentCore &core, const QString &size) {
|
||||
maybeShot(win, QStringLiteral("-toolimage"));
|
||||
}
|
||||
|
||||
/* --- the order the engine uses on a live turn: the result first, the
|
||||
* files after it (a card is complete by then, which used to lose them) --- */
|
||||
{
|
||||
const int before = list->property("count").toInt();
|
||||
const int cardsBefore =
|
||||
countNamed(win->contentItem(), QStringLiteral("fileCard"));
|
||||
event4("engineToolEvent", QStringLiteral("open"),
|
||||
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||
QStringLiteral("{\"path\":\"report.md\"}"));
|
||||
event4("engineToolEvent", QStringLiteral("done"),
|
||||
QStringLiteral("call-file"), QStringLiteral("deliver_file"),
|
||||
QStringLiteral("exit=0 bytes=120"));
|
||||
event4("engineToolResult", QStringLiteral("call-file"),
|
||||
QStringLiteral("deliver_file"), QVariant(0),
|
||||
QStringLiteral("delivered report.md (text/markdown, 120 B)"));
|
||||
const QString files =
|
||||
QStringLiteral("file|art-0123456789abcdef.md|report.md|tool|"
|
||||
"text/markdown|120|0x0");
|
||||
check(QMetaObject::invokeMethod(win, "engineToolImages",
|
||||
Q_ARG(QVariant, QStringLiteral("call-file")),
|
||||
Q_ARG(QVariant, files)),
|
||||
QStringLiteral("the files a tool delivered reach the view"));
|
||||
QTest::qWait(150);
|
||||
const QVariantMap card = rowAt(before);
|
||||
check(card.value(QStringLiteral("images"))
|
||||
.toString()
|
||||
.contains(QLatin1String("art-0123456789abcdef.md")),
|
||||
QStringLiteral("a card that is already complete still takes them"));
|
||||
check(countNamed(win->contentItem(), QStringLiteral("fileCard")) >
|
||||
cardsBefore,
|
||||
QStringLiteral("so the delivered file is on the card (%1 -> %2)")
|
||||
.arg(cardsBefore)
|
||||
.arg(countNamed(win->contentItem(), QStringLiteral("fileCard"))));
|
||||
}
|
||||
|
||||
/* --- a narrow window: dock -> drawer, and the button opens it --- */
|
||||
resizeTo(win, 460, 900);
|
||||
check(docked && !docked->isVisible(),
|
||||
|
||||
Reference in New Issue
Block a user