v0.9.2: files the agent delivers come back as cards, and the saved key is used

A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.

deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
  the model; cst_attach_import_artifact() stores any bytes up to
  CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
  extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
  directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
  skips for_user entries without even a "not sent" note, and a turn that only
  delivered a file no longer invents an empty user message. tests/test_artifacts.c
  pins that down end to end, including a session-file round trip.

The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
  name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
  entry is a file card (extension badge, name, type and size, Open, Share);
  a picture stays a thumbnail and opens on tap. Both shapes are read, so
  sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
  the session's own messages carries it, and hand the display name to the
  platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
  resolveActivity() checked, read permission granted) and shares through the
  share sheet; a desktop uses QDesktopServices / the clipboard.
  CST_APP_NO_OPEN=1 makes --uitest run without opening anything.

Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
  `for_user`: the file survived the file, but came back as an attachment --
  no card, and put back in front of the model. It now uses the one writer
  (cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
  card that is still running -- and the engine reports the result *before*
  the files, so the card was already complete and the files were dropped.
  A live turn therefore showed no card while a replay of the same session
  did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
  never drawn. The card now carries the reason ("no app on the phone opens
  report.md").

Also
- The saved provider key reaches the engine on every start: AgentCore::open()
  was handed an empty key and the engine never resolves credentials itself
  (only the CLI did), so every start asked for it again. It resolves the same
  order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
  the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
  and keeps failing on the NX789J -- the build() returns null and Qt clears
  the exception before C++ can read it, so the call has to be wrapped in Java
  next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
  refused), the live order of result-then-files, and the startup row count.

Version 0.9.2.
This commit is contained in:
WHC
2026-10-11 16:34:47 +08:00
parent 84f9585588
commit 60cbce3c5c
26 changed files with 2082 additions and 148 deletions
+1
View File
@@ -371,6 +371,7 @@ endforeach()
# Tests that link the whole core (no TUI). # Tests that link the whole core (no TUI).
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
test_apply_patch test_session_roundtrip test_attach test_apply_patch test_session_roundtrip test_attach
test_artifacts
test_shell test_diff test_tools_fs test_registry test_shell test_diff test_tools_fs test_registry
test_undo test_config test_models test_model_registry test_sandbox test_undo test_config test_models test_model_registry test_sandbox
test_keymap test_shell_compact test_skills test_keymap test_shell_compact test_skills
+1
View File
@@ -140,6 +140,7 @@ TESTS = \
tests/test_apply_patch \ tests/test_apply_patch \
tests/test_session_roundtrip \ tests/test_session_roundtrip \
tests/test_attach \ tests/test_attach \
tests/test_artifacts \
tests/test_pdf_text \ tests/test_pdf_text \
tests/test_shell \ tests/test_shell \
tests/test_diff \ tests/test_diff \
+1
View File
@@ -305,6 +305,7 @@ cst-agent --headless --prompt 'List files'
| `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 | | `ask_user` | 询问 | 向用户提问(可给选项);headless 下返回错误让模型自行决定 |
| `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | | `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 |
| `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 |
| `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 |
插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 插件工具和 MCP 工具(`mcp__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。 同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
+14 -1
View File
@@ -64,7 +64,14 @@ endif()
# --- the GUI ------------------------------------------------------------- # --- the GUI -------------------------------------------------------------
if(CST_APP_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) if(NOT Qt6_FOUND)
message(WARNING message(WARNING
"app: Qt 6 Quick/Qml not found, skipping cst-agent-app " "app: Qt 6 Quick/Qml not found, skipping cst-agent-app "
@@ -102,6 +109,12 @@ if(CST_APP_GUI)
endif() endif()
target_link_libraries(cst-agent-app PRIVATE target_link_libraries(cst-agent-app PRIVATE
Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::Test cstcore) 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) if(WIN32)
set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE)
endif() endif()
+43 -4
View File
@@ -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" with each entry's base URL and where its key would come from: "key saved"
(`.agent`-independent, `<config>/cst-agent/credentials`), "key from (`.agent`-independent, `<config>/cst-agent/credentials`), "key from
OPENAI_API_KEY", or "no key". A key itself is never sent to the QML side — 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 - "Use this" calls `AgentCore::useProvider()`, which runs
`cst_ps_switch_provider()` / `cst_ps_set_key()` on the engine's live config: `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 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 -- 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. 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 ## Transcript, tool calls, streaming text
- A tool call is a card of its own, and the call **id** is what pairs it with - 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) | | 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=<Qt>/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 | | Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=<Qt>/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`) | | 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 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/<stored name>`, see the Images section) thumbnails (`image://cstattach/<stored name>`, see the Images section)
- [x] Android APK: the engine is linked in (no port), builds, installs under - [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`) `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) - [ ] a real turn driven through the window (needs a provider key)
- [ ] Android platform tools (JNI: notification, share sheet, clipboard, file - [ ] SAF file picker, the back gesture, the icon glyphs on the phone
picker), the back gesture, the icon glyphs on the phone
- [ ] Kirigami (the rest of the Breeze look: colors, controls), tray, - [ ] Kirigami (the rest of the Breeze look: colors, controls), tray,
global shortcuts global shortcuts
+15
View File
@@ -69,5 +69,20 @@
android:name="android.app.arguments" android:name="android.app.arguments"
android:value="-- %%INSERT_APP_ARGUMENTS%% --" /> android:value="-- %%INSERT_APP_ARGUMENTS%% --" />
</activity> </activity>
<!-- The share tool hands a file out of the workspace, and a file
crossing an app boundary has to travel as a content:// uri. This
is Qt's own provider (androidx.core.content.FileProvider, paths
from res/xml/qtprovider_paths.xml): its files-path is the app's
files directory, which is where the workspace lives. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.qtprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/qtprovider_paths" />
</provider>
</application> </application>
</manifest> </manifest>
+210 -51
View File
@@ -2,21 +2,28 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
// The images a message or a tool call carries: thumbnails from the session's // The files a message or a tool call carries.
// 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.
// //
// One row of thumbnails: the height is capped so a screenshot cannot push the // A picture is a thumbnail from the session's own store (image://cstattach/,
// conversation off the screen, the width follows the picture's shape. // 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):
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
// <w>x<h>" for pictures; both shapes are read here.
Flow { Flow {
id: strip id: strip
objectName: "imageStrip" objectName: "imageStrip"
/* One line per picture, written by the engine side (AgentCore.cpp: /* A row carries this as text: a ListModel role cannot hold a list. */
* "<stored name>|<name>|<origin>|<w>x<h>"). A row carries this as text
* because a ListModel role cannot hold a list. */
property string text: "" 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 spacing: 6
function decode(t) { function decode(t) {
@@ -28,83 +35,235 @@ Flow {
const f = lines[i].split("|") const f = lines[i].split("|")
if (!f[0].length) if (!f[0].length)
continue 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 let w = 0, h = 0
const dim = String(f[3] || "").split("x")
if (dim.length === 2) { if (dim.length === 2) {
w = parseInt(dim[0], 10) || 0 w = parseInt(dim[0], 10) || 0
h = parseInt(dim[1], 10) || 0 h = parseInt(dim[1], 10) || 0
} }
out.push({ url: "image://cstattach/" + f[0], rel: f[0], const rel = old ? f[0] : f[1]
name: f[1] || f[0], origin: f[2] || "", w: w, h: h }) 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 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 { Repeater {
model: strip.images model: strip.files
delegate: Rectangle { delegate: Rectangle {
id: thumb id: cell
required property var modelData required property var modelData
required property int index 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: { readonly property real ratio: {
const w = Number(modelData.w), h = Number(modelData.h) const w = Number(modelData.w), h = Number(modelData.h)
return (w > 0 && h > 0) ? w / h : 1.5 return (w > 0 && h > 0) ? w / h : 1.5
} }
readonly property int cap: 150 // the tallest a thumbnail gets readonly property int cap: 150
objectName: "imageThumb" + index
width: Math.max(56, Math.min(Math.round(cap * ratio), 320)) objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
height: Math.max(40, Math.min(cap, Math.round(width / ratio))) 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 radius: Theme.radiusSm
color: Theme.bg color: Theme.bg
border.width: 1 border.width: 1
border.color: mouse.containsMouse ? Theme.accentDim : Theme.border border.color: hovered ? Theme.accentDim : Theme.border
clip: true 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 { MouseArea {
id: mouse id: mouse
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
acceptedButtons: Qt.NoButton 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.delay: 400
ToolTip.text: { ToolTip.text: {
const m = thumb.modelData const m = cell.modelData
let s = String(m.name) let s = String(m.name)
if (m.w > 0 && m.h > 0) if (m.w > 0 && m.h > 0)
s += " · " + m.w + "×" + m.h s += " · " + m.w + "×" + m.h
if (m.origin && m.origin !== "user") if (m.mime && m.kind !== "img")
s += " · " + m.origin s += " · " + m.mime
return s 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
}
} }
+17 -2
View File
@@ -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) { function engineToolImages(callId, images) {
if (!images.length) if (!images.length)
return 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) if (i >= 0)
transcript.setProperty(i, "images", images) transcript.setProperty(i, "images", images)
} }
+118 -16
View File
@@ -16,6 +16,7 @@
#include <cstdlib> #include <cstdlib>
#include "ToolProvider.h" /* openLocalFile / shareLocalFile */
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_models.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); 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 /* 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 * WebUI use. Without this the engine would open session.jsonl in the
* process's working directory, next to a session list that stays empty. */ * process's working directory, next to a session list that stays empty. */
@@ -334,28 +351,81 @@ QString field(const char *s) {
return out; 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:
* *
* <stored name>|<name>|<origin>|<w>x<h> * <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
* *
* A list role is not an option: a ListModel keeps what fits in a QVariant of * kind is "img" for a picture (a thumbnail the view can show) or "file" for a
* a simple type -- a list reads back as undefined there, and setProperty drops * file the agent delivered (a card with open and share). A list role is not an
* the role -- so the rows carry this text and ImageStrip.qml decodes it. A * option: a ListModel keeps what fits in a QVariant of a simple type -- a list
* document (PDF, text) is not a picture and stays out of it. */ * reads back as undefined there, and setProperty drops the role -- so the rows
QString imagesText(const cst_attachment_t *list, int n) { * 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; QStringList lines;
for (int i = 0; list && i < n; i++) { for (int i = 0; list && i < n; i++) {
const cst_attachment_t *a = &list[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; continue;
lines << QStringLiteral("%1|%2|%3|%4x%5") QString line = QStringLiteral("%1|%2|%3|%4|%5|%6|%7x%8");
.arg(field(a->rel), field(a->name), field(a->origin)) line = line.arg(img ? QStringLiteral("img") : QStringLiteral("file"));
.arg(a->width) line = line.arg(field(a->rel));
.arg(a->height); 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')); 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 /* 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 * 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 * 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); 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 AgentCore::sessions() const {
QVariantList out; QVariantList out;
if (m_workspace.isEmpty()) if (m_workspace.isEmpty())
@@ -457,14 +559,14 @@ QVariantList AgentCore::history() const {
r["name"] = name; r["name"] = name;
r["callId"] = callId; r["callId"] = callId;
r["output"] = utf8(m->content); r["output"] = utf8(m->content);
r["images"] = imagesText(m->attach, m->nattach); r["images"] = filesText(m->attach, m->nattach);
r["state"] = "done"; r["state"] = "done";
r["complete"] = true; r["complete"] = true;
rows.append(r); rows.append(r);
} else { } else {
QVariantMap r = rows.at(at).toMap(); QVariantMap r = rows.at(at).toMap();
r["output"] = utf8(m->content); r["output"] = utf8(m->content);
r["images"] = imagesText(m->attach, m->nattach); r["images"] = filesText(m->attach, m->nattach);
r["state"] = "done"; r["state"] = "done";
r["complete"] = true; r["complete"] = true;
rows[at] = r; rows[at] = r;
@@ -490,7 +592,7 @@ QVariantList AgentCore::history() const {
if (!names.isEmpty()) if (!names.isEmpty())
text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", ")); text += QStringLiteral("\n\n[attached: %1]").arg(names.join(", "));
r["text"] = text; r["text"] = text;
r["images"] = imagesText(m->attach, m->nattach); r["images"] = filesText(m->attach, m->nattach);
r["complete"] = true; r["complete"] = true;
rows.append(r); rows.append(r);
continue; 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, void AgentCore::hookToolImages(const ctl_tool_call_t *call,
const cst_attachment_t *list, int n, void *ud) { const cst_attachment_t *list, int n, void *ud) {
AgentCore *self = static_cast<AgentCore *>(ud); AgentCore *self = static_cast<AgentCore *>(ud);
const QString images = imagesText(list, n); const QString images = filesText(list, n);
if (images.isEmpty()) if (images.isEmpty())
return; /* a call that made no picture needs no signal */ return; /* a call that made no picture needs no signal */
const QString id = QString::fromUtf8(call && call->id ? call->id : ""); const QString id = QString::fromUtf8(call && call->id ? call->id : "");
+7
View File
@@ -139,6 +139,13 @@ class AgentCore : public QObject {
* image://cstattach/ serves (SessionImages.cpp). "" until the engine is * image://cstattach/ serves (SessionImages.cpp). "" until the engine is
* open. It follows the session. */ * open. It follows the session. */
Q_INVOKABLE QString attachmentDir() const; 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. */ /* 1 the model is shown images, 0 it is not (they go as notes), -1 unknown. */
Q_INVOKABLE int seesImages() const; Q_INVOKABLE int seesImages() const;
+14
View File
@@ -54,6 +54,20 @@ int platformRegisterMac(AgentCore *core);
int platformRegisterWindows(AgentCore *core); int platformRegisterWindows(AgentCore *core);
int platformRegisterAndroid(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 } // namespace app
#endif /* CST_APP_TOOLPROVIDER_H */ #endif /* CST_APP_TOOLPROVIDER_H */
+61 -2
View File
@@ -10,6 +10,7 @@
#include <QCommandLineParser> #include <QCommandLineParser>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDir> #include <QDir>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QGuiApplication> #include <QGuiApplication>
#include <QQmlApplicationEngine> #include <QQmlApplicationEngine>
@@ -20,6 +21,11 @@
#include <QTextStream> #include <QTextStream>
#include <QUrl> #include <QUrl>
#if defined(Q_OS_ANDROID)
#include <QJniObject>
#include <QtCore/private/qandroidextras_p.h>
#endif
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <memory> #include <memory>
@@ -50,13 +56,41 @@ namespace {
int runSelftest() { int runSelftest() {
QTextStream out(stdout); QTextStream out(stdout);
int bad = 0; int bad = 0;
QTemporaryDir tmp; QTemporaryDir tmp, cfg;
if (!tmp.isValid()) { if (!tmp.isValid() || !cfg.isValid()) {
out << "selftest: cannot create a scratch dir\n"; out << "selftest: cannot create a scratch dir\n";
return 1; return 1;
} }
/* The window passes no --api-key, so the key the user saved for the provider
* has to come back from <config>/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; AgentCore core;
QString err; 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"), if (!core.open(tmp.path(), QStringLiteral("deepseek"),
QStringLiteral("deepseek-flash"), QStringLiteral("test-key"), QStringLiteral("deepseek-flash"), QStringLiteral("test-key"),
&err)) { &err)) {
@@ -86,6 +120,18 @@ int runSelftest() {
<< (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why))) << (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why)))
<< "\n"; << "\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 */ /* the mode comes from the engine, not from the UI */
core.setMode(QStringLiteral("general")); core.setMode(QStringLiteral("general"));
if (core.mode() != QLatin1String("general")) { if (core.mode() != QLatin1String("general")) {
@@ -199,6 +245,19 @@ int main(int argc, char **argv) {
return 1; return 1;
} }
app::registerPlatformTools(&core); /* the app's own tools */ 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)) if (parser.isSet(planOpt))
core.setPlanMode(true); core.setPlanMode(true);
+698 -15
View File
@@ -12,32 +12,715 @@
* WHERE THE CODE RUNS: `run_ex` is called on the turn's worker thread, never * 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 * 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 * 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): * The clipboard tools keep the names of the engine's built-ins: those are
* notification_send NotificationChannel + NotificationManager through * gated to CST_PLAT_DESKTOP and the registry lets an app tool of the same
* JNI; hidden until POST_NOTIFICATIONS is granted * name shadow the built-in (src/tools/registry.c: cst_tool_find()), so the
* (Android 13+), which the UI asks for on first use. * model sees one capability either way. */
* clipboard_get/set the phone clipboard through Qt's QClipboard, taking #include <QByteArray>
* the name of the desktop tool that cannot work here #include <QClipboard>
* (see app/README.md: a front end may shadow a built-in #include <QCoreApplication>
* that has no working backend on its host). #include <QDir>
* share ACTION_SEND of a file or text through the system share #include <QFileInfo>
* sheet -- the phone's answer to "show this to the user". #include <QFuture>
* #include <QGuiApplication>
* Nothing is registered yet: the first APK ships the engine, the window and #include <QJniEnvironment>
* the tool contract, and the tools above arrive next (docs/android.md). */ #include <QJniObject>
#include <QMetaObject>
#include <QString> #include <QString>
#include <QStringList>
#include <QThread>
#include <QtCore/private/qandroidextras_p.h>
#include <cstdio>
#include <cstring>
#include <utility>
#include "core/AgentCore.h" #include "core/AgentCore.h"
#include "core/ToolProvider.h" #include "core/ToolProvider.h"
namespace app { 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 <typename Fn>
void onGuiThread(Fn &&fn) {
if (!qApp || QThread::currentThread() == qApp->thread()) {
fn();
return;
}
QMetaObject::invokeMethod(qApp, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection);
}
jint sdkInt() {
static const jint sdk = QJniObject::getStaticField<jint>(
"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<QtAndroidPrivate::PermissionResult> 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<jstring>(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<jstring>());
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<jstring>(),
QJniObject::fromString(QStringLiteral("CST-agent")).object<jstring>(),
static_cast<jint>(3) /* IMPORTANCE_DEFAULT */);
manager.callMethod<void>("createNotificationChannel",
"(Landroid/app/NotificationChannel;)V",
channel.object<jobject>());
builder = QJniObject("android/app/Notification$Builder",
"(Landroid/content/Context;Ljava/lang/String;)V",
ctx.object<jobject>(),
QJniObject::fromString(channelId).object<jstring>());
} else {
builder = QJniObject("android/app/Notification$Builder",
"(Landroid/content/Context;)V", ctx.object<jobject>());
}
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<jint>(
"getIdentifier", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I",
QJniObject::fromString(QString::fromLatin1(name)).object<jstring>(),
QJniObject::fromString(QString::fromLatin1(type)).object<jstring>(),
owner ? QJniObject::fromString(QString::fromLatin1(owner)).object<jstring>()
: pkg.object<jstring>());
};
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<jstring>());
builder.callObjectMethod(
"setContentText", "(Ljava/lang/CharSequence;)Landroid/app/Notification$Builder;",
QJniObject::fromString(message).object<jstring>());
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<jstring>());
if (launch.isValid()) {
/* FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP */
launch.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000 | 0x04000000));
QJniObject pending(
"android/app/PendingIntent",
"(Landroid/content/Context;ILandroid/content/Intent;I)Landroid/app/PendingIntent;",
ctx.object<jobject>(), static_cast<jint>(0), launch.object<jobject>(),
/* FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT */
static_cast<jint>(0x04000000 | 0x08000000));
if (pending.isValid())
builder.callObjectMethod(
"setContentIntent",
"(Landroid/app/PendingIntent;)Landroid/app/Notification$Builder;",
pending.object<jobject>());
}
}
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<void>("notify", "(ILandroid/app/Notification;)V",
static_cast<jint>(1), note.object<jobject>());
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<int>(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 <package>.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<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("text/plain"))
.object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
if (!subject.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.SUBJECT"))
.object<jstring>(),
QJniObject::fromString(subject).object<jstring>());
QJniObject chooser("android/content/Intent", "(Ljava/lang/String;)V",
QJniObject::fromString(QStringLiteral("android.intent.action.CHOOSER"))
.object<jstring>());
/* 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<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000) /* NEW_TASK */);
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
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 <package>.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<jstring>());
QJniObject uri = QJniObject::callStaticObjectMethod(
"androidx/core/content/FileProvider", "getUriForFile",
"(Landroid/content/Context;Ljava/lang/String;Ljava/io/File;)Landroid/net/Uri;",
ctx.object<jobject>(),
QJniObject::fromString(authority).object<jstring>(),
file.object<jobject>());
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<jstring>());
intent.callObjectMethod("setType",
"(Ljava/lang/String;)Landroid/content/Intent;",
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Landroid/os/Parcelable;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.STREAM"))
.object<jstring>(),
uri.object<jobject>());
if (!text.isEmpty())
intent.callObjectMethod(
"putExtra",
"(Ljava/lang/String;Ljava/lang/CharSequence;)Landroid/content/Intent;",
QJniObject::fromString(QStringLiteral("android.intent.extra.TEXT"))
.object<jstring>(),
QJniObject::fromString(text).object<jstring>());
intent.callObjectMethod(
"addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(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<jobject>(),
QJniObject::fromString(QStringLiteral("Share with")).object<jstring>());
QJniObject toStart = sheet.isValid() ? sheet : intent;
toStart.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(0x10000000));
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
toStart.object<jobject>());
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<jstring>());
intent.callObjectMethod(
"setDataAndType",
"(Landroid/net/Uri;Ljava/lang/String;)Landroid/content/Intent;",
uri.object<jobject>(),
QJniObject::fromString(mimeOf(path)).object<jstring>());
intent.callObjectMethod("addFlags", "(I)Landroid/content/Intent;",
static_cast<jint>(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<jobject>(),
static_cast<jint>(0x00010000) /* MATCH_DEFAULT_ONLY */)
: QJniObject();
if (!found.isValid()) {
err = QStringLiteral("no app on the phone opens %1").arg(name);
return;
}
ctx.callMethod<void>("startActivity", "(Landroid/content/Intent;)V",
intent.object<jobject>());
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) { int platformRegisterAndroid(AgentCore *core) {
if (!core) if (!core)
return 0; 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 } // namespace app
+41
View File
@@ -3,10 +3,14 @@
* the capability -- platforms + probe decide whether the tool is offered. */ * the capability -- platforms + probe decide whether the tool is offered. */
#include <QByteArray> #include <QByteArray>
#include <QClipboard>
#include <QDesktopServices>
#include <QFileInfo> #include <QFileInfo>
#include <QGuiApplication>
#include <QProcess> #include <QProcess>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QUrl>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
@@ -86,6 +90,43 @@ cst_tool_def_t g_notify;
} // namespace } // 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) { int platformRegisterLinux(AgentCore *core) {
static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = {
{"message", "string", 1, "text of the notification"}, {"message", "string", 1, "text of the notification"},
+140 -12
View File
@@ -45,16 +45,18 @@ void check(bool ok, const QString &what) {
g_fail++; g_fail++;
} }
QQuickItem *byName(QQuickItem *root, const QString &name) { /* Every item with this objectName in the visual tree. A view that has just
* been replayed can still hold the delegates it is about to drop, so a name
* can match more than one item for a moment. */
void collectNamed(QQuickItem *root, const QString &name,
QList<QQuickItem *> *out) {
if (!root) if (!root)
return nullptr; return;
if (root->objectName() == name) if (root->objectName() == name)
return root; out->append(root);
const QList<QQuickItem *> kids = root->childItems(); const QList<QQuickItem *> kids = root->childItems();
for (QQuickItem *c : kids) for (QQuickItem *c : kids)
if (QQuickItem *hit = byName(c, name)) collectNamed(c, name, out);
return hit;
return nullptr;
} }
int countNamed(QQuickItem *root, const QString &prefix) { 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 * 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. */ * visual tree -- what a finger can actually reach -- is walked as well. */
QQuickItem *item(QQuickWindow *win, const QString &name) { QQuickItem *item(QQuickWindow *win, const QString &name) {
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), name, &hits);
/* What a finger can reach is the one the test means: a delegate on its way
* out is still in the tree but invisible. */
for (QQuickItem *it : hits)
if (it->isVisible())
return it;
if (QQuickItem *it = win->findChild<QQuickItem *>(name)) if (QQuickItem *it = win->findChild<QQuickItem *>(name))
return it; return it;
return byName(win->contentItem(), name); return hits.isEmpty() ? nullptr : hits.first();
} }
/* The centre of an item, in window coordinates. */ /* 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_call_id, sizeof(m.tool_call_id), "%s", "call_1");
snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file"); snprintf(m.tool_name, sizeof(m.tool_name), "%s", "read_file");
cst_session_append(s, &m); 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)); memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT; m.role = CST_ROLE_ASSISTANT;
m.reasoning = strdup("checked the file"); 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 * 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, * transcript already in the engine. Put one there before the QML loads,
* which is exactly what a restart looks like. */ * 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 = const QString resumed =
writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on")); writeFixtureSession(core.workspace(), QStringLiteral("20260101-115959-on"));
check(!resumed.isEmpty() && core.switchSession(resumed), check(!resumed.isEmpty() && core.switchSession(resumed),
@@ -232,7 +271,7 @@ int runUiTest(AgentCore &core, const QString &size) {
{ {
QQuickItem *tr = item(win, QStringLiteral("transcript")); QQuickItem *tr = item(win, QStringLiteral("transcript"));
const int rows = tr ? tr->property("count").toInt() : -1; 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)") QStringLiteral("the open session is on screen at startup (%1 rows)")
.arg(rows)); .arg(rows));
} }
@@ -304,11 +343,12 @@ int runUiTest(AgentCore &core, const QString &size) {
check(core.sessions().size() >= 2, check(core.sessions().size() >= 2,
QStringLiteral("the store lists it next to the open one")); 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); clickMouse(win, QStringLiteral("sessionRow:") + fixture);
check(core.sessionId() == fixture, check(core.sessionId() == fixture,
QStringLiteral("a click on a row switches the engine")); 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")); QStringLiteral("the reopened transcript is replayed into the view"));
{ {
bool toolOk = false; 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")); check(toolOk, QStringLiteral("a tool result is paired with its call"));
} }
/* --- a delivered file: a card with open and share --------------------- */
{
/* The view is re-laid out off the click; give it the frame it needs. */
QTest::qWait(150);
const int cards =
countNamed(win->contentItem(), QStringLiteral("fileCard"));
check(cards == 1,
QStringLiteral("a delivered file is a card in the tool row (%1)")
.arg(cards));
check(item(win, QStringLiteral("fileOpen0")) != nullptr &&
item(win, QStringLiteral("fileShare0")) != nullptr,
QStringLiteral("the card carries open and share"));
win->setProperty("statusText", QString());
clickMouse(win, QStringLiteral("fileOpen0"));
const QString opened = win->property("statusText").toString();
check(opened.startsWith(QLatin1String("opened ")),
QStringLiteral("Open hands the file to the system (%1)").arg(opened));
clickMouse(win, QStringLiteral("fileShare0"));
const QString shared = win->property("statusText").toString();
check(shared.contains(QLatin1String("report.md")),
QStringLiteral("Share names the file (%1)").arg(shared));
const QString refused =
core.openArtifact(QStringLiteral("art-not-one-of-ours.md"));
check(refused.contains(QLatin1String("no such file")),
QStringLiteral("a name this session does not own is refused (%1)")
.arg(refused));
/* And the card says it: a tap that cannot work must not look dead. */
QQuickItem *strip = nullptr;
{
QList<QQuickItem *> hits;
collectNamed(win->contentItem(), QStringLiteral("imageStrip"), &hits);
for (QQuickItem *it : hits)
if (it->isVisible())
strip = it;
}
bool called = false;
if (strip)
called = QMetaObject::invokeMethod(
strip, "openFile",
Q_ARG(QVariant, QStringLiteral("art-not-one-of-ours.md")));
const QVariant err = strip ? strip->property("errorText") : QVariant();
check(called && err.toString().contains(QLatin1String("no such file")),
QStringLiteral("the card shows why it could not open it (%1)")
.arg(err.toString()));
QTest::qWait(150);
QQuickItem *errText = item(win, QStringLiteral("fileError"));
check(errText && errText->isVisible() &&
errText->property("text").toString().contains(
QLatin1String("no such file")),
QStringLiteral("and the line is where the card is"));
}
/* new sessions: the engine moves, the view empties, the store grows */ /* new sessions: the engine moves, the view empties, the store grows */
const int before = core.sessions().size(); const int before = core.sessions().size();
clickMouse(win, QStringLiteral("newSessionButton")); clickMouse(win, QStringLiteral("newSessionButton"));
@@ -345,10 +437,11 @@ int runUiTest(AgentCore &core, const QString &size) {
check(core.sessionId() == second, check(core.sessionId() == second,
QStringLiteral("deleting another session leaves the open one alone")); 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); QTest::qWait(150);
clickMouse(win, QStringLiteral("sessionRow:") + fixture); 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")); QStringLiteral("switching back replays it again"));
{ {
bool promptImage = false; bool promptImage = false;
@@ -703,6 +796,41 @@ int runUiTest(AgentCore &core, const QString &size) {
maybeShot(win, QStringLiteral("-toolimage")); 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 --- */ /* --- a narrow window: dock -> drawer, and the button opens it --- */
resizeTo(win, 460, 900); resizeTo(win, 460, 900);
check(docked && !docked->isVisible(), check(docked && !docked->isVisible(),
+24 -7
View File
@@ -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 * 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 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 `/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 request from inside the ART process. The saved provider key is resolved into
401 -- the engine, curl and TLS parts of the round trip are what that the engine's config on every start (`AgentCore::open()`: saved key, the
shows). Rotation keeps the session; the sidebar appears in landscape. provider's environment variable, `CST_API_KEY`), so the phone answers without
* Still to come: the Android platform tools behind `app:android` being asked for the key again. Rotation keeps the session; the sidebar
(`notification_send`, clipboard, share sheet, SAF file picker), the back appears in landscape.
gesture, and the icon glyphs -- `ThemeIcon` finds no icon files on the phone * Platform tools behind `app:android` (`app/src/platform/android/AndroidTools.cpp`):
and draws empty boxes where the desktop shows Breeze icons. `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
(`<package>.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.
+4
View File
@@ -61,6 +61,10 @@ typedef struct {
int pages; /* PDF page count, 0 for anything else */ int pages; /* PDF page count, 0 for anything else */
char detail[8]; /* auto | low | high (wire hint, "" = auto) */ char detail[8]; /* auto | low | high (wire hint, "" = auto) */
char origin[32]; /* user | view_image | mcp (for display only) */ 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; } cst_attachment_t;
typedef struct { typedef struct {
+12
View File
@@ -23,6 +23,10 @@ extern "C" {
#define CST_ATTACH_MAX_COUNT 8 /* per message */ #define CST_ATTACH_MAX_COUNT 8 /* per message */
/* Text stored and inlined per document (a PDF also caps its text here). */ /* Text stored and inlined per document (a PDF also caps its text here). */
#define CST_ATTACH_TEXT_MAX (4 * 1024 * 1024) #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. */ /* Images go on the wire as image parts; everything else is inlined as text. */
int cst_attach_is_image(const cst_attachment_t *a); 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, cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data, const char *name, const char *data,
size_t n, char *err, size_t err_sz); 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. */ /* 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, char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 9 #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) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+150 -12
View File
@@ -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 /* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */ * inside one session. The prefix says what the name is for: "img" for what a
static void make_id(const char *name, const char *data, size_t n, char *out, * model is shown, "art" for what a person is handed. */
size_t out_sz) { 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); uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name)); h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n)); h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms(); double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now)); h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int)); 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. */ /* 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; 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, static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *mime, const char *data, size_t n, const char *mime,
int pages, const char *origin, char *err, int pages, const char *origin, int for_user,
size_t err_sz) { const char *id_prefix, const char *ext_override,
char *err, size_t err_sz) {
if (!name || !name[0]) if (!name || !name[0])
name = "image"; name = "image";
char dir[OS_PATH_MAX]; 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"); snprintf(err, err_sz, "out of memory");
return NULL; return NULL;
} }
make_id(name, data, n, a->id, sizeof(a->id)); make_id(id_prefix, name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime)); 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->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime); snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user"); snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto"); snprintf(a->detail, sizeof(a->detail), "auto");
a->for_user = for_user ? 1 : 0;
a->bytes = (long long)n; a->bytes = (long long)n;
a->pages = pages; a->pages = pages;
if (cst_attach_is_image(a)) 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)); (double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL; 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, 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) if (!payload)
return NULL; return NULL;
cst_attachment_t *a = 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); free(payload);
return a; 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 /* Images are opaque and travel as image parts; everything else is text the
* store holds and the request inlines. */ * store holds and the request inlines. */
int cst_attach_is_image(const cst_attachment_t *a) { 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) { 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, 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); cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0]) if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin); cJSON_AddStringToObject(o, "origin", a->origin);
if (a->for_user)
cJSON_AddNumberToObject(o, "for_user", 1);
cJSON_AddItemToArray(arr, o); cJSON_AddItemToArray(arr, o);
} }
char *s = cJSON_PrintUnformatted(arr); 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"); v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s", snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : ""); cJSON_IsString(v) ? v->valuestring : "");
v = cJSON_GetObjectItemCaseSensitive(it, "for_user");
a->for_user = cJSON_IsNumber(v) ? v->valueint : 0;
k++; k++;
} }
if (k == 0) { if (k == 0) {
+22 -6
View File
@@ -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 /* Append every attachment of m: images as image parts (or a note saying why
* not), documents inline as text. */ * not), documents inline as text. Returns how many parts were added: a
static void parts_add_attachments(const cst_llm_request_t *req, cJSON *parts, * delivered file (for_user) adds none. */
const cst_message_t *m, size_t *budget, static int parts_add_attachments(const cst_llm_request_t *req, cJSON *parts,
size_t *doc_budget, const char *img_off) { const cst_message_t *m, size_t *budget,
size_t *doc_budget, const char *img_off) {
int added = 0;
int n = m->nattach; int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) { if (n > CST_ATTACH_MAX_COUNT) {
char note[160]; 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]", "[only the first %d of %d attachments are sent]",
CST_ATTACH_MAX_COUNT, n); CST_ATTACH_MAX_COUNT, n);
parts_add_text(parts, cJSON_CreateString(note)); parts_add_text(parts, cJSON_CreateString(note));
added++;
n = CST_ATTACH_MAX_COUNT; n = CST_ATTACH_MAX_COUNT;
} }
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[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); int is_img = cst_attach_is_image(a);
char why[256]; char why[256];
cJSON *p = NULL; 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)); p = document_part(req, a, doc_budget, why, sizeof(why));
if (p) { if (p) {
cJSON_AddItemToArray(parts, p); cJSON_AddItemToArray(parts, p);
added++;
continue; continue;
} }
char note[512]; char note[512];
snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]", snprintf(note, sizeof(note), "[%s \"%.120s\" not sent: %.200s]",
is_img ? "image" : "document", a->name, why); is_img ? "image" : "document", a->name, why);
parts_add_text(parts, cJSON_CreateString(note)); parts_add_text(parts, cJSON_CreateString(note));
added++;
} }
return added;
} }
/* Images a tool result produced are deferred: an assistant tool_call must be /* 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"); cJSON_AddArrayToObject(p, "content");
*pending = p; *pending = p;
} }
parts_add_attachments(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m, cJSON *content = cJSON_GetObjectItemCaseSensitive(p, "content");
budget, doc_budget, img_off); 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 /* m's text plus one line per image it would have carried (malloc'd); a
+11 -19
View File
@@ -280,26 +280,18 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
cJSON_Delete(mo); cJSON_Delete(mo);
} }
if (msg->attach && msg->nattach > 0) { if (msg->attach && msg->nattach > 0) {
cJSON *arr = cJSON_CreateArray(); /* One writer for every attachment (cst_attachments_to_json): a field it
for (int i = 0; i < msg->nattach; i++) { * carries must not have to be remembered here too -- forgetting
const cst_attachment_t *a = &msg->attach[i]; * `for_user` would put a delivered file back in front of the model. */
cJSON *it = cJSON_CreateObject(); char *json = cst_attachments_to_json(msg->attach, msg->nattach);
cJSON_AddStringToObject(it, "id", a->id); if (json) {
cJSON_AddStringToObject(it, "name", a->name); cJSON *arr = cJSON_Parse(json);
cJSON_AddStringToObject(it, "mime", a->mime); free(json);
cJSON_AddStringToObject(it, "rel", a->rel); if (cJSON_IsArray(arr))
cJSON_AddNumberToObject(it, "bytes", (double)a->bytes); cJSON_AddItemToObject(o, "attachments", arr);
if (a->width > 0 && a->height > 0) { else
cJSON_AddNumberToObject(it, "width", a->width); cJSON_Delete(arr);
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);
} }
cJSON_AddItemToObject(o, "attachments", arr);
} }
char *txt = cJSON_PrintUnformatted(o); char *txt = cJSON_PrintUnformatted(o);
cJSON_Delete(o); cJSON_Delete(o);
+50
View File
@@ -291,6 +291,56 @@ int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return rc; 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 ----------------------------------------------------------- */ /* --- edit_file ----------------------------------------------------------- */
static size_t count_occ(const char *hay, size_t hn, const char *needle, static size_t count_occ(const char *hay, size_t hn, const char *needle,
+11
View File
@@ -67,6 +67,17 @@ static const cst_tool_def_t TOOLS[] = {
{{"path", S, 1, "image file path relative to the workspace"}, {{"path", S, 1, "image file path relative to the workspace"},
{"detail", S, 0, "low or high (provider hint; default auto)"}}, {"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN, NO_META}, 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_shell",
"Run a non-interactive shell command in the workspace root: builds, " "Run a non-interactive shell command in the workspace root: builds, "
"tests, git, scripts, computations, data processing, other CLIs " "tests, git, scripts, computations, data processing, other CLIs "
+2
View File
@@ -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); cst_tool_result_t *r);
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); 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, int cst_preview_write_file(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out); const ctl_tool_call_t *c, char **out);
+414
View File
@@ -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 <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int nfail;
static char g_ws[1024];
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static void wfile(const char *rel, const void *data, size_t n) {
char p[1200];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *f = fopen(p, "wb");
if (!f) {
fprintf(stderr, "cannot write %s\n", p);
exit(1);
}
if (n && fwrite(data, 1, n, f) != n) {
fprintf(stderr, "short write %s\n", p);
exit(1);
}
fclose(f);
}
static void full_path(char *out, size_t n, const char *rel) {
snprintf(out, n, "%s/%s", g_ws, rel);
}
/* name, then key/value pairs, NULL-terminated (the ctl_tool_call_t a turn
* builds from a model's call). */
static ctl_tool_call_t mk(const char *name, ...) {
ctl_tool_call_t c;
memset(&c, 0, sizeof(c));
snprintf(c.id, sizeof(c.id), "t");
snprintf(c.name, sizeof(c.name), "%s", name);
va_list ap;
va_start(ap, name);
const char *k;
while ((k = va_arg(ap, const char *)) != NULL) {
const char *v = va_arg(ap, const char *);
ctl_arg_t *a = &c.args[c.nargs++];
snprintf(a->key, sizeof(a->key), "%s", k);
a->value = (char *)v;
a->value_len = strlen(v);
}
va_end(ap);
return c;
}
static void test_registry(void) {
const cst_tool_def_t *t = cst_tool_find("deliver_file");
EXPECT(t != NULL, "deliver_file is in the registry");
if (!t)
return;
char why[160] = "";
EXPECT(cst_tool_available(t, why, sizeof(why)) == 0,
"deliver_file is available here");
/* It copies out of the workspace and never writes it: a read. */
EXPECT(strcmp(cst_tool_perm_name(t), "read") == 0,
"deliver_file is a read-policy tool");
EXPECT(t->owner == NULL, "deliver_file is an engine tool");
}
static void test_import(const char *session) {
const char *md = "# Report\n\n- one\n- two\n";
wfile("out/report.md", md, strlen(md));
char src[1200];
full_path(src, sizeof(src), "out/report.md");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "a markdown file is delivered");
if (!a) {
fprintf(stderr, " (%s)\n", err);
return;
}
EXPECT(strcmp(a->mime, "text/markdown") == 0, "mime comes from the name");
EXPECT(strcmp(a->name, "report.md") == 0, "display name is the file name");
EXPECT(a->for_user == 1, "marked for the user");
EXPECT(!cst_attach_is_text(a), "a delivered file is not inlined as text");
EXPECT(!cst_attach_is_image(a), "and it is not an image part either");
EXPECT(strncmp(a->rel, "art-", 4) == 0, "stored under an artifact id");
size_t rl = strlen(a->rel);
EXPECT(rl > 4 && strcmp(a->rel + rl - 3, ".md") == 0,
"the stored copy keeps the extension");
EXPECT(a->bytes == (long long)strlen(md), "size is the file's size");
size_t n = 0;
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
EXPECT(back && n == strlen(md) && memcmp(back, md, n) == 0,
"the payload is byte for byte");
free(back);
/* The copy lives beside the session, not in the workspace. */
char stored[1200];
EXPECT(cst_attach_full_path(session, a, stored, sizeof(stored)) == 0,
"stored path resolves");
os_stat_t st;
EXPECT(os_lstat(stored, &st) == 0 && st.size == (long long)strlen(md),
"the stored file is on disk");
/* A title wins over the file name, and its extension decides the type. */
cst_attachment_t *b = cst_attach_import_artifact(session, src, "summary.html",
err, sizeof(err));
EXPECT(b && strcmp(b->name, "summary.html") == 0, "title is the display name");
EXPECT(b && strcmp(b->mime, "text/html") == 0, "the title's extension decides");
cst_attach_free(b);
cst_attach_free(a);
}
static void test_any_bytes(const char *session) {
/* A zip is bytes, not text: an attachment would refuse it (the content
* decides), a delivered file is copied as it is. */
char zip[512];
for (size_t i = 0; i < sizeof(zip); i++)
zip[i] = (char)(i % 251);
memcpy(zip, "PK\003\004", 4);
wfile("out/build.zip", zip, sizeof(zip));
char src[1200];
full_path(src, sizeof(src), "out/build.zip");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "a binary file is delivered");
EXPECT(a && strcmp(a->mime, "application/zip") == 0, "zip mime");
if (a) {
size_t n = 0;
char *back = cst_attach_read(session, a, &n, err, sizeof(err));
EXPECT(back && n == sizeof(zip) && memcmp(back, zip, n) == 0,
"binary payload survives");
free(back);
cst_attach_free(a);
}
/* The same file through the attachment path: refused, as before. */
cst_attachment_t *as_attach =
cst_attach_import_file(session, src, err, sizeof(err));
EXPECT(as_attach == NULL, "an attachment still refuses binary garbage");
cst_attach_free(as_attach);
/* Bigger than an attachment may be (16 MB) but inside the artifact cap: a
* report is allowed to be large. */
char big[1200];
full_path(big, sizeof(big), "out/big.log");
FILE *f = fopen(big, "wb");
if (f) {
char line[64];
memset(line, 'x', sizeof(line) - 1);
line[sizeof(line) - 1] = '\n';
/* 17 MB: past the 16 MB attachment cap, well inside the artifact one. */
for (int i = 0; i < 17 * 1024 * 1024 / (int)sizeof(line); i++)
fwrite(line, 1, sizeof(line), f);
fclose(f);
}
os_stat_t st;
EXPECT(os_lstat(big, &st) == 0 && st.size > CST_ATTACH_MAX_BYTES,
"the scratch file is larger than the attachment cap");
cst_attachment_t *large =
cst_attach_import_artifact(session, big, NULL, err, sizeof(err));
EXPECT(large != NULL, "a file past the attachment cap is delivered");
EXPECT(large && large->bytes == (long long)st.size, "all of it is stored");
EXPECT(large && strcmp(large->mime, "text/plain") == 0, "log mime");
cst_attach_free(large);
cst_attachment_t *small =
cst_attach_import_file(session, big, err, sizeof(err));
EXPECT(small == NULL, "the attachment path still caps at 16 MB");
cst_attach_free(small);
}
static void test_json(const char *session) {
char src[1200];
full_path(src, sizeof(src), "out/report.md");
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(session, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "artifact for the json round trip");
if (!a)
return;
char *json = cst_attachments_to_json(a, 1);
EXPECT(json && strstr(json, "\"for_user\":1") != NULL,
"the JSONL says it is the user's");
cJSON *parsed = json ? cJSON_Parse(json) : NULL;
int n = 0;
cst_attachment_t *back = parsed ? cst_attachments_from_json(parsed, &n) : NULL;
EXPECT(back && n == 1, "one entry parses back");
if (back) {
EXPECT(back[0].for_user == 1, "for_user survives the JSONL");
EXPECT(strcmp(back[0].mime, "text/markdown") == 0, "mime survives");
EXPECT(strcmp(back[0].rel, a->rel) == 0, "stored name survives");
cst_attachments_free(back, n);
}
cJSON_Delete(parsed);
free(json);
cst_attach_free(a);
}
/* The session file is what a replayed transcript is read from: a delivered
* file that loses `for_user` on the way through it would be handed back to the
* model (and never shown as a card) on the next start. */
static void test_session_file(void) {
char src[1200], path[1200];
full_path(src, sizeof(src), "out/report.md");
snprintf(path, sizeof(path), "%s/delivered.jsonl", g_ws);
char err[256] = "";
cst_attachment_t *a =
cst_attach_import_artifact(path, src, NULL, err, sizeof(err));
EXPECT(a != NULL, "the file is stored for a session message");
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "the session opens");
if (!s) {
cst_attach_free(a);
return;
}
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_TOOL;
m.content = (char *)"delivered report.md";
snprintf(m.tool_call_id, sizeof(m.tool_call_id), "c1");
snprintf(m.tool_name, sizeof(m.tool_name), "deliver_file");
m.attach = a;
m.nattach = a ? 1 : 0;
EXPECT(cst_session_append(s, &m) == 0, "the tool result is appended");
cst_session_close(s);
cst_attach_free(a);
/* Read it back the way cst_agent_open() does. */
s = cst_session_open(path);
EXPECT(s && cst_session_count(s) == 1, "the message is in the file");
const cst_message_t *back = s ? cst_session_get(s, 0) : NULL;
EXPECT(back && back->nattach == 1, "its attachment comes back");
EXPECT(back && back->attach && back->attach[0].for_user == 1,
"and is still the user's, not the model's");
EXPECT(back && back->attach && !cst_attach_is_text(&back->attach[0]),
"so it is never inlined");
EXPECT(back && back->attach && !cst_attach_is_image(&back->attach[0]),
"and it is not an image part either");
cst_session_close(s);
}
/* The tool through the registry, the way a turn calls it. */
static void test_tool(const char *session) {
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", g_ws);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s", session);
cst_agent_t *ag = cst_agent_create(&cfg);
EXPECT(ag != NULL, "agent for the tool call");
if (!ag)
return;
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = g_ws;
ctx.agent = ag;
cst_tool_result_t r;
memset(&r, 0, sizeof(r));
ctl_tool_call_t c = mk("deliver_file", "path", "out/report.md", NULL);
cst_tool_run(&ctx, &c, &r); /* by name, through the registry */
EXPECT(r.exit_code == 0, "deliver_file runs");
EXPECT(r.out && strstr(r.out, "delivered report.md") != NULL,
"the result names the file");
EXPECT(r.nattach == 1 && r.attach && r.attach[0].for_user == 1,
"the tool hands the file to the front end");
EXPECT(r.attach && !cst_attach_is_text(&r.attach[0]),
"and never to a request");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", "path", "out", NULL); /* a directory */
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out && strstr(r.out, "is a directory") != NULL,
"a directory is refused");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", "path", "../outside.txt", NULL);
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out &&
strstr(r.out, "outside workspace") != NULL,
"a path outside the workspace is refused");
cst_tool_result_free(&r);
memset(&r, 0, sizeof(r));
c = mk("deliver_file", NULL);
cst_tool_run(&ctx, &c, &r);
EXPECT(r.exit_code == 1 && r.out && strstr(r.out, "path") != NULL,
"a missing path is refused");
cst_tool_result_free(&r);
cst_agent_destroy(ag);
}
/* What actually goes out: the delivered file is not in the body, a document
* attachment on the same transcript still is. */
static void test_wire(const char *session) {
char md_src[1200], txt_src[1200];
full_path(md_src, sizeof(md_src), "out/report.md");
const char *note = "the build log lives in out/build.log\n";
wfile("notes.txt", note, strlen(note));
full_path(txt_src, sizeof(txt_src), "notes.txt");
char err[256] = "";
cst_attachment_t *art =
cst_attach_import_artifact(session, md_src, NULL, err, sizeof(err));
cst_attachment_t *doc =
cst_attach_import_file(session, txt_src, err, sizeof(err));
EXPECT(art && doc, "both a delivered file and a document");
cst_message_t msgs[4];
memset(msgs, 0, sizeof(msgs));
msgs[0].role = CST_ROLE_USER;
msgs[0].content = (char *)"what did you build?";
msgs[1].role = CST_ROLE_ASSISTANT;
msgs[1].tool_calls_json = (char *)"[{\"id\":\"c1\",\"type\":\"function\","
"\"function\":{\"name\":\"deliver_file\","
"\"arguments\":\"{}\"}}]";
/* Two tool results of one turn: the first delivered a file for the person,
* the second came back with a document the model is meant to read. The
* delivered file must add nothing (not even a "not sent" note); the
* document must still be inlined. */
msgs[2].role = CST_ROLE_TOOL;
msgs[2].content = (char *)"the finished report is stored for the user";
snprintf(msgs[2].tool_call_id, sizeof(msgs[2].tool_call_id), "c1");
snprintf(msgs[2].tool_name, sizeof(msgs[2].tool_name), "deliver_file");
msgs[2].attach = art;
msgs[2].nattach = art ? 1 : 0;
msgs[3].role = CST_ROLE_TOOL;
msgs[3].content = (char *)"read the build log";
snprintf(msgs[3].tool_call_id, sizeof(msgs[3].tool_call_id), "c2");
snprintf(msgs[3].tool_name, sizeof(msgs[3].tool_name), "read_file");
msgs[3].attach = doc;
msgs[3].nattach = doc ? 1 : 0;
cst_llm_request_t req;
memset(&req, 0, sizeof(req));
req.model = "test-model";
req.msgs = msgs;
req.nmsgs = 4;
req.session_path = session; /* a document is read back from the store */
char *body = cst_llm_build_body(&req);
EXPECT(body != NULL, "body built");
if (body) {
EXPECT(art && strstr(body, art->rel) == NULL,
"the delivered file's stored name is not in the request");
EXPECT(strstr(body, "for_user") == NULL,
"and nothing marks it as an attachment either");
EXPECT(strstr(body, "the build log lives in") != NULL,
"a document attachment still travels as text");
cJSON *j = cJSON_Parse(body);
const cJSON *m = j ? cJSON_GetObjectItemCaseSensitive(j, "messages") : NULL;
EXPECT(m && cJSON_GetArraySize(m) == 5,
"four messages plus the one deferred document message");
cJSON_Delete(j);
free(body);
}
cst_attachments_free(art, art ? 1 : 0);
cst_attachments_free(doc, doc ? 1 : 0);
}
int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-artifacts") != 0) {
perror("tmpdir");
return 1;
}
char mk1[1200];
snprintf(mk1, sizeof(mk1), "%s/out", g_ws);
test_mkdir(mk1, 0755);
char session[1200];
snprintf(session, sizeof(session), "%s/s.jsonl", g_ws);
test_registry();
test_import(session);
test_any_bytes(session);
test_json(session);
test_session_file();
test_tool(session);
test_wire(session);
test_rmrf(g_ws);
if (nfail) {
fprintf(stderr, "%d check(s) failed\n", nfail);
return 1;
}
printf("test_artifacts: OK\n");
return 0;
}