diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fef65b..dfe54ba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -371,6 +371,7 @@ endforeach() # Tests that link the whole core (no TUI). set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode test_apply_patch test_session_roundtrip test_attach + test_artifacts test_shell test_diff test_tools_fs test_registry test_undo test_config test_models test_model_registry test_sandbox test_keymap test_shell_compact test_skills diff --git a/Makefile b/Makefile index 3ad94c6..37c8629 100644 --- a/Makefile +++ b/Makefile @@ -140,6 +140,7 @@ TESTS = \ tests/test_apply_patch \ tests/test_session_roundtrip \ tests/test_attach \ + tests/test_artifacts \ tests/test_pdf_text \ tests/test_shell \ tests/test_diff \ diff --git a/README.md b/README.md index 6b77617..2fd202a 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,7 @@ cst-agent --headless --prompt 'List files' | `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | | `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | +| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 | 插件工具和 MCP 工具(`mcp____`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。 diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index b721666..b172171 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -64,7 +64,14 @@ endif() # --- the GUI ------------------------------------------------------------- if(CST_APP_GUI) - find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET) + # Android's platform file talks to the platform through QtAndroidPrivate + # (the POST_NOTIFICATIONS probe), whose include path comes with CorePrivate. + if(ANDROID) + find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test + CorePrivate QUIET) + else() + find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 Test QUIET) + endif() if(NOT Qt6_FOUND) message(WARNING "app: Qt 6 Quick/Qml not found, skipping cst-agent-app " @@ -102,6 +109,12 @@ if(CST_APP_GUI) endif() target_link_libraries(cst-agent-app PRIVATE Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore) + if(ANDROID) + # app/src/platform/android/AndroidTools.cpp talks to the platform + # through QtAndroidPrivate (permissions) -- a private header, hence the + # private module for its include path. + target_link_libraries(cst-agent-app PRIVATE Qt6::CorePrivate) + endif() if(WIN32) set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) endif() diff --git a/app/README.md b/app/README.md index bcbd694..3b5747d 100644 --- a/app/README.md +++ b/app/README.md @@ -131,7 +131,12 @@ header opens the dialog that changes it. with each entry's base URL and where its key would come from: "key saved" (`.agent`-independent, `/cst-agent/credentials`), "key from OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side — - the dialog only ever describes it. + the dialog only ever describes it. The window starts the engine without a + key (`AgentCore::open()` is passed an empty one), so `open()` resolves it + for the provider it starts on: saved key, then the provider's env variable, + then `CST_API_KEY` — the same order the CLI uses in `main.c`. Without that + the key sat in the file but never in the turn, and every start asked for it + again. - "Use this" calls `AgentCore::useProvider()`, which runs `cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config: a pasted key is verified against the provider's `/models` before it is saved @@ -233,6 +238,35 @@ session: `AgentCore::attachmentDir()` is re-read on every switch. A document (PDF, UTF-8 text) still arrives as its name in the message text -- those are sent to the model as text, not as a picture. +## Files the agent delivers + +The opposite direction: the engine's `deliver_file` tool (registered in +`src/tools/registry.c`, run in `src/tools/fs_tools.c`) copies a finished file +out of the workspace into the session's own store, and the transcript shows it +as a **file card** -- an extension badge, the file's name, its type and size, +and two buttons. + +- The copy is marked `for_user` (`cst_attachment_t.for_user`, + `cst_attach_import_artifact()`): any bytes up to `CST_ARTIFACT_MAX_BYTES` + (64 MB), the name's extension decides the mime, and **no request ever + contains it** -- `src/llm_openai.c` skips those entries when it builds the + body, which is what keeps a delivered report from being pasted back into the + context. `tests/test_artifacts.c` holds that down at the wire level. +- A card never invents a path. The row carries the stored file's name, and + `AgentCore::openArtifact()` / `shareArtifact()` accept it only when one of + the session's own messages carries that name (`artifactPathOf()`, plus the + engine's `cst_attach_rel_ok()`), so a forged name is refused with a reason. +- **Open** hands the file to the system: Android builds a `content://` URI + through the app's FileProvider, checks `resolveActivity()`, starts + `ACTION_VIEW` and grants the reader permission; a desktop calls + `QDesktopServices::openUrl`. **Share** uses the phone's share sheet; a + desktop has none, so the path goes to the clipboard. +- `CST_APP_NO_OPEN=1` makes both do nothing but report the line they would + write. `--uitest` sets it, so a test run never opens anything for real: it + replays a session with a delivered `report.md`, checks the card and its two + buttons, clicks Open (the header must say `opened report.md`), clicks Share + and checks a name the session does not own is refused. + ## Transcript, tool calls, streaming text - A tool call is a card of its own, and the call **id** is what pairs it with @@ -328,7 +362,7 @@ GUI only through queued signals. |---|---|---| | Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) | | Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path | -| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | none yet: `app/src/platform/android/AndroidTools.cpp` is a stub, so notification, share sheet, clipboard and the file picker are still missing | +| Android | engine compiled with the NDK inside the APK (`scripts/build_apk_android.sh`, `-DCST_TUI=0`, `CST_NO_WEBUI=1`); no port, QML talks to `AgentCore` in-process | notification (`NotificationChannel` + permission probe), clipboard (`QClipboard` under the engine's own tool names), share sheet (`ACTION_SEND` + chooser), and the file card's open/share through the app's FileProvider (`AndroidTools.cpp`) | | macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) | Android's long-term shape: the core lives in the APK (no Termux needed), the @@ -353,8 +387,13 @@ The build flag that keeps Unix helpers out of the picture is thumbnails (`image://cstattach/`, see the Images section) - [x] Android APK: the engine is linked in (no port), builds, installs under `xyz.whcself.cstagent` and runs on the device (see `docs/android.md`) +- [x] the saved provider key reaches the engine on every start (`AgentCore::open()` + resolves it: saved key, provider environment, `CST_API_KEY`) +- [x] delivered files: `deliver_file` stores the copy next to the session and the + transcript draws a file card with Open and Share (see the Files section) +- [x] Android platform tools (JNI: notification, clipboard, share sheet) and the + file card's open/share through the app's FileProvider - [ ] a real turn driven through the window (needs a provider key) -- [ ] Android platform tools (JNI: notification, share sheet, clipboard, file - picker), the back gesture, the icon glyphs on the phone +- [ ] SAF file picker, the back gesture, the icon glyphs on the phone - [ ] Kirigami (the rest of the Breeze look: colors, controls), tray, global shortcuts diff --git a/app/android/AndroidManifest.xml b/app/android/AndroidManifest.xml index e812bb1..5b7a2ed 100644 --- a/app/android/AndroidManifest.xml +++ b/app/android/AndroidManifest.xml @@ -69,5 +69,20 @@ android:name="android.app.arguments" android:value="-- %%INSERT_APP_ARGUMENTS%% --" /> + + + + + diff --git a/app/qml/ImageStrip.qml b/app/qml/ImageStrip.qml index b885fb8..c8f645b 100644 --- a/app/qml/ImageStrip.qml +++ b/app/qml/ImageStrip.qml @@ -2,21 +2,28 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -// The images a message or a tool call carries: thumbnails from the session's -// own store (image://cstattach/, app/src/ui/SessionImages.cpp). The names are -// the ones the engine wrote next to the session -- view_image, an MCP image, a -// picture the user attached -- never a path the view invented. +// The files a message or a tool call carries. // -// One row of thumbnails: the height is capped so a screenshot cannot push the -// conversation off the screen, the width follows the picture's shape. +// A picture is a thumbnail from the session's own store (image://cstattach/, +// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for +// images when it is tapped. A file the agent delivered (deliver_file) is a card +// with the name, the type, the size and two buttons: open and share. The names +// are the ones the engine wrote next to the session -- a card never invents a +// path. +// +// One line per file, written by AgentCore.cpp (filesText): +// ||||||x +// A session saved by an older version wrote "||| +// x" for pictures; both shapes are read here. Flow { id: strip objectName: "imageStrip" - /* One line per picture, written by the engine side (AgentCore.cpp: - * "|||x"). A row carries this as text - * because a ListModel role cannot hold a list. */ + /* A row carries this as text: a ListModel role cannot hold a list. */ property string text: "" - property var images: decode(text) + property var files: decode(text) + /* The last refusal, so a tap that cannot work says why (the header's own + * status line is the engine's). */ + property string errorText: "" spacing: 6 function decode(t) { @@ -28,83 +35,235 @@ Flow { const f = lines[i].split("|") if (!f[0].length) continue + const old = f.length < 7 // a picture line from an old session + const dim = String(old ? f[3] : f[6]).split("x") let w = 0, h = 0 - const dim = String(f[3] || "").split("x") if (dim.length === 2) { w = parseInt(dim[0], 10) || 0 h = parseInt(dim[1], 10) || 0 } - out.push({ url: "image://cstattach/" + f[0], rel: f[0], - name: f[1] || f[0], origin: f[2] || "", w: w, h: h }) + const rel = old ? f[0] : f[1] + out.push({ kind: old ? "img" : f[0], + rel: rel, + name: (old ? f[1] : f[2]) || rel, + origin: (old ? f[2] : f[3]) || "", + mime: old ? "image/*" : (f[4] || ""), + bytes: old ? 0 : (parseInt(f[5], 10) || 0), + w: w, h: h, + url: "image://cstattach/" + rel }) } return out } + function humanSize(n) { + if (!n || n <= 0) + return "" + if (n >= 1024 * 1024) + return (n / (1024 * 1024)).toFixed(1) + " MB" + if (n >= 1024) + return Math.round(n / 1024) + " KB" + return n + " B" + } + + /* The two things a file card does. The engine answers with "" when it + * worked (it puts the line in the header itself) or a reason. */ + function openFile(rel) { + errorText = agent.openArtifact(rel) + } + function shareFile(rel) { + errorText = agent.shareArtifact(rel) + } + Repeater { - model: strip.images + model: strip.files delegate: Rectangle { - id: thumb + id: cell required property var modelData required property int index + readonly property bool isFile: String(modelData.kind) !== "img" + readonly property bool hovered: mouse.containsMouse + /* Images keep their own shape, capped; a file card is a fixed + * width so a long name cannot stretch the transcript. */ readonly property real ratio: { const w = Number(modelData.w), h = Number(modelData.h) return (w > 0 && h > 0) ? w / h : 1.5 } - readonly property int cap: 150 // the tallest a thumbnail gets - objectName: "imageThumb" + index - width: Math.max(56, Math.min(Math.round(cap * ratio), 320)) - height: Math.max(40, Math.min(cap, Math.round(width / ratio))) + readonly property int cap: 150 + + objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index) + width: isFile ? 300 + : Math.max(56, Math.min(Math.round(cap * ratio), 320)) + height: isFile ? 52 + : Math.max(40, Math.min(cap, Math.round(width / ratio))) radius: Theme.radiusSm color: Theme.bg border.width: 1 - border.color: mouse.containsMouse ? Theme.accentDim : Theme.border + border.color: hovered ? Theme.accentDim : Theme.border clip: true - Image { - id: pic - anchors.fill: parent - anchors.margins: 2 - source: String(thumb.modelData.url) - sourceSize.width: 512 - sourceSize.height: 512 - fillMode: Image.PreserveAspectFit - /* Not asynchronous: the provider already hands back a - * thumbnail-sized picture, so there is nothing to wait for. */ - asynchronous: false - visible: status === Image.Ready - } - - // A file the store no longer has is not worth shouting about: the - // frame says which image it was. - Text { - anchors.centerIn: parent - width: parent.width - 8 - visible: pic.status === Image.Error - text: String(thumb.modelData.name) - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignHCenter - color: Theme.textFaint - font.pixelSize: Theme.smallSize - } - MouseArea { id: mouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.NoButton } - ToolTip.visible: mouse.containsMouse + + // --- a picture: tap it to look at it --------------------------- + Image { + id: pic + anchors.fill: parent + anchors.margins: 2 + visible: !cell.isFile && status === Image.Ready + source: String(cell.modelData.url) + sourceSize.width: 512 + sourceSize.height: 512 + fillMode: Image.PreserveAspectFit + asynchronous: false + } + + Text { + anchors.centerIn: parent + width: parent.width - 8 + visible: !cell.isFile && pic.status === Image.Error + text: String(cell.modelData.name) + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignHCenter + color: Theme.textFaint + font.pixelSize: Theme.smallSize + } + + MouseArea { + anchors.fill: parent + enabled: !cell.isFile + cursorShape: Qt.PointingHandCursor + onClicked: strip.openFile(String(cell.modelData.rel)) + } + + // --- a delivered file: name, type, open, share ----------------- + RowLayout { + id: row + visible: cell.isFile + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 6 + spacing: 6 + + /* The extension stands in for an icon: no theme lookup, and a + * ZIP that says ZIP is more use than a generic sheet. */ + Rectangle { + implicitWidth: 40 + implicitHeight: 30 + radius: Theme.radiusSm + color: Theme.surfaceHi + Text { + anchors.centerIn: parent + text: { + const n = String(cell.modelData.name) + const dot = n.lastIndexOf(".") + if (dot < 0 || dot === n.length - 1) + return "FILE" + return n.slice(dot + 1, dot + 5).toUpperCase() + } + color: Theme.textDim + font.pixelSize: Theme.smallSize + font.bold: true + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + Text { + Layout.fillWidth: true + text: String(cell.modelData.name) + color: Theme.text + font.pixelSize: Theme.bodySize + elide: Text.ElideMiddle + } + Text { + Layout.fillWidth: true + text: { + const m = cell.modelData + let s = m.mime ? String(m.mime) : "" + const size = strip.humanSize(m.bytes) + if (size) + s += (s ? " · " : "") + size + if (m.origin && m.origin !== "user" && m.origin !== "tool") + s += (s ? " · " : "") + m.origin + return s + } + color: Theme.textFaint + font.pixelSize: Theme.smallSize + elide: Text.ElideRight + } + } + + ToolButton { + objectName: "fileOpen" + cell.index + implicitWidth: Theme.touchTarget + implicitHeight: Theme.touchTarget + onClicked: strip.openFile(String(cell.modelData.rel)) + contentItem: Text { + text: "Open" + color: parent.hovered ? Theme.text : Theme.textDim + font.pixelSize: Theme.smallSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered ? Theme.surfaceHi : "transparent" + border.width: 1 + border.color: Theme.border + } + } + + ToolButton { + objectName: "fileShare" + cell.index + implicitWidth: Theme.touchTarget + implicitHeight: Theme.touchTarget + onClicked: strip.shareFile(String(cell.modelData.rel)) + contentItem: Text { + text: "Share" + color: parent.hovered ? Theme.text : Theme.textDim + font.pixelSize: Theme.smallSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + radius: Theme.radiusSm + color: parent.hovered ? Theme.surfaceHi : "transparent" + border.width: 1 + border.color: Theme.border + } + } + } + + ToolTip.visible: hovered ToolTip.delay: 400 ToolTip.text: { - const m = thumb.modelData + const m = cell.modelData let s = String(m.name) if (m.w > 0 && m.h > 0) s += " · " + m.w + "×" + m.h - if (m.origin && m.origin !== "user") - s += " · " + m.origin + if (m.mime && m.kind !== "img") + s += " · " + m.mime return s } } } + + /* A tap that could not work says why, right where the card is: a phone + * with nothing installed for the type is the common case ("no app on the + * phone opens report.md"), and a silence there looks like a dead button. */ + Text { + objectName: "fileError" + visible: strip.errorText.length > 0 + width: strip.width + wrapMode: Text.WordWrap + text: strip.errorText + color: Theme.err + font.pixelSize: Theme.smallSize + } } diff --git a/app/qml/Main.qml b/app/qml/Main.qml index 24fb203..5a5c20f 100644 --- a/app/qml/Main.qml +++ b/app/qml/Main.qml @@ -566,11 +566,26 @@ ApplicationWindow { } } - /* Images a tool produced, on the card of the call that made them. */ + /* Files a tool produced, on the card of the call that made them. The + * engine reports the result first and the files after it, so the card is + * normally already complete by the time they arrive -- findTool() only + * looks at cards still running, which is why this looks for the call id + * itself (and falls back to findTool for a call that has no id). */ function engineToolImages(callId, images) { if (!images.length) return - const i = win.findTool(callId, "") + let i = -1 + if (callId.length) { + for (let k = transcript.count - 1; k >= 0; k--) { + const r = transcript.get(k) + if (r.kind === "tool" && r.callId === callId) { + i = k + break + } + } + } else { + i = win.findTool("", "") + } if (i >= 0) transcript.setProperty(i, "images", images) } diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 07f9293..918ef51 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -16,6 +16,7 @@ #include +#include "ToolProvider.h" /* openLocalFile / shareLocalFile */ #include "cst_agent.h" #include "cst_attach.h" #include "cst_models.h" @@ -113,6 +114,22 @@ bool AgentCore::open(const QString &workspace, const QString &provider, snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", m); } } + /* The window is started without --api-key, and the engine's config never + * resolves credentials on its own: without this the key the user saved for + * the provider would be in the file but not in the turn, and every start + * would ask for it again. Same order as the CLI (main.c): a key passed to + * open() > the saved one > the provider's env var > CST_API_KEY. */ + if (m_cfg.api_key[0] == 0 && m_cfg.provider[0]) { + char key[512] = ""; + if (cst_creds_resolve(m_cfg.provider, key, sizeof(key), nullptr) != 0) { + const char *env = getenv("CST_API_KEY"); + if (env && env[0]) + snprintf(key, sizeof(key), "%s", env); + } + if (key[0]) + snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", key); + memset(key, 0, sizeof(key)); /* never let it outlive the call */ + } /* 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. */ @@ -334,28 +351,81 @@ QString field(const char *s) { return out; } -/* The images of one message or tool result, one line per picture: +/* The files of one message or tool result, one line each: * - * |||x + * ||||||x * - * A list role is not an option: a ListModel keeps what fits in a QVariant of - * a simple type -- a list reads back as undefined there, and setProperty drops - * the role -- so the rows carry this text and ImageStrip.qml decodes it. A - * document (PDF, text) is not a picture and stays out of it. */ -QString imagesText(const cst_attachment_t *list, int n) { + * kind is "img" for a picture (a thumbnail the view can show) or "file" for a + * file the agent delivered (a card with open and share). A list role is not an + * option: a ListModel keeps what fits in a QVariant of a simple type -- a list + * reads back as undefined there, and setProperty drops the role -- so the rows + * carry this text and ImageStrip.qml decodes it. A document the user attached + * (PDF, text) is named in the message text and stays out of it, and so does + * anything older sessions wrote. */ +QString filesText(const cst_attachment_t *list, int n) { QStringList lines; for (int i = 0; list && i < n; i++) { const cst_attachment_t *a = &list[i]; - if (!cst_attach_is_image(a)) + const bool img = cst_attach_is_image(a) != 0; + if (!img && !a->for_user) continue; - lines << QStringLiteral("%1|%2|%3|%4x%5") - .arg(field(a->rel), field(a->name), field(a->origin)) - .arg(a->width) - .arg(a->height); + QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8"); + line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file")); + line = line.arg(field(a->rel)); + line = line.arg(field(a->name)); + line = line.arg(field(a->origin)); + line = line.arg(field(a->mime)); + line = line.arg(a->bytes); + line = line.arg(a->width); + line = line.arg(a->height); + lines << line; } return lines.join(QLatin1Char('\n')); } +/* A stored file of this session, named the way a file card names it: its full + * path, or empty when the name is not one this session's store owns. A card + * hands back a name it got from the engine (filesText), so a name no message + * carries is not a file of this session at all -- never open it. `title`, when + * asked for, gets the display name the person knows the file by. */ +QString artifactPathOf(cst_agent_t *agent, const QString &rel, QString *title) { + if (title) + title->clear(); + if (!agent) + return QString(); + const QByteArray r = rel.trimmed().toUtf8(); + if (r.isEmpty() || !cst_attach_rel_ok(r.constData())) + return QString(); + cst_session_t *s = cst_agent_session(agent); + if (!s) + return QString(); + bool mine = false; + cst_session_lock(s); + const int n = cst_session_count(s); + for (int i = 0; !mine && i < n; i++) { + const cst_message_t *m = cst_session_get(s, i); + for (int a = 0; m && a < m->nattach; a++) { + if (strcmp(m->attach[a].rel, r.constData()) != 0) + continue; + mine = true; + if (title) + *title = utf8(m->attach[a].name); + break; + } + } + cst_session_unlock(s); + if (!mine) + return QString(); + cst_attachment_t a; + memset(&a, 0, sizeof(a)); + snprintf(a.rel, sizeof(a.rel), "%s", r.constData()); + const char *session = cst_agent_session_path(agent); + char full[4096] = ""; + if (!session || cst_attach_full_path(session, &a, full, sizeof(full)) != 0) + return QString(); + return utf8(full); +} + /* The open tool row this result belongs to, -1 when none. Rows are paired by * call id first: the same tool may run twice in a turn, and pairing by name * would hang the first card on "running" forever. Only a call without an id @@ -407,6 +477,38 @@ QString AgentCore::attachmentDir() const { return utf8(dir); } +/* A file card's "open": the file goes to whatever the system uses for its + * type (Android ACTION_VIEW through the FileProvider, a desktop's own + * application). "" when it went through. */ +QString AgentCore::openArtifact(const QString &rel) { + QString title; + const QString path = artifactPathOf(m_agent, rel, &title); + if (path.isEmpty()) + return QStringLiteral("no such file in this session: %1").arg(rel); + QString status; + const QString err = app::openLocalFile(path, title, &status); + if (!err.isEmpty()) + return err; + if (!status.isEmpty()) + emit statusChanged(status); + return QString(); +} + +/* The same through the share sheet (a phone) or the clipboard (a desktop). */ +QString AgentCore::shareArtifact(const QString &rel) { + QString title; + const QString path = artifactPathOf(m_agent, rel, &title); + if (path.isEmpty()) + return QStringLiteral("no such file in this session: %1").arg(rel); + QString status; + const QString err = app::shareLocalFile(path, title, &status); + if (!err.isEmpty()) + return err; + if (!status.isEmpty()) + emit statusChanged(status); + return QString(); +} + QVariantList AgentCore::sessions() const { QVariantList out; if (m_workspace.isEmpty()) @@ -457,14 +559,14 @@ QVariantList AgentCore::history() const { r["name"] = name; r["callId"] = callId; r["output"] = utf8(m->content); - r["images"] = imagesText(m->attach, m->nattach); + r["images"] = filesText(m->attach, m->nattach); r["state"] = "done"; r["complete"] = true; rows.append(r); } else { QVariantMap r = rows.at(at).toMap(); r["output"] = utf8(m->content); - r["images"] = imagesText(m->attach, m->nattach); + r["images"] = filesText(m->attach, m->nattach); r["state"] = "done"; r["complete"] = true; rows[at] = r; @@ -490,7 +592,7 @@ QVariantList AgentCore::history() const { if (!names.isEmpty()) text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", ")); r["text"] = text; - r["images"] = imagesText(m->attach, m->nattach); + r["images"] = filesText(m->attach, m->nattach); r["complete"] = true; rows.append(r); continue; @@ -1027,7 +1129,7 @@ void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out, void AgentCore::hookToolImages(const ctl_tool_call_t *call, const cst_attachment_t *list, int n, void *ud) { AgentCore *self = static_cast(ud); - const QString images = imagesText(list, n); + const QString images = filesText(list, n); if (images.isEmpty()) return; /* a call that made no picture needs no signal */ const QString id = QString::fromUtf8(call && call->id ? call->id : ""); diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h index 67cf3c9..49fd5b4 100644 --- a/app/src/core/AgentCore.h +++ b/app/src/core/AgentCore.h @@ -139,6 +139,13 @@ class AgentCore : public QObject { * image://cstattach/ serves (SessionImages.cpp). "" until the engine is * open. It follows the session. */ Q_INVOKABLE QString attachmentDir() const; + /* A file card's two buttons: the stored file goes to the system -- a phone + * opens it with ACTION_VIEW or hands it to the share sheet, a desktop gives + * it to its own application or to the clipboard. "" when it went through + * (the header gets the line from statusChanged), else what to say. The name + * is the stored one the card carries; anything else is refused. */ + Q_INVOKABLE QString openArtifact(const QString &rel); + Q_INVOKABLE QString shareArtifact(const QString &rel); /* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */ Q_INVOKABLE int seesImages() const; diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h index 6b1eaa0..15385c6 100644 --- a/app/src/core/ToolProvider.h +++ b/app/src/core/ToolProvider.h @@ -54,6 +54,20 @@ int platformRegisterMac(AgentCore *core); int platformRegisterWindows(AgentCore *core); int platformRegisterAndroid(AgentCore *core); +/* Hand a file of the session's store to the system: a phone opens it with + * ACTION_VIEW through the app's FileProvider, a desktop gives it to whatever + * the desktop uses. `title` is the name a person knows the file by (the + * stored copy is named by its id, which means nothing outside), used for the + * status line. Both return "" on success and a reason to show the user + * otherwise; *status (optional) gets the line to put in the header. + * CST_APP_NO_OPEN=1 makes both do nothing (--uitest drives the window without + * opening anything for real). */ +QString openLocalFile(const QString &path, const QString &title, QString *status); +/* The same file through the share sheet where there is one (Android); a + * desktop has no share sheet, so the path goes to the clipboard. */ +QString shareLocalFile(const QString &path, const QString &title, + QString *status); + } // namespace app #endif /* CST_APP_TOOLPROVIDER_H */ diff --git a/app/src/main.cpp b/app/src/main.cpp index 03be29e..32cd968 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,11 @@ #include #include +#if defined(Q_OS_ANDROID) +#include +#include +#endif + #include #include #include @@ -50,13 +56,41 @@ namespace { int runSelftest() { QTextStream out(stdout); int bad = 0; - QTemporaryDir tmp; - if (!tmp.isValid()) { + QTemporaryDir tmp, cfg; + if (!tmp.isValid() || !cfg.isValid()) { out << "selftest: cannot create a scratch dir\n"; return 1; } + /* The window passes no --api-key, so the key the user saved for the provider + * has to come back from /cst-agent/credentials -- the file the + * settings dialog writes. Planted in a scratch config dir, never in the + * real one. */ + const QString cfgdir = cfg.path() + QStringLiteral("/cst-agent"); + QDir().mkpath(cfgdir); + QFile creds(cfgdir + QStringLiteral("/credentials")); + if (!creds.open(QIODevice::WriteOnly | QIODevice::Truncate) || + creds.write("deepseek.api_key=selftest-saved\n") < 0) { + out << "selftest: cannot write a scratch credentials file\n"; + return 1; + } + creds.close(); + qputenv("XDG_CONFIG_HOME", cfg.path().toUtf8()); + AgentCore core; QString err; + if (!core.open(tmp.path(), QStringLiteral("deepseek"), QString(), QString(), + &err)) { + out << "selftest: engine did not start: " << err << "\n"; + return 1; + } + const cst_agent_config_t *live = + core.agent() ? cst_agent_config(core.agent()) : nullptr; + if (!live || strcmp(live->api_key, "selftest-saved") != 0) { + out << "selftest: the saved key did not reach the engine config\n"; + bad++; + } else { + out << "selftest: the saved key is in the engine config\n"; + } if (!core.open(tmp.path(), QStringLiteral("deepseek"), QStringLiteral("deepseek-flash"), QStringLiteral("test-key"), &err)) { @@ -86,6 +120,18 @@ int runSelftest() { << (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why))) << "\n"; } + /* The engine's own deliver_file: the file a tool hands to the person. */ + const cst_tool_def_t *dv = cst_tool_find("deliver_file"); + if (!dv) { + out << "selftest: deliver_file is not in the registry\n"; + bad++; + } else { + char why[160] = ""; + const bool ok = cst_tool_available(dv, why, sizeof(why)) == 0; + out << "selftest: deliver_file available=" << (ok ? "yes" : "no") + << (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why))) + << "\n"; + } /* the mode comes from the engine, not from the UI */ core.setMode(QStringLiteral("general")); if (core.mode() != QLatin1String("general")) { @@ -199,6 +245,19 @@ int main(int argc, char **argv) { return 1; } app::registerPlatformTools(&core); /* the app's own tools */ +#if defined(Q_OS_ANDROID) + /* notification_send sits behind POST_NOTIFICATIONS from Android 13 on, and + * the tool is hidden while it is not granted (its probe says so): ask once, + * here, so the tool is there for the first turn rather than a prompt that + * shows up mid-answer. Denying it only costs the tool. */ + if (QNativeInterface::QAndroidApplication::context().isValid() && + QtAndroidPrivate::checkPermission( + QStringLiteral("android.permission.POST_NOTIFICATIONS")) + .result() != QtAndroidPrivate::Authorized) { + QtAndroidPrivate::requestPermission( + QStringLiteral("android.permission.POST_NOTIFICATIONS")); + } +#endif if (parser.isSet(planOpt)) core.setPlanMode(true); diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp index 0eeb838..4f5db86 100644 --- a/app/src/platform/android/AndroidTools.cpp +++ b/app/src/platform/android/AndroidTools.cpp @@ -12,32 +12,715 @@ * WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never * on the GUI thread. JNI calls are fine from any thread (QJniEnvironment * attaches it), Qt GUI objects are not -- a tool that needs the widget tree - * has to hop back with a blocking queued call. + * has to hop back with a blocking queued call (onGuiThread() below). * - * WHAT GOES HERE (one at a time, each with its own probe): - * notification_send NotificationChannel + NotificationManager through - * JNI; hidden until POST_NOTIFICATIONS is granted - * (Android 13+), which the UI asks for on first use. - * clipboard_get/set the phone clipboard through Qt's QClipboard, taking - * the name of the desktop tool that cannot work here - * (see app/README.md: a front end may shadow a built-in - * that has no working backend on its host). - * share ACTION_SEND of a file or text through the system share - * sheet -- the phone's answer to "show this to the user". - * - * Nothing is registered yet: the first APK ships the engine, the window and - * the tool contract, and the tools above arrive next (docs/android.md). */ + * The clipboard tools keep the names of the engine's built-ins: those are + * gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same + * name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the + * model sees one capability either way. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include + +#include + +#include +#include +#include #include "core/AgentCore.h" #include "core/ToolProvider.h" namespace app { +namespace { + +/* --- small helpers ------------------------------------------------------- */ + +const char *argOf(const ctl_tool_call_t *call, const char *key) { + for (int i = 0; i < call->nargs; i++) + if (strcmp(call->args[i].key, key) == 0) + return call->args[i].value; + return nullptr; +} + +void setOut(cst_tool_result_t *r, const QByteArray &text, int rc) { + r->out = strdup(text.constData()); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = rc; +} + +/* Anything that touches a Qt GUI object (the clipboard) or an Activity has to + * run on the GUI thread; the turn's worker thread waits for it. */ +template +void onGuiThread(Fn &&fn) { + if (!qApp || QThread::currentThread() == qApp->thread()) { + fn(); + return; + } + QMetaObject::invokeMethod(qApp, std::forward(fn), + Qt::BlockingQueuedConnection); +} + +jint sdkInt() { + static const jint sdk = QJniObject::getStaticField( + "android/os/Build$VERSION", "SDK_INT"); + return sdk; +} + +QJniObject appContext() { + return QNativeInterface::QAndroidApplication::context(); +} + +/* --- notification_send --------------------------------------------------- */ + +/* Android 13 (API 33) put notifications behind a runtime permission; before + * that the permission does not exist and they always work. main.cpp asks for + * it once at startup, so the probe is normally green on a device where the + * user said yes. */ +int notifyProbe(char *why, size_t why_sz) { + if (sdkInt() < 33) + return 0; + const QFuture f = + QtAndroidPrivate::checkPermission( + QStringLiteral("android.permission.POST_NOTIFICATIONS")); + if (f.result() == QtAndroidPrivate::Authorized) + return 0; + snprintf(why, why_sz, + "POST_NOTIFICATIONS is not granted (Android 13+ asks the user the " + "first time the app runs)"); + return -1; +} + +/* The message of the Java exception that is pending right now, if any. Qt + * clears exceptions on the way out of a call, and build() throws rather than + * returning: without this the tool could only say "it failed". */ +QString javaErrorText() { + QJniEnvironment env; + JNIEnv *e = env.jniEnv(); + if (!e || !e->ExceptionCheck()) + return QString(); + jthrowable th = e->ExceptionOccurred(); + e->ExceptionClear(); + QString text; + if (th) { + jclass cls = e->GetObjectClass(th); + if (cls) { + jmethodID mid = e->GetMethodID(cls, "getMessage", "()Ljava/lang/String;"); + if (mid) { + jstring js = static_cast(e->CallObjectMethod(th, mid)); + if (js) { + const char *utf = e->GetStringUTFChars(js, nullptr); + if (utf) { + text = QString::fromUtf8(utf); + e->ReleaseStringUTFChars(js, utf); + } + e->DeleteLocalRef(js); + } + } + e->DeleteLocalRef(cls); + } + e->DeleteLocalRef(th); + } + return text.isEmpty() ? QStringLiteral("a Java exception was thrown") : text; +} + +bool postNotification(const QString &title, const QString &message, + QString *err) { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + *err = QStringLiteral("no Android context"); + return false; + } + QJniObject manager = ctx.callObjectMethod( + "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;", + QJniObject::fromString(QStringLiteral("notification")).object()); + if (!manager.isValid()) { + *err = QStringLiteral("no NotificationManager"); + return false; + } + + const QString channelId = QStringLiteral("cst-agent"); + QJniObject builder; + if (sdkInt() >= 26) { + QJniObject channel( + "android/app/NotificationChannel", + "(Ljava/lang/String;Ljava/lang/CharSequence;I)V", + QJniObject::fromString(channelId).object(), + QJniObject::fromString(QStringLiteral("CST-agent")).object(), + static_cast(3) /* IMPORTANCE_DEFAULT */); + manager.callMethod("createNotificationChannel", + "(Landroid/app/NotificationChannel;)V", + channel.object()); + builder = QJniObject("android/app/Notification$Builder", + "(Landroid/content/Context;Ljava/lang/String;)V", + ctx.object(), + QJniObject::fromString(channelId).object()); + } else { + builder = QJniObject("android/app/Notification$Builder", + "(Landroid/content/Context;)V", ctx.object()); + } + if (!builder.isValid()) { + *err = QStringLiteral("no Notification.Builder"); + return false; + } + + /* The small icon. The R classes are not reachable from here, so the id is + * looked up by name, and build() throws IllegalArgumentException ("Invalid + * notification (no valid small icon)") when there is none at all: every + * candidate that can be there is tried -- the framework drawables first, + * then the launcher icon the APK itself carries (AGP puts it in mipmap, a + * hand made APK in drawable/icon). */ + QJniObject res = ctx.callObjectMethod("getResources", + "()Landroid/content/res/Resources;"); + QJniObject pkg = ctx.callObjectMethod("getPackageName", + "()Ljava/lang/String;"); + jint icon = 0; + if (res.isValid()) { + auto idOf = [&res, &pkg](const char *name, const char *type, + const char *owner) { + return res.callMethod( + "getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", + QJniObject::fromString(QString::fromLatin1(name)).object(), + QJniObject::fromString(QString::fromLatin1(type)).object(), + owner ? QJniObject::fromString(QString::fromLatin1(owner)).object() + : pkg.object()); + }; + for (const char *n : {"ic_dialog_info", "ic_popup_reminder", + "stat_notify_chat", "sym_def_app_icon"}) { + icon = idOf(n, "drawable", "android"); + if (icon != 0) + break; + } + for (const char *n : {"ic_launcher", "icon", "app_icon", "qt_icon"}) { + if (icon == 0) + icon = idOf(n, "mipmap", nullptr); + if (icon == 0) + icon = idOf(n, "drawable", nullptr); + if (icon != 0) + break; + } + } + if (icon == 0) { + *err = QStringLiteral("no small icon: it is in neither the framework " + "resources nor the APK's own"); + return false; + } + builder.callObjectMethod( + "setContentTitle", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;", + QJniObject::fromString(title).object()); + builder.callObjectMethod( + "setContentText", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;", + QJniObject::fromString(message).object()); + builder.callObjectMethod( + "setSmallIcon", "(I)Landroid/app/Notification$Builder;", icon); + + /* Tapping it opens the app again (the launch intent of the package). */ + QJniObject pm = ctx.callObjectMethod( + "getPackageManager", "()Landroid/content/pm/PackageManager;"); + if (pm.isValid() && pkg.isValid()) { + QJniObject launch = pm.callObjectMethod( + "getLaunchIntentForPackage", + "(Ljava/lang/String;)Landroid/content/Intent;", pkg.object()); + if (launch.isValid()) { + /* FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP */ + launch.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", + static_cast(0x10000000 | 0x04000000)); + QJniObject pending( + "android/app/PendingIntent", + "(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;", + ctx.object(), static_cast(0), launch.object(), + /* FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT */ + static_cast(0x04000000 | 0x08000000)); + if (pending.isValid()) + builder.callObjectMethod( + "setContentIntent", + "(Landroid/app/PendingIntent;)Landroid/app/Notification$Builder;", + pending.object()); + } + } + + QJniObject note = builder.callObjectMethod("build", + "()Landroid/app/Notification;"); + if (!note.isValid()) { + const QString java = javaErrorText(); + *err = QStringLiteral("Notification.Builder.build() failed: %1") + .arg(java.isEmpty() ? QStringLiteral("no exception detail") + : java); + return false; + } + manager.callMethod("notify", "(ILandroid/app/Notification;)V", + static_cast(1), note.object()); + const QString after = javaErrorText(); + if (!after.isEmpty()) { + *err = QStringLiteral("NotificationManager.notify() failed: %1").arg(after); + return false; + } + return true; +} + +/* Runs on the turn's worker thread: no GUI objects here. */ +int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)ud; + const char *message = argOf(call, "message"); + const char *title = argOf(call, "title"); + if (!message || !message[0]) { + setOut(r, QByteArrayLiteral("error: notification_send needs a message"), 1); + return 0; + } + const QString t = title && title[0] ? QString::fromUtf8(title) + : QStringLiteral("CST-agent"); + const QString m = QString::fromUtf8(message); + QString err; + bool ok = false; + onGuiThread([&] { ok = postNotification(t, m, &err); }); + if (ok) + setOut(r, QByteArrayLiteral("notification sent"), 0); + else + setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1); + return 0; +} + +/* --- clipboard_read / clipboard_write ----------------------------------- */ + +int clipboardProbe(char *why, size_t why_sz) { + if (QGuiApplication::clipboard()) + return 0; + snprintf(why, why_sz, "no QClipboard (no GUI application)"); + return -1; +} + +int clipboardReadRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)ud; + const char *cap = argOf(call, "max_bytes"); + const long max = + cap && cap[0] ? strtol(cap, nullptr, 10) : 65536L; + QString text; + bool have = false; + onGuiThread([&] { + if (QClipboard *cb = QGuiApplication::clipboard()) { + text = cb->text(); + have = true; + } + }); + if (!have) { + setOut(r, QByteArrayLiteral("error: no clipboard"), 1); + return 0; + } + QByteArray body = text.toUtf8(); + const bool cut = max > 0 && body.size() > max; + if (cut) + body.truncate(static_cast(max)); + QByteArray out = + QByteArrayLiteral("clipboard text (") + QByteArray::number(body.size()) + + QByteArrayLiteral(" bytes") + + (cut ? QByteArrayLiteral(", truncated") : QByteArray()) + + QByteArrayLiteral("):\n") + body + QByteArrayLiteral("\n"); + setOut(r, out, 0); + return 0; +} + +int clipboardWriteRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)ud; + const char *text = argOf(call, "text"); + if (!text) { + setOut(r, QByteArrayLiteral("error: clipboard_write needs text"), 1); + return 0; + } + const QString t = QString::fromUtf8(text); + bool ok = false; + onGuiThread([&] { + if (QClipboard *cb = QGuiApplication::clipboard()) { + cb->setText(t); + ok = true; + } + }); + if (ok) + setOut(r, QByteArrayLiteral("clipboard: replaced with ") + + QByteArray::number(t.toUtf8().size()) + + QByteArrayLiteral(" bytes"), + 0); + else + setOut(r, QByteArrayLiteral("error: no clipboard"), 1); + return 0; +} + +/* --- share --------------------------------------------------------------- */ + +/* A path inside the workspace, resolved the way the engine's file tools do: + * relative to the workspace and never outside it. */ +QString resolveInWorkspace(const cst_tool_ctx_t *ctx, const char *rel) { + if (!ctx || !ctx->workspace_root || !rel || !rel[0]) + return QString(); + const QString root = QDir::cleanPath(QString::fromUtf8(ctx->workspace_root)); + const QString full = QDir::cleanPath(root + QLatin1Char('/') + + QString::fromUtf8(rel)); + if (full != root && !full.startsWith(root + QLatin1Char('/'))) + return QString(); + return full; +} + +QString mimeOf(const QString &path) { + const QString ext = QFileInfo(path).suffix().toLower(); + if (ext == QLatin1String("png")) return QStringLiteral("image/png"); + if (ext == QLatin1String("jpg") || ext == QLatin1String("jpeg")) + return QStringLiteral("image/jpeg"); + if (ext == QLatin1String("webp")) return QStringLiteral("image/webp"); + if (ext == QLatin1String("gif")) return QStringLiteral("image/gif"); + if (ext == QLatin1String("pdf")) return QStringLiteral("application/pdf"); + if (ext == QLatin1String("json")) return QStringLiteral("application/json"); + if (ext == QLatin1String("txt") || ext == QLatin1String("md") || + ext == QLatin1String("log")) + return QStringLiteral("text/plain"); + return QStringLiteral("*/*"); +} + +/* ACTION_SEND through the system share sheet. `text` shares a sentence (the + * usual case: the user takes it to another app), `file` shares one file out of + * the workspace by way of the FileProvider Qt's manifest template declares + * (androidx.core.content.FileProvider, authority .qtprovider). */ +bool shareText(const QString &text, const QString &subject, QString *err) { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + *err = QStringLiteral("no Android context"); + return false; + } + QJniObject send = QJniObject::fromString(QStringLiteral("android.intent.action.SEND")); + QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V", + send.object()); + intent.callObjectMethod("setType", + "(Ljava/lang/String;)Landroid/content/Intent;", + QJniObject::fromString(QStringLiteral("text/plain")) + .object()); + intent.callObjectMethod( + "putExtra", + "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", + QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT")) + .object(), + QJniObject::fromString(text).object()); + if (!subject.isEmpty()) + intent.callObjectMethod( + "putExtra", + "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", + QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT")) + .object(), + QJniObject::fromString(subject).object()); + QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V", + QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER")) + .object()); + /* Intent.createChooser(Intent, CharSequence) -- the share sheet itself. */ + QJniObject sheet = QJniObject::callStaticObjectMethod( + "android/content/Intent", "createChooser", + "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;", + intent.object(), + QJniObject::fromString(QStringLiteral("Share with")).object()); + QJniObject toStart = sheet.isValid() ? sheet : intent; + toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", + static_cast(0x10000000) /* NEW_TASK */); + ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", + toStart.object()); + return true; +} + +/* The content:// uri of a file inside the app's own data directory. The + * FileProvider and its paths xml come from Qt's manifest template + * (authority .qtprovider, files-path "/"), which covers the whole + * workspace and the session store. */ +QJniObject contentUriFor(const QString &path, QString *err) { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + *err = QStringLiteral("no Android context"); + return QJniObject(); + } + QJniObject pkg = ctx.callObjectMethod("getPackageName", "()Ljava/lang/String;"); + const QString authority = pkg.toString() + QStringLiteral(".qtprovider"); + QJniObject file("java/io/File", "(Ljava/lang/String;)V", + QJniObject::fromString(path).object()); + QJniObject uri = QJniObject::callStaticObjectMethod( + "androidx/core/content/FileProvider", "getUriForFile", + "(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;", + ctx.object(), + QJniObject::fromString(authority).object(), + file.object()); + if (!uri.isValid()) + *err = QStringLiteral("the FileProvider did not hand out a uri for ") + path; + return uri; +} + +bool shareFile(const QString &path, const QString &text, QString *err) { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + *err = QStringLiteral("no Android context"); + return false; + } + QJniObject uri = contentUriFor(path, err); + if (!uri.isValid()) + return false; + QJniObject action = QJniObject::fromString(QStringLiteral("android.intent.action.SEND")); + QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V", + action.object()); + intent.callObjectMethod("setType", + "(Ljava/lang/String;)Landroid/content/Intent;", + QJniObject::fromString(mimeOf(path)).object()); + intent.callObjectMethod( + "putExtra", + "(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;", + QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM")) + .object(), + uri.object()); + if (!text.isEmpty()) + intent.callObjectMethod( + "putExtra", + "(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;", + QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT")) + .object(), + QJniObject::fromString(text).object()); + intent.callObjectMethod( + "addFlags", "(I)Landroid/content/Intent;", + static_cast(0x10000000 /* NEW_TASK */ | 0x00000001 /* GRANT_READ_URI_PERMISSION */)); + QJniObject sheet = QJniObject::callStaticObjectMethod( + "android/content/Intent", "createChooser", + "(Landroid/content/Intent;Ljava/lang/CharSequence;)Landroid/content/Intent;", + intent.object(), + QJniObject::fromString(QStringLiteral("Share with")).object()); + QJniObject toStart = sheet.isValid() ? sheet : intent; + toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", + static_cast(0x10000000)); + ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", + toStart.object()); + return true; +} + +int shareRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ud; + const char *text = argOf(call, "text"); + const char *file = argOf(call, "file"); + const char *subject = argOf(call, "subject"); + if ((!text || !text[0]) && (!file || !file[0])) { + setOut(r, QByteArrayLiteral("error: share needs text or file"), 1); + return 0; + } + QString path; + if (file && file[0]) { + path = resolveInWorkspace(ctx, file); + if (path.isEmpty() || !QFileInfo::exists(path)) { + setOut(r, QByteArrayLiteral("error: ") + QByteArray(file) + + QByteArrayLiteral(" is not inside the workspace"), + 1); + return 0; + } + } + const QString body = text ? QString::fromUtf8(text) : QString(); + const QString subjectLine = subject ? QString::fromUtf8(subject) : QString(); + QString err; + bool ok = false; + onGuiThread([&] { + ok = path.isEmpty() ? shareText(body, subjectLine, &err) + : shareFile(path, body, &err); + }); + if (!ok) { + setOut(r, QByteArrayLiteral("error: ") + err.toUtf8(), 1); + return 0; + } + setOut(r, path.isEmpty() + ? QByteArrayLiteral("the share sheet is open with the text") + : QByteArrayLiteral("the share sheet is open with ") + + path.toUtf8(), + 0); + return 0; +} + +int shareProbe(char *why, size_t why_sz) { + if (appContext().isValid()) + return 0; + snprintf(why, why_sz, "no Android activity to share from"); + return -1; +} + +/* Registered once; the engine keeps a copy until the owner is unregistered. */ +cst_tool_def_t g_notify; +cst_tool_def_t g_clip_read; +cst_tool_def_t g_clip_write; +cst_tool_def_t g_share; + +} // namespace + +/* A file card's "open": whatever app the phone has for the type, with the read + * permission granted to the app that picks it up. */ +QString openLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("opened %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + QString err; + bool ok = false; + onGuiThread([&] { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + err = QStringLiteral("no Android context"); + return; + } + QJniObject uri = contentUriFor(path, &err); + if (!uri.isValid()) + return; + QJniObject action = + QJniObject::fromString(QStringLiteral("android.intent.action.VIEW")); + QJniObject intent("android/content/Intent", "(Ljava/lang/String;)V", + action.object()); + intent.callObjectMethod( + "setDataAndType", + "(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;", + uri.object(), + QJniObject::fromString(mimeOf(path)).object()); + intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;", + static_cast(0x10000000 /* NEW_TASK */ | + 0x00000001 /* GRANT_READ_URI */)); + /* Nothing installed for the type: say so rather than fail silently. */ + QJniObject pm = ctx.callObjectMethod( + "getPackageManager", "()Landroid/content/pm/PackageManager;"); + QJniObject found = + pm.isValid() + ? pm.callObjectMethod( + "resolveActivity", + "(Landroid/content/Intent;I)Landroid/content/pm/ResolveInfo;", + intent.object(), + static_cast(0x00010000) /* MATCH_DEFAULT_ONLY */) + : QJniObject(); + if (!found.isValid()) { + err = QStringLiteral("no app on the phone opens %1").arg(name); + return; + } + ctx.callMethod("startActivity", "(Landroid/content/Intent;)V", + intent.object()); + ok = true; + }); + if (!ok) + return err.isEmpty() ? QStringLiteral("cannot open %1").arg(name) : err; + if (status) + *status = QStringLiteral("opened %1").arg(name); + return QString(); +} + +/* The share sheet, the same way the share tool opens it. */ +QString shareLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("shared %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + QString err; + bool ok = false; + onGuiThread([&] { ok = shareFile(path, QString(), &err); }); + if (!ok) + return err.isEmpty() ? QStringLiteral("cannot share %1").arg(name) : err; + if (status) + *status = QStringLiteral("share sheet: %1").arg(name); + return QString(); +} int platformRegisterAndroid(AgentCore *core) { if (!core) return 0; - return 0; /* no Android tools yet */ + int n = 0; + + { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"message", "string", 1, "text of the notification"}, + {"title", "string", 0, "title line (default: CST-agent)"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_notify, 0, sizeof(g_notify)); + setMeta(&g_notify, "notification_send", + "Show a notification in the phone's shade (what the user sees " + "outside the app).", + CST_TP_ASK, "notify", kAndroidOnly, notifyProbe); + memcpy(g_notify.params, params, sizeof(params)); + g_notify.run_ex = notifyRun; + g_notify.owner = "app:android"; + n += core->registerTool(&g_notify) ? 1 : 0; + } + { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"max_bytes", "integer", 0, "cap on the returned text (default 65536)"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_clip_read, 0, sizeof(g_clip_read)); + setMeta(&g_clip_read, "clipboard_read", + "Read the text on the phone's clipboard — what the user copied " + "outside the workspace. Returns UTF-8 text.", + CST_TP_READ, "read", kAndroidOnly, clipboardProbe); + memcpy(g_clip_read.params, params, sizeof(params)); + g_clip_read.run_ex = clipboardReadRun; + g_clip_read.owner = "app:android"; + n += core->registerTool(&g_clip_read) ? 1 : 0; + } + { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"text", "string", 1, "text to put on the clipboard"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_clip_write, 0, sizeof(g_clip_write)); + setMeta(&g_clip_write, "clipboard_write", + "Replace the phone's clipboard with text, so the user can paste it " + "into another app. Overwrites what they had copied.", + CST_TP_WRITE, "write", kAndroidOnly, clipboardProbe); + memcpy(g_clip_write.params, params, sizeof(params)); + g_clip_write.run_ex = clipboardWriteRun; + g_clip_write.owner = "app:android"; + n += core->registerTool(&g_clip_write) ? 1 : 0; + } + { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"text", "string", 0, "text to share"}, + {"file", "string", 0, "workspace-relative file to share"}, + {"subject", "string", 0, "subject line of the shared item"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_share, 0, sizeof(g_share)); + setMeta(&g_share, "share", + "Hand text or a workspace file to the system share sheet, so the " + "user can send it to another app.", + CST_TP_ASK, "share", kAndroidOnly, shareProbe); + memcpy(g_share.params, params, sizeof(params)); + g_share.run_ex = shareRun; + g_share.owner = "app:android"; + n += core->registerTool(&g_share) ? 1 : 0; + } + return n; } } // namespace app diff --git a/app/src/platform/linux/NotifyTool.cpp b/app/src/platform/linux/NotifyTool.cpp index 370700f..768cd9c 100644 --- a/app/src/platform/linux/NotifyTool.cpp +++ b/app/src/platform/linux/NotifyTool.cpp @@ -3,10 +3,14 @@ * the capability -- platforms + probe decide whether the tool is offered. */ #include +#include +#include #include +#include #include #include #include +#include #include #include @@ -86,6 +90,43 @@ cst_tool_def_t g_notify; } // namespace +/* A file card's "open": xdg-open through QDesktopServices, so the desktop's + * own application for the type comes up. */ +QString openLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("opened %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + if (!QDesktopServices::openUrl(QUrl::fromLocalFile(path))) + return QStringLiteral("no application on this desktop opens %1").arg(name); + if (status) + *status = QStringLiteral("opened %1").arg(name); + return QString(); +} + +/* No share sheet here: the path is what another program (or another window) + * needs, so it goes to the clipboard. */ +QString shareLocalFile(const QString &path, const QString &title, + QString *status) { + const QString name = title.isEmpty() ? QFileInfo(path).fileName() : title; + if (qEnvironmentVariableIsSet("CST_APP_NO_OPEN")) { + if (status) + *status = QStringLiteral("shared %1 (test mode)").arg(name); + return QString(); + } + if (!QFileInfo::exists(path)) + return QStringLiteral("the file is gone: %1").arg(name); + QGuiApplication::clipboard()->setText(path); + if (status) + *status = QStringLiteral("%1: the path is on the clipboard").arg(name); + return QString(); +} + int platformRegisterLinux(AgentCore *core) { static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { {"message", "string", 1, "text of the notification"}, diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index 16a21db..a621130 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -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 *out) { if (!root) - return nullptr; + return; if (root->objectName() == name) - return root; + out->append(root); const QList 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 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(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 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(), diff --git a/docs/android.md b/docs/android.md index 0ec7d4a..3167a5d 100644 --- a/docs/android.md +++ b/docs/android.md @@ -158,10 +158,27 @@ have, so the script rewrites the shebang of the build-tools wrappers. * The APK builds, installs and runs: the Qt front end links the engine in-process (no port), `main.cpp` puts the workspace, config and tmp under `/data/user/0/xyz.whcself.cstagent/files/`, and a turn does a real HTTPS - request from inside the ART process (with no API key the provider answers - 401 -- the engine, curl and TLS parts of the round trip are what that - shows). Rotation keeps the session; the sidebar appears in landscape. -* Still to come: the Android platform tools behind `app:android` - (`notification_send`, clipboard, share sheet, SAF file picker), the back - gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the phone - and draws empty boxes where the desktop shows Breeze icons. + request from inside the ART process. The saved provider key is resolved into + the engine's config on every start (`AgentCore::open()`: saved key, the + provider's environment variable, `CST_API_KEY`), so the phone answers without + being asked for the key again. Rotation keeps the session; the sidebar + appears in landscape. +* Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`): + `clipboard_get` / `clipboard_set` under the engine's own tool names and + `share` (`ACTION_SEND` + chooser) both work on the device. The file + card's **Open** builds a `content://` URI through the app's FileProvider + (`.qtprovider`, Qt's `@xml/qtprovider_paths`), checks + `resolveActivity()` before starting `ACTION_VIEW` and grants the reader + permission; **Share** reuses the share sheet. + `notification_send` still fails on the NX789J: the channel and the small + icon are found, `Notification.Builder.build()` returns null, and Qt clears + the Java exception before C++ can read it ("no exception detail"). Catching + it needs the call to be wrapped in Java (a small `app/android/src/...` + helper class), which is the next step there. +* A session's transcript replays a delivered file (`deliver_file`) as a card + with Open and Share -- the copy lives next to the session, marked `for_user` + so no request ever contains it (see `app/README.md`, "Files the agent + delivers"). +* Still to come: the SAF picker for attaching a picture from another app, the + back gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the + phone and draws empty boxes where the desktop shows Breeze icons. diff --git a/include/cst_agent.h b/include/cst_agent.h index ee7337f..6d367d2 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -61,6 +61,10 @@ typedef struct { int pages; /* PDF page count, 0 for anything else */ char detail[8]; /* auto | low | high (wire hint, "" = auto) */ char origin[32]; /* user | view_image | mcp (for display only) */ + /* A delivered file (deliver_file): the front end shows it as a card the + * person can open or share, and no request ever carries it. The stored copy + * is the file as it was, not a text reduction -- see cst_attach.h. */ + int for_user; } cst_attachment_t; typedef struct { diff --git a/include/cst_attach.h b/include/cst_attach.h index 2e5db60..ee44069 100644 --- a/include/cst_attach.h +++ b/include/cst_attach.h @@ -23,6 +23,10 @@ extern "C" { #define CST_ATTACH_MAX_COUNT 8 /* per message */ /* Text stored and inlined per document (a PDF also caps its text here). */ #define CST_ATTACH_TEXT_MAX (4 * 1024 * 1024) +/* A delivered file (deliver_file) is the opposite of an attachment: it is + * handed to the person, not to the model, so any bytes are allowed up to this + * size, the extension names the type, and no request ever shows it. */ +#define CST_ARTIFACT_MAX_BYTES (64 * 1024 * 1024) /* Images go on the wire as image parts; everything else is inlined as text. */ int cst_attach_is_image(const cst_attachment_t *a); @@ -55,6 +59,14 @@ cst_attachment_t *cst_attach_import_file(const char *session_path, cst_attachment_t *cst_attach_import_bytes(const char *session_path, const char *name, const char *data, size_t n, char *err, size_t err_sz); +/* Copy src_path into the session's store as a finished file for the user: + * any bytes up to CST_ARTIFACT_MAX_BYTES, mime from the name's extension, and + * `for_user` set so it stays out of every request. title (optional) is the + * display name, the source's file name the default. */ +cst_attachment_t *cst_attach_import_artifact(const char *session_path, + const char *src_path, + const char *title, char *err, + size_t err_sz); /* Validate and read back a stored payload, or NULL when it is gone. */ char *cst_attach_read(const char *session_path, const cst_attachment_t *a, diff --git a/include/cst_version.h b/include/cst_version.h index 7fd7986..1c605ab 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 1 +#define CST_VERSION_PATCH 2 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/attach.c b/src/attach.c index ec4b812..fa191eb 100644 --- a/src/attach.c +++ b/src/attach.c @@ -344,16 +344,18 @@ static char *read_whole(const char *path, size_t *out_len, size_t max) { } /* FNV over the bytes, the name and the clock: short, stable, unique enough - * inside one session. */ -static void make_id(const char *name, const char *data, size_t n, char *out, - size_t out_sz) { + * inside one session. The prefix says what the name is for: "img" for what a + * model is shown, "art" for what a person is handed. */ +static void make_id(const char *prefix, const char *name, const char *data, + size_t n, char *out, size_t out_sz) { uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n); h = cst_fnv1a64(h, name, strlen(name)); h = cst_fnv1a64(h, &n, sizeof(n)); double now = os_now_ms(); h = cst_fnv1a64(h, &now, sizeof(now)); h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int)); - snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu)); + snprintf(out, out_sz, "%s-%08x", prefix && prefix[0] ? prefix : "img", + (unsigned)(h & 0xFFFFFFFFu)); } /* n bytes, NUL-terminated: a text payload is handed around as a C string. */ @@ -416,11 +418,15 @@ static char *prepare(const char *data, size_t n, char *mime, size_t mime_sz, return copy; } -/* Write one payload whose kind is already known. */ +/* Write one payload whose kind is already known. id_prefix names it ("img" / + * "art"), for_user marks it as the person's rather than the model's, and + * ext_override is the stored file's extension when the caller knows better + * than the mime does. */ static cst_attachment_t *store(const char *session_path, const char *name, const char *data, size_t n, const char *mime, - int pages, const char *origin, char *err, - size_t err_sz) { + int pages, const char *origin, int for_user, + const char *id_prefix, const char *ext_override, + char *err, size_t err_sz) { if (!name || !name[0]) name = "image"; char dir[OS_PATH_MAX]; @@ -433,12 +439,14 @@ static cst_attachment_t *store(const char *session_path, const char *name, snprintf(err, err_sz, "out of memory"); return NULL; } - make_id(name, data, n, a->id, sizeof(a->id)); - snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime)); + make_id(id_prefix, name, data, n, a->id, sizeof(a->id)); + snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, + ext_override && ext_override[0] ? ext_override : mime_of_ext(mime)); snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image"); snprintf(a->mime, sizeof(a->mime), "%s", mime); snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user"); snprintf(a->detail, sizeof(a->detail), "auto"); + a->for_user = for_user ? 1 : 0; a->bytes = (long long)n; a->pages = pages; if (cst_attach_is_image(a)) @@ -494,7 +502,8 @@ cst_attachment_t *cst_attach_import_bytes(const char *session_path, (double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20)); return NULL; } - return store(session_path, name, data, n, mime, 0, "user", err, err_sz); + return store(session_path, name, data, n, mime, 0, "user", 0, "img", NULL, + err, err_sz); } cst_attachment_t *cst_attach_import_file(const char *session_path, @@ -526,11 +535,135 @@ cst_attachment_t *cst_attach_import_file(const char *session_path, if (!payload) return NULL; cst_attachment_t *a = - store(session_path, leaf, payload, pn, mime, pages, "user", err, err_sz); + store(session_path, leaf, payload, pn, mime, pages, "user", 0, "img", + NULL, err, err_sz); free(payload); return a; } +/* The mime of a delivered file: its extension decides (the content is not + * sniffed -- a .zip is not a picture), and anything unknown is a plain blob + * the OS still opens by name. */ +static const char *mime_from_name(const char *name) { + static const struct { + const char *ext, *mime; + } T[] = { + {"md", "text/markdown"}, {"markdown", "text/markdown"}, + {"txt", "text/plain"}, {"log", "text/plain"}, + {"json", "application/json"}, {"csv", "text/csv"}, + {"html", "text/html"}, {"htm", "text/html"}, + {"xml", "application/xml"}, {"yaml", "application/yaml"}, + {"yml", "application/yaml"}, {"toml", "application/toml"}, + {"pdf", "application/pdf"}, {"zip", "application/zip"}, + {"gz", "application/gzip"}, {"tgz", "application/gzip"}, + {"tar", "application/x-tar"}, {"sh", "text/x-shellscript"}, + {"py", "text/x-python"}, {"c", "text/x-c"}, + {"h", "text/x-c"}, {"cpp", "text/x-c++"}, + {"java", "text/x-java"}, {"kt", "text/x-kotlin"}, + {"js", "text/javascript"}, {"ts", "text/typescript"}, + {"go", "text/x-go"}, {"rs", "text/x-rust"}, + {"rb", "text/x-ruby"}, {"qml", "text/x-qml"}, + {"css", "text/css"}, {"sql", "text/x-sql"}, + {"diff", "text/x-diff"}, {"patch", "text/x-diff"}, + {"png", "image/png"}, {"jpg", "image/jpeg"}, + {"jpeg", "image/jpeg"}, {"gif", "image/gif"}, + {"webp", "image/webp"}, {"bmp", "image/bmp"}, + {"svg", "image/svg+xml"}, {"ico", "image/x-icon"}, + {"mp3", "audio/mpeg"}, {"m4a", "audio/mp4"}, + {"ogg", "audio/ogg"}, {"wav", "audio/wav"}, + {"mp4", "video/mp4"}, {"webm", "video/webm"}, + {"apk", "application/vnd.android.package-archive"}, + {"docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + {"xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + {"pptx", + "application/vnd.openxmlformats-officedocument.presentationml." + "presentation"}, + }; + const char *dot = name ? strrchr(name, '.') : NULL; + if (!dot || !dot[1]) + return "application/octet-stream"; + const char *e = dot + 1; + size_t el = strlen(e); + for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) { + if (strlen(T[i].ext) != el) + continue; + size_t k = 0; + for (; k < el; k++) { + char c = e[k]; + if (c >= 'A' && c <= 'Z') + c = (char)(c - 'A' + 'a'); + if (c != T[i].ext[k]) + break; + } + if (k == el) + return T[i].mime; + } + return "application/octet-stream"; +} + +/* The stored file's extension: the name's own when it is plain ([a-z0-9], at + * most 8 characters -- a stored name has to stay portable), else whatever the + * mime maps to. */ +static void ext_for_name(const char *name, const char *mime, char *out, + size_t out_sz) { + size_t k = 0; + const char *dot = name ? strrchr(name, '.') : NULL; + if (dot && dot[1] && out_sz > 1) { + for (const char *p = dot + 1; *p && k < 8 && k + 1 < out_sz; p++) { + char c = *p; + if (c >= 'A' && c <= 'Z') + c = (char)(c - 'A' + 'a'); + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) + break; + out[k++] = c; + } + } + out[k] = 0; + if (!k) + snprintf(out, out_sz, "%s", mime_of_ext(mime)); +} + +cst_attachment_t *cst_attach_import_artifact(const char *session_path, + const char *src_path, + const char *title, char *err, + size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (!session_path || !session_path[0]) { + snprintf(err, err_sz, "no session to store the file in"); + return NULL; + } + if (!src_path || !src_path[0]) { + snprintf(err, err_sz, "no file to deliver"); + return NULL; + } + size_t n = 0; + char *data = read_whole(src_path, &n, CST_ARTIFACT_MAX_BYTES); + if (!data) { + snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)", + src_path, (int)(CST_ARTIFACT_MAX_BYTES >> 20)); + return NULL; + } + const char *leaf = src_path; + for (const char *p = src_path; *p; p++) + if (*p == '/' || *p == '\\') + leaf = p + 1; + const char *name = (title && title[0]) ? title : leaf; + char mime[64], ext[16]; + snprintf(mime, sizeof(mime), "%s", mime_from_name(name)); + ext_for_name(name, mime, ext, sizeof(ext)); + cst_attachment_t *a = store(session_path, name, data, n, mime, 0, "tool", 1, + "art", ext, err, err_sz); + free(data); + return a; +} + /* Images are opaque and travel as image parts; everything else is text the * store holds and the request inlines. */ int cst_attach_is_image(const cst_attachment_t *a) { @@ -538,7 +671,8 @@ int cst_attach_is_image(const cst_attachment_t *a) { } int cst_attach_is_text(const cst_attachment_t *a) { - return a && !cst_attach_is_image(a); + /* A delivered file is the person's: it is never inlined into a request. */ + return a && !a->for_user && !cst_attach_is_image(a); } char *cst_attach_read(const char *session_path, const cst_attachment_t *a, @@ -688,6 +822,8 @@ char *cst_attachments_to_json(const cst_attachment_t *list, int n) { cJSON_AddStringToObject(o, "detail", a->detail); if (a->origin[0]) cJSON_AddStringToObject(o, "origin", a->origin); + if (a->for_user) + cJSON_AddNumberToObject(o, "for_user", 1); cJSON_AddItemToArray(arr, o); } char *s = cJSON_PrintUnformatted(arr); @@ -740,6 +876,8 @@ cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) v = cJSON_GetObjectItemCaseSensitive(it, "origin"); snprintf(a->origin, sizeof(a->origin), "%s", cJSON_IsString(v) ? v->valuestring : ""); + v = cJSON_GetObjectItemCaseSensitive(it, "for_user"); + a->for_user = cJSON_IsNumber(v) ? v->valueint : 0; k++; } if (k == 0) { diff --git a/src/llm_openai.c b/src/llm_openai.c index d65d6ba..e77063b 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -296,10 +296,12 @@ static cJSON *document_part(const cst_llm_request_t *req, } /* Append every attachment of m: images as image parts (or a note saying why - * not), documents inline as text. */ -static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, - const cst_message_t *m, size_t *budget, - size_t *doc_budget, const char *img_off) { + * not), documents inline as text. Returns how many parts were added: a + * delivered file (for_user) adds none. */ +static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, + const cst_message_t *m, size_t *budget, + size_t *doc_budget, const char *img_off) { + int added = 0; int n = m->nattach; if (n > CST_ATTACH_MAX_COUNT) { char note[160]; @@ -307,10 +309,15 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, "[only the first %d of %d attachments are sent]", CST_ATTACH_MAX_COUNT, n); parts_add_text(parts, cJSON_CreateString(note)); + added++; n = CST_ATTACH_MAX_COUNT; } for (int i = 0; i < n; i++) { const cst_attachment_t *a = &m->attach[i]; + /* A delivered file belongs to the person, not to the model: no part, and + * not even a "not sent" note -- it was never meant to be in the request. */ + if (a->for_user) + continue; int is_img = cst_attach_is_image(a); char why[256]; cJSON *p = NULL; @@ -322,13 +329,16 @@ static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, p = document_part(req, a, doc_budget, why, sizeof(why)); if (p) { cJSON_AddItemToArray(parts, p); + added++; continue; } char note[512]; snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]", is_img ? "image" : "document", a->name, why); parts_add_text(parts, cJSON_CreateString(note)); + added++; } + return added; } /* Images a tool result produced are deferred: an assistant tool_call must be @@ -345,8 +355,14 @@ static void defer_attachments(const cst_llm_request_t *req, cJSON_AddArrayToObject(p, "content"); *pending = p; } - parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, - budget, doc_budget, img_off); + cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content"); + parts_add_attachments(req, content, m, budget, doc_budget, img_off); + if (cJSON_GetArraySize(content) == 0) { + /* Nothing to carry (a tool result that only delivered a file): an empty + * user message is noise, and some providers refuse it. */ + cJSON_Delete(p); + *pending = NULL; + } } /* m's text plus one line per image it would have carried (malloc'd); a diff --git a/src/session.c b/src/session.c index 146190c..5b0ede9 100644 --- a/src/session.c +++ b/src/session.c @@ -280,26 +280,18 @@ static int write_line(FILE *fp, const cst_message_t *msg) { cJSON_Delete(mo); } if (msg->attach && msg->nattach > 0) { - cJSON *arr = cJSON_CreateArray(); - for (int i = 0; i < msg->nattach; i++) { - const cst_attachment_t *a = &msg->attach[i]; - cJSON *it = cJSON_CreateObject(); - cJSON_AddStringToObject(it, "id", a->id); - cJSON_AddStringToObject(it, "name", a->name); - cJSON_AddStringToObject(it, "mime", a->mime); - cJSON_AddStringToObject(it, "rel", a->rel); - cJSON_AddNumberToObject(it, "bytes", (double)a->bytes); - if (a->width > 0 && a->height > 0) { - cJSON_AddNumberToObject(it, "width", a->width); - cJSON_AddNumberToObject(it, "height", a->height); - } - if (a->detail[0]) - cJSON_AddStringToObject(it, "detail", a->detail); - if (a->origin[0]) - cJSON_AddStringToObject(it, "origin", a->origin); - cJSON_AddItemToArray(arr, it); + /* One writer for every attachment (cst_attachments_to_json): a field it + * carries must not have to be remembered here too -- forgetting + * `for_user` would put a delivered file back in front of the model. */ + char *json = cst_attachments_to_json(msg->attach, msg->nattach); + if (json) { + cJSON *arr = cJSON_Parse(json); + free(json); + if (cJSON_IsArray(arr)) + cJSON_AddItemToObject(o, "attachments", arr); + else + cJSON_Delete(arr); } - cJSON_AddItemToObject(o, "attachments", arr); } char *txt = cJSON_PrintUnformatted(o); cJSON_Delete(o); diff --git a/src/tools/fs_tools.c b/src/tools/fs_tools.c index 3641838..5fe42eb 100644 --- a/src/tools/fs_tools.c +++ b/src/tools/fs_tools.c @@ -291,6 +291,56 @@ int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, return rc; } +/* --- deliver_file -------------------------------------------------------- */ + +/* The file IS the answer: copy it into the session store and hand it to the + * person as a card the front end can open or share. It is marked for_user, so + * the model never sees the copy again -- the result line has to say what was + * delivered. */ +int cst_tool_deliver_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *path = tool_arg(c, "path"); + if (!path || !path[0]) + return tool_set_result(r, "error: missing path", 1); + const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL; + if (!session || !session[0]) + return tool_set_result(r, "error: no session to store the file in", 1); + char full[OS_PATH_MAX]; + if (resolve(ctx, path, full, sizeof(full)) != 0) + return tool_set_resultf(r, 1, + "error: path outside workspace or missing parent: " + "%.200s", + path); + if (is_dir(full)) + return tool_set_resultf(r, 1, "error: %.200s is a directory", path); + + char err[256]; + cst_attachment_t *a = + cst_attach_import_artifact(session, full, tool_arg(c, "title"), err, + sizeof(err)); + if (!a) + return tool_set_resultf(r, 1, "error: cannot deliver %.200s: %.200s", path, + err); + char human[32]; + if (a->bytes >= 1024 * 1024) + snprintf(human, sizeof(human), "%.1f MB", + (double)a->bytes / (1024.0 * 1024.0)); + else if (a->bytes >= 1024) + snprintf(human, sizeof(human), "%.0f KB", (double)a->bytes / 1024.0); + else + snprintf(human, sizeof(human), "%lld B", a->bytes); + cst_buf_t out = {0}; + cst_buf_printf(&out, "delivered %.120s (%s, %s)\n", a->name, a->mime, human); + cst_buf_puts(&out, + "(the user sees it as a file card they can open or share; " + "you will not be shown its bytes again)\n"); + /* After tool_take_buf: it clears the result, and view_image does the same. */ + int rc = tool_take_buf(r, &out, 0); + r->attach = a; /* one entry; freed as a block by cst_tool_result_free */ + r->nattach = 1; + return rc; +} + /* --- edit_file ----------------------------------------------------------- */ static size_t count_occ(const char *hay, size_t hn, const char *needle, diff --git a/src/tools/registry.c b/src/tools/registry.c index ccd6e25..be4148a 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -67,6 +67,17 @@ static const cst_tool_def_t TOOLS[] = { {{"path", S, 1, "image file path relative to the workspace"}, {"detail", S, 0, "low or high (provider hint; default auto)"}}, CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN, NO_META}, + {"deliver_file", + "Hand a finished file to the person: it is copied into the session store " + "and shown in the transcript as a card they can open or share. Use it " + "whenever the answer is a file -- a report, an export, a patch, a " + "picture, a build. You are not shown the copy again, so say what it " + "contains in your answer.", + "Deliver a workspace file to the user (a file card in the transcript, " + "openable and shareable from there).", + {{"path", S, 1, "file path relative to the workspace"}, + {"title", S, 0, "display name (default: the file name)"}}, + CST_TP_READ, 0, cst_tool_deliver_file, NULL, NO_DYN, NO_META}, {"run_shell", "Run a non-interactive shell command in the workspace root: builds, " "tests, git, scripts, computations, data processing, other CLIs " diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index 28fc962..ec07112 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -57,6 +57,8 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); +int cst_tool_deliver_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); int cst_preview_write_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, char **out); diff --git a/tests/test_artifacts.c b/tests/test_artifacts.c new file mode 100644 index 0000000..287e707 --- /dev/null +++ b/tests/test_artifacts.c @@ -0,0 +1,414 @@ +/* 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 +#include +#include +#include + +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; +}