Files
CST-agent/tests/test_artifacts.c
T
WHC 60cbce3c5c 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.
2026-10-11 16:34:47 +08:00

415 lines
15 KiB
C

/* deliver_file: a finished file handed to the person, not to the model.
*
* The three things that must hold:
* - the stored copy is the file itself (any bytes, its own extension and a
* mime taken from the name), not the text reduction an attachment gets;
* - it carries `for_user`, so no request ever contains it -- and a document
* attachment on the same session still travels;
* - the tool is in the engine's registry, takes a workspace path and refuses
* a directory or a path that leaves the workspace.
*/
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_test_util.h"
#include "cst_tools.h"
#include "os_compat.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
static char g_ws[1024];
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static void wfile(const char *rel, const void *data, size_t n) {
char p[1200];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *f = fopen(p, "wb");
if (!f) {
fprintf(stderr, "cannot write %s\n", p);
exit(1);
}
if (n && fwrite(data, 1, n, f) != n) {
fprintf(stderr, "short write %s\n", p);
exit(1);
}
fclose(f);
}
static void full_path(char *out, size_t n, const char *rel) {
snprintf(out, n, "%s/%s", g_ws, rel);
}
/* name, then key/value pairs, NULL-terminated (the ctl_tool_call_t a turn
* builds from a model's call). */
static ctl_tool_call_t mk(const char *name, ...) {
ctl_tool_call_t c;
memset(&c, 0, sizeof(c));
snprintf(c.id, sizeof(c.id), "t");
snprintf(c.name, sizeof(c.name), "%s", name);
va_list ap;
va_start(ap, name);
const char *k;
while ((k = va_arg(ap, const char *)) != NULL) {
const char *v = va_arg(ap, const char *);
ctl_arg_t *a = &c.args[c.nargs++];
snprintf(a->key, sizeof(a->key), "%s", k);
a->value = (char *)v;
a->value_len = strlen(v);
}
va_end(ap);
return c;
}
static void test_registry(void) {
const cst_tool_def_t *t = cst_tool_find("deliver_file");
EXPECT(t != NULL, "deliver_file is in the registry");
if (!t)
return;
char why[160] = "";
EXPECT(cst_tool_available(t, why, sizeof(why)) == 0,
"deliver_file is available here");
/* It copies out of the workspace and never writes it: a read. */
EXPECT(strcmp(cst_tool_perm_name(t), "read") == 0,
"deliver_file is a read-policy tool");
EXPECT(t->owner == NULL, "deliver_file is an engine tool");
}
static void test_import(const char *session) {
const char *md = "# Report\n\n- one\n- two\n";
wfile("out/report.md", md, strlen(md));
char src[1200];
full_path(src, sizeof(src), "out/report.md");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "a markdown file is delivered");
if (!a) {
fprintf(stderr, " (%s)\n", err);
return;
}
EXPECT(strcmp(a->mime, "text/markdown") == 0, "mime comes from the name");
EXPECT(strcmp(a->name, "report.md") == 0, "display name is the file name");
EXPECT(a->for_user == 1, "marked for the user");
EXPECT(!cst_attach_is_text(a), "a delivered file is not inlined as text");
EXPECT(!cst_attach_is_image(a), "and it is not an image part either");
EXPECT(strncmp(a->rel, "art-", 4) == 0, "stored under an artifact id");
size_t rl = strlen(a->rel);
EXPECT(rl > 4 && strcmp(a->rel + rl - 3, ".md") == 0,
"the stored copy keeps the extension");
EXPECT(a->bytes == (long long)strlen(md), "size is the file's size");
size_t n = 0;
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
EXPECT(back && n == strlen(md) && memcmp(back, md, n) == 0,
"the payload is byte for byte");
free(back);
/* The copy lives beside the session, not in the workspace. */
char stored[1200];
EXPECT(cst_attach_full_path(session, a, stored, sizeof(stored)) == 0,
"stored path resolves");
os_stat_t st;
EXPECT(os_lstat(stored, &st) == 0 && st.size == (long long)strlen(md),
"the stored file is on disk");
/* A title wins over the file name, and its extension decides the type. */
cst_attachment_t *b = cst_attach_import_artifact(session, src, "summary.html",
err, sizeof(err));
EXPECT(b && strcmp(b->name, "summary.html") == 0, "title is the display name");
EXPECT(b && strcmp(b->mime, "text/html") == 0, "the title's extension decides");
cst_attach_free(b);
cst_attach_free(a);
}
static void test_any_bytes(const char *session) {
/* A zip is bytes, not text: an attachment would refuse it (the content
* decides), a delivered file is copied as it is. */
char zip[512];
for (size_t i = 0; i < sizeof(zip); i++)
zip[i] = (char)(i % 251);
memcpy(zip, "PK\003\004", 4);
wfile("out/build.zip", zip, sizeof(zip));
char src[1200];
full_path(src, sizeof(src), "out/build.zip");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "a binary file is delivered");
EXPECT(a && strcmp(a->mime, "application/zip") == 0, "zip mime");
if (a) {
size_t n = 0;
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
EXPECT(back && n == sizeof(zip) && memcmp(back, zip, n) == 0,
"binary payload survives");
free(back);
cst_attach_free(a);
}
/* The same file through the attachment path: refused, as before. */
cst_attachment_t *as_attach =
cst_attach_import_file(session, src, err, sizeof(err));
EXPECT(as_attach == NULL, "an attachment still refuses binary garbage");
cst_attach_free(as_attach);
/* Bigger than an attachment may be (16 MB) but inside the artifact cap: a
* report is allowed to be large. */
char big[1200];
full_path(big, sizeof(big), "out/big.log");
FILE *f = fopen(big, "wb");
if (f) {
char line[64];
memset(line, 'x', sizeof(line) - 1);
line[sizeof(line) - 1] = '\n';
/* 17 MB: past the 16 MB attachment cap, well inside the artifact one. */
for (int i = 0; i < 17 * 1024 * 1024 / (int)sizeof(line); i++)
fwrite(line, 1, sizeof(line), f);
fclose(f);
}
os_stat_t st;
EXPECT(os_lstat(big, &st) == 0 && st.size > CST_ATTACH_MAX_BYTES,
"the scratch file is larger than the attachment cap");
cst_attachment_t *large =
cst_attach_import_artifact(session, big, NULL, err, sizeof(err));
EXPECT(large != NULL, "a file past the attachment cap is delivered");
EXPECT(large && large->bytes == (long long)st.size, "all of it is stored");
EXPECT(large && strcmp(large->mime, "text/plain") == 0, "log mime");
cst_attach_free(large);
cst_attachment_t *small =
cst_attach_import_file(session, big, err, sizeof(err));
EXPECT(small == NULL, "the attachment path still caps at 16 MB");
cst_attach_free(small);
}
static void test_json(const char *session) {
char src[1200];
full_path(src, sizeof(src), "out/report.md");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "artifact for the json round trip");
if (!a)
return;
char *json = cst_attachments_to_json(a, 1);
EXPECT(json && strstr(json, "\"for_user\":1") != NULL,
"the JSONL says it is the user's");
cJSON *parsed = json ? cJSON_Parse(json) : NULL;
int n = 0;
cst_attachment_t *back = parsed ? cst_attachments_from_json(parsed, &n) : NULL;
EXPECT(back && n == 1, "one entry parses back");
if (back) {
EXPECT(back[0].for_user == 1, "for_user survives the JSONL");
EXPECT(strcmp(back[0].mime, "text/markdown") == 0, "mime survives");
EXPECT(strcmp(back[0].rel, a->rel) == 0, "stored name survives");
cst_attachments_free(back, n);
}
cJSON_Delete(parsed);
free(json);
cst_attach_free(a);
}
/* The session file is what a replayed transcript is read from: a delivered
* file that loses `for_user` on the way through it would be handed back to the
* model (and never shown as a card) on the next start. */
static void test_session_file(void) {
char src[1200], path[1200];
full_path(src, sizeof(src), "out/report.md");
snprintf(path, sizeof(path), "%s/delivered.jsonl", g_ws);
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(path, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "the file is stored for a session message");
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "the session opens");
if (!s) {
cst_attach_free(a);
return;
}
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = (char *)"delivered report.md";
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "c1");
snprintf(m.tool_name, sizeof(m.tool_name), "deliver_file");
m.attach = a;
m.nattach = a ? 1 : 0;
EXPECT(cst_session_append(s, &m) == 0, "the tool result is appended");
cst_session_close(s);
cst_attach_free(a);
/* Read it back the way cst_agent_open() does. */
s = cst_session_open(path);
EXPECT(s && cst_session_count(s) == 1, "the message is in the file");
const cst_message_t *back = s ? cst_session_get(s, 0) : NULL;
EXPECT(back && back->nattach == 1, "its attachment comes back");
EXPECT(back && back->attach && back->attach[0].for_user == 1,
"and is still the user's, not the model's");
EXPECT(back && back->attach && !cst_attach_is_text(&back->attach[0]),
"so it is never inlined");
EXPECT(back && back->attach && !cst_attach_is_image(&back->attach[0]),
"and it is not an image part either");
cst_session_close(s);
}
/* The tool through the registry, the way a turn calls it. */
static void test_tool(const char *session) {
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", g_ws);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s", session);
cst_agent_t *ag = cst_agent_create(&cfg);
EXPECT(ag != NULL, "agent for the tool call");
if (!ag)
return;
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = g_ws;
ctx.agent = ag;
cst_tool_result_t r;
memset(&r, 0, sizeof(r));
ctl_tool_call_t c = mk("deliver_file", "path", "out/report.md", NULL);
cst_tool_run(&ctx, &c, &r); /* by name, through the registry */
EXPECT(r.exit_code == 0, "deliver_file runs");
EXPECT(r.out && strstr(r.out, "delivered report.md") != NULL,
"the result names the file");
EXPECT(r.nattach == 1 && r.attach && r.attach[0].for_user == 1,
"the tool hands the file to the front end");
EXPECT(r.attach && !cst_attach_is_text(&r.attach[0]),
"and never to a request");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", "path", "out", NULL); /* a directory */
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out && strstr(r.out, "is a directory") != NULL,
"a directory is refused");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", "path", "../outside.txt", NULL);
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out &&
strstr(r.out, "outside workspace") != NULL,
"a path outside the workspace is refused");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", NULL);
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out && strstr(r.out, "path") != NULL,
"a missing path is refused");
cst_tool_result_free(&r);
cst_agent_destroy(ag);
}
/* What actually goes out: the delivered file is not in the body, a document
* attachment on the same transcript still is. */
static void test_wire(const char *session) {
char md_src[1200], txt_src[1200];
full_path(md_src, sizeof(md_src), "out/report.md");
const char *note = "the build log lives in out/build.log\n";
wfile("notes.txt", note, strlen(note));
full_path(txt_src, sizeof(txt_src), "notes.txt");
char err[256] = "";
cst_attachment_t *art =
cst_attach_import_artifact(session, md_src, NULL, err, sizeof(err));
cst_attachment_t *doc =
cst_attach_import_file(session, txt_src, err, sizeof(err));
EXPECT(art && doc, "both a delivered file and a document");
cst_message_t msgs[4];
memset(msgs, 0, sizeof(msgs));
msgs[0].role = CST_ROLE_USER;
msgs[0].content = (char *)"what did you build?";
msgs[1].role = CST_ROLE_ASSISTANT;
msgs[1].tool_calls_json = (char *)"[{\"id\":\"c1\",\"type\":\"function\","
"\"function\":{\"name\":\"deliver_file\","
"\"arguments\":\"{}\"}}]";
/* Two tool results of one turn: the first delivered a file for the person,
* the second came back with a document the model is meant to read. The
* delivered file must add nothing (not even a "not sent" note); the
* document must still be inlined. */
msgs[2].role = CST_ROLE_TOOL;
msgs[2].content = (char *)"the finished report is stored for the user";
snprintf(msgs[2].tool_call_id, sizeof(msgs[2].tool_call_id), "c1");
snprintf(msgs[2].tool_name, sizeof(msgs[2].tool_name), "deliver_file");
msgs[2].attach = art;
msgs[2].nattach = art ? 1 : 0;
msgs[3].role = CST_ROLE_TOOL;
msgs[3].content = (char *)"read the build log";
snprintf(msgs[3].tool_call_id, sizeof(msgs[3].tool_call_id), "c2");
snprintf(msgs[3].tool_name, sizeof(msgs[3].tool_name), "read_file");
msgs[3].attach = doc;
msgs[3].nattach = doc ? 1 : 0;
cst_llm_request_t req;
memset(&req, 0, sizeof(req));
req.model = "test-model";
req.msgs = msgs;
req.nmsgs = 4;
req.session_path = session; /* a document is read back from the store */
char *body = cst_llm_build_body(&req);
EXPECT(body != NULL, "body built");
if (body) {
EXPECT(art && strstr(body, art->rel) == NULL,
"the delivered file's stored name is not in the request");
EXPECT(strstr(body, "for_user") == NULL,
"and nothing marks it as an attachment either");
EXPECT(strstr(body, "the build log lives in") != NULL,
"a document attachment still travels as text");
cJSON *j = cJSON_Parse(body);
const cJSON *m = j ? cJSON_GetObjectItemCaseSensitive(j, "messages") : NULL;
EXPECT(m && cJSON_GetArraySize(m) == 5,
"four messages plus the one deferred document message");
cJSON_Delete(j);
free(body);
}
cst_attachments_free(art, art ? 1 : 0);
cst_attachments_free(doc, doc ? 1 : 0);
}
int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-artifacts") != 0) {
perror("tmpdir");
return 1;
}
char mk1[1200];
snprintf(mk1, sizeof(mk1), "%s/out", g_ws);
test_mkdir(mk1, 0755);
char session[1200];
snprintf(session, sizeof(session), "%s/s.jsonl", g_ws);
test_registry();
test_import(session);
test_any_bytes(session);
test_json(session);
test_session_file();
test_tool(session);
test_wire(session);
test_rmrf(g_ws);
if (nfail) {
fprintf(stderr, "%d check(s) failed\n", nfail);
return 1;
}
printf("test_artifacts: OK\n");
return 0;
}