From ba21d988f9f87a8e7835fee89ca1f9548d660284 Mon Sep 17 00:00:00 2001 From: yi-ge Date: Thu, 15 Oct 2026 22:23:25 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=A1=A5=E9=BD=90=20Run=20=E8=BD=AC?= =?UTF-8?q?=E4=BA=A4=E3=80=81=E4=BC=9A=E8=AF=9D=E5=8E=86=E5=8F=B2=E4=B8=8E?= =?UTF-8?q?=E9=99=84=E4=BB=B6=E5=AA=92=E4=BD=93=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/README.md | 16 +- api/endpoint-catalog.json | 510 +- api/examples/agent-client/README.md | 32 +- api/examples/agent-client/attachments.mjs | 125 + api/examples/agent-client/client.mjs | 73 +- api/examples/agent-client/history.mjs | 194 + .../agent-client/test/attachments.test.mjs | 128 + .../agent-client/test/client.test.mjs | 69 +- .../agent-client/test/history.test.mjs | 179 + .../test/source-history.fixture.ts | 122 + .../agent-client/test/source-route.fixture.ts | 76 +- api/history-attachments-verification.md | 68 + api/verification.md | 6 + docs/07-api/01-connection-and-auth.md | 2 + docs/07-api/04-conversations-and-runs.md | 33 +- docs/07-api/07-openapi.md | 18 +- .../07-api/08-session-history-and-messages.md | 290 + docs/07-api/09-attachments-and-media.md | 225 + docs/07-api/index.md | 2 + .../current/07-api/01-connection-and-auth.md | 2 + .../07-api/04-conversations-and-runs.md | 33 +- .../current/07-api/07-openapi.md | 18 +- .../07-api/08-session-history-and-messages.md | 290 + .../07-api/09-attachments-and-media.md | 225 + .../current/07-api/index.md | 2 + scripts/check-api-docs.mjs | 26 +- static/api-examples/agent-client.mjs | 73 +- static/api-examples/attachments.mjs | 125 + static/api-examples/history.mjs | 194 + static/openapi/agent-service.json | 7322 +++++++++++------ tsconfig.json | 2 +- 31 files changed, 7896 insertions(+), 2584 deletions(-) create mode 100644 api/examples/agent-client/attachments.mjs create mode 100644 api/examples/agent-client/history.mjs create mode 100644 api/examples/agent-client/test/attachments.test.mjs create mode 100644 api/examples/agent-client/test/history.test.mjs create mode 100644 api/examples/agent-client/test/source-history.fixture.ts create mode 100644 api/history-attachments-verification.md create mode 100644 docs/07-api/08-session-history-and-messages.md create mode 100644 docs/07-api/09-attachments-and-media.md create mode 100644 i18n/en/docusaurus-plugin-content-docs/current/07-api/08-session-history-and-messages.md create mode 100644 i18n/en/docusaurus-plugin-content-docs/current/07-api/09-attachments-and-media.md create mode 100644 static/api-examples/attachments.mjs create mode 100644 static/api-examples/history.mjs diff --git a/api/README.md b/api/README.md index d58b442..7e2a08a 100644 --- a/api/README.md +++ b/api/README.md @@ -4,13 +4,13 @@ ## 公开范围 -8 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、模型代理、工作流与 OpenAPI 下载。24 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。 +10 组中英文页面覆盖接入认证、Agent Webhook 管理与触发、只读 Agent/Run/回执、HTTP + Socket.IO 对话、会话历史与消息、附件与媒体、模型代理、工作流与 OpenAPI 下载。35 个 HTTP 操作按服务分成三个自包含 OpenAPI 3.1 文件;对话事件和恢复规则维护在指南与可运行 Node.js 示例中。 Webhook 所有者管理依赖握手 `capabilities.webhookManagement: true`,由可信入口选择实际工具并生成文件范围快照;旧服务仅支持已有事件。该支持条件没有被改写成未经核对的客户端发行号。现有 Write/Bash 与审批模型保留,不把配置文件宣称为不可修改的安全边界。 -默认 Caddy 未代理 `/health`、Workflow 路径或本机 Compat Proxy。三种服务的地址、认证和权限分别维护。Workflow 只公开管理员预置定义的触发与观察,不伪造直接运行所需 grant 的公开签发流程。 +默认 Caddy 未代理 `/health`、Workflow 路径或本机 Compat Proxy;附件和历史使用 `/api/*`,签名媒体读取使用单独代理的 `/media/*`。三种服务的地址、认证和权限分别维护。Workflow 只公开管理员预置定义的触发与观察,不伪造直接运行所需 grant 的公开签发流程。 -独立示例包在 [examples/agent-client](./examples/agent-client/README.md),静态下载副本为 `static/api-examples/agent-client.mjs`;二者必须逐字一致。依赖和锁文件在示例目录维护,不改变站点依赖。 +独立示例包在 [examples/agent-client](./examples/agent-client/README.md)。`client.mjs`、`history.mjs` 和 `attachments.mjs` 分别对应 `static/api-examples/agent-client.mjs`、`history.mjs` 和 `attachments.mjs`,每一对必须逐字一致。依赖和锁文件在示例目录维护,不改变站点依赖。 ## 修改步骤 @@ -45,7 +45,7 @@ v5 只对 `/more/changelog/v10.0.180` 和 `/en/more/changelog/v10.0.180` 做定 1. 先确认生产页面、canonical、robots、双语 sitemap 与正文正常。API 页应有可直接读取的 `.theme-doc-markdown`、标题及 `docsearch:language`、`docsearch:version`、`docsearch:docusaurus_tag` 元数据。 2. 在现有 crawler 的 URL Tester 中分别检查中英文页面的记录提取,再手动触发该 crawler 抓取。保留已保存配置和现有每日周期,不将编辑器草稿发布或新建密钥作为常规重抓步骤。 3. 等待该次抓取结束,记录时间、成功/忽略/失败 URL 数与索引记录数;检查失败原因,不能只看记录总数增长。先保留路径规则、选择器、调度等变量,只有抓取证据指向具体配置问题时再处理该项。 -4. 按站点实际过滤条件查询:中文 `language:zh-Hans`、英文 `language:en`,并使用 `docusaurus_tag:default OR docs-default-current`。逐项检查 16 个双语 API 页面标题、代表性端点和两种语言的旧页面对照,确认返回目标 URL、`current` 版本和正确 tag;结果排名不要求固定第一。 +4. 按站点实际过滤条件查询:中文 `language:zh-Hans`、英文 `language:en`,并使用 `docusaurus_tag:default OR docs-default-current`。逐项检查 清单内全部双语 API 页面标题、代表性端点和两种语言的旧页面对照,确认返回目标 URL、`current` 版本和正确 tag;结果排名不要求固定第一。 5. 在网站搜索框分别查找“智能体 Webhook”和“Agent Webhook”,打开目标页面,再更新 [每日调度与分块验收记录](./search-daily-verification.json)。公开记录只保留必要的时间、配置、查询与结果证据,不保存密钥、账号身份、请求头或本机绝对路径。 首次收录时的 [v4 搜索验收记录](./search-verification.json) 保留当时的月度调度快照:2026-10-15 手动抓取使索引从 19,324 条增至 21,572 条,22 项查询及双语浏览器检查通过,但两篇 `v10.0.180` 更新日志因记录过大而失败。该历史记录不随 v5 改写;每日调度、定向分块和后续真实抓取结果单独记录,URL Tester 通过不等于整站抓取已全部通过。 @@ -85,3 +85,11 @@ v5 只对 `/more/changelog/v10.0.180` 和 `/en/more/changelog/v10.0.180` 做定 ## 当前验证记录 参见 [核查与验证](./verification.md)、[发布与验收摘要](./publication.json)、[生产逐项报告](./production-verification.json)、[首次搜索验收记录](./search-verification.json)及[每日调度与分块验收记录](./search-daily-verification.json)。后续更改认证、路由、配置入口或结果交付时,只重新核对受影响合同和相关示例,不将本批测试结果传播为其它功能的运行保证。 + +## Run、历史消息与附件媒体扩展 + +本批固定核对源码 `7fe016c76ab98e0643ab4f2710c3733f757ef4fb`、内部文档 `b17e6a7a2d3dc4d969ea422ee37825ae39eb6177`,记录见 [本批验证](./history-attachments-verification.md)。公共规范中 Agent Service 为 27 个操作,Compat Proxy 3 个、Workflow Service 5 个。后两份规范保留原来源基线。 + +RunMeta 接受 `handed_off`,但现行 runtime-state HTTP 入口仍对该状态返回 `404`。共享 Schema 的五值枚举不能直接当作该路由的实际响应合同。历史消息的分页窗口与返回消息条数不同,示例按请求窗口推进;媒体读取使用短期签名,附件下载使用实例身份。 + +`publication.json`、生产和搜索验收 JSON 保留已经发布批次的事实;本批提交 PR 不代表这些新页面已经部署或收录。 diff --git a/api/endpoint-catalog.json b/api/endpoint-catalog.json index a2449db..b7efae7 100644 --- a/api/endpoint-catalog.json +++ b/api/endpoint-catalog.json @@ -3,9 +3,9 @@ "reviewBaseline": { "reviewedAt": "2026-10-15", "clientRepository": "desirecore/desirecore", - "clientRevision": "e45f22902af2dc45b109cfa8f454795538aea2ed", - "internalDocsRevision": "9cc47fc9216db4a3803a982f00649f7af667c933", - "publicDocsBaseRevision": "34fb4a7cbc8bd2f453ee79536981d22c7448373b" + "clientRevision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "internalDocsRevision": "b17e6a7a2d3dc4d969ea422ee37825ae39eb6177", + "publicDocsBaseRevision": "3e0e3de7168e2392802d37d295f73a65af070f5e" }, "pages": [ { @@ -47,6 +47,16 @@ "id": "api/openapi", "file": "docs/07-api/07-openapi.md", "slug": "/api/openapi" + }, + { + "id": "api/session-history-and-messages", + "file": "docs/07-api/08-session-history-and-messages.md", + "slug": "/api/session-history-and-messages" + }, + { + "id": "api/attachments-and-media", + "file": "docs/07-api/09-attachments-and-media.md", + "slug": "/api/attachments-and-media" } ], "endpoints": [ @@ -65,7 +75,7 @@ "resultDelivery": "Synchronous JSON metadata. A 200 response verifies this authenticated request, not every optional capability.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/client-handshake-routes.ts", "packages/agent-service/src/service-metadata.ts", @@ -81,7 +91,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source auth and handshake unit tests plus a real loopback HTTPS readiness integration; no public-domain or Caddy-container run." + "scope": "Fixed-source auth and handshake unit tests plus a real loopback HTTPS readiness integration; no public-domain or Caddy-container run.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -103,7 +114,7 @@ "resultDelivery": "200 {accepted:true,agentId,event} acknowledges receipt, with no Run ID or delivery deduplication. Only explicit authorized SendUserMessage output is delivered to the user; final model answers are not automatically returned to the caller or added to chat.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-routes.ts", "packages/agent-service/src/routes/webhook-state.ts", @@ -121,7 +132,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Real production owner management, atomic AgentFS snapshot writes, local fake-provider runtime and SendUserMessage delivery, rotation, failure and disable/delete integration. No commercial-provider or packaged-client claim." + "scope": "Real production owner management, atomic AgentFS snapshot writes, local fake-provider runtime and SendUserMessage delivery, rotation, failure and disable/delete integration. No commercial-provider or packaged-client claim.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -142,7 +154,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/agent-routes.ts", "packages/agent-service/src/agent/list-projection.ts" @@ -155,7 +167,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -176,7 +189,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/session/run-manager.ts" @@ -188,8 +201,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -210,7 +224,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/session/run-meta-read.ts" @@ -222,8 +236,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -244,7 +259,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts" ] @@ -255,8 +270,9 @@ "effectiveVersion": null, "versionEvidence": null, "verification": { - "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -277,7 +293,7 @@ "resultDelivery": "Synchronous read; live state is observational, not a query completion signal.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/runs-routes.ts", "packages/agent-service/src/receipts/reader.ts" @@ -290,7 +306,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -311,7 +328,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -324,7 +341,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -345,7 +363,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -358,7 +376,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -379,7 +398,7 @@ "resultDelivery": "HTTP admission or lookup JSON; query completion and incremental messages arrive through correlated Socket.IO events.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/query-routes.ts", "packages/agent-service/src/query/client-request-registry.ts" @@ -392,7 +411,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture." + "scope": "Fixed-source request/response examples and focused HTTP + Socket.IO client contract; runtime fake in the public client fixture.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -413,7 +433,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -426,7 +446,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -447,7 +468,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -460,7 +481,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -481,7 +503,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -494,7 +516,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -515,7 +538,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -528,7 +551,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -549,7 +573,7 @@ "resultDelivery": "Synchronous configuration or explicit delivery JSON. First configuration and rotation disclose a secret once; GET does not return it.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/webhook-management-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -562,7 +586,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -583,7 +608,7 @@ "resultDelivery": "Synchronous {items,total,limit,offset}; default limit50, maximum200. Items may include other authorized background deliveries.", "source": { "repository": "desirecore/desirecore", - "revision": "e45f22902af2dc45b109cfa8f454795538aea2ed", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", "files": [ "packages/agent-service/src/routes/schedule-routes.ts", "packages/agent-service/src/auth/governed-endpoint-auth.ts" @@ -594,7 +619,8 @@ "versionEvidence": null, "verification": { "record": "api/verification.md", - "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification." + "scope": "Production Webhook management integration with local fake model upstream and real delivery persistence; no commercial model or desktop package verification.", + "baselineNote": "Source contract rechecked at 7fe016c. Historical runtime test results retain their recorded source baseline; this batch coverage is recorded separately in api/history-attachments-verification.md." }, "spec": "static/openapi/agent-service.json", "securitySchemes": [ @@ -872,6 +898,414 @@ "securitySchemes": [ "WorkflowToken" ] + }, + { + "operationId": "uploadAttachment", + "service": "agent-service", + "method": "POST", + "path": "/api/attachments", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "One non-empty multipart file, at most 20 MiB. Stores bytes without extraction or execution. Per-user retained-directory limit 256, total retained data limit 512 MiB. Each successful POST creates a new attachment; no automatic retry/idempotency contract. The receipt fileReference is passed unchanged in Query options.fileReferences on this instance and does not grant tool or cross-user permission.", + "resultDelivery": "Complete persisted attachment receipt.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/attachment-routes.ts", + "packages/schemas/src/agent-service/attachments.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/attachments.ts:attachmentReceiptSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "downloadAttachment", + "service": "agent-service", + "method": "GET", + "path": "/api/attachments/:attachmentId", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Returns original bytes as an attachment, not JSON. The user is resolved from instance authentication. IDs do not confer cross-user permission. This route has no media byte-Range contract.", + "resultDelivery": "Original file bytes with no-store and nosniff.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/attachment-routes.ts", + "packages/schemas/src/agent-service/attachments.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "uploadMedia", + "service": "agent-service", + "method": "POST", + "path": "/api/media/upload", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Single multipart file with an allowed MIME. Service multipart limit is 2 GiB; deployment/proxy limits may be smaller. Empty files are rejected. New files retain authenticated owner metadata. Metadata may be passed in top-level Query mediaRefs; model support is a separate condition.", + "resultDelivery": "Direct media metadata.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "createMediaAccessUrls", + "service": "agent-service", + "method": "POST", + "path": "/api/media/access-urls", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "X-Agent-Service-Token supplied by the instance administrator", + "permissions": "Normal instance authentication is required to mint five-minute links. Validates new-media ownership and actual media content. Legacy media without owner metadata retains read compatibility. Existing objects forbidden to the user reject the request. The signing service requires an initialized instance token.", + "resultDelivery": "Links and missing IDs; no universal success/data wrapper.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/http-server.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/routes/media-routes.ts:mediaAccessUrlsBodySchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getSignedMedia", + "service": "agent-service", + "method": "GET", + "path": "/media/:filename", + "doc": "api/attachments-and-media", + "deploymentModes": [ + "standalone-owner", + "standalone-external" + ], + "authentication": "expires and signature query values from an authenticated media access-urls response; no instance token header for the signed GET", + "permissions": "Only GET/HEAD on the single-segment /media/:filename path bypass normal instance-token authentication, after the application authority fence. The handler requires the signed expires/signature pair; /api/media/*, other methods, and multi-segment paths do not inherit this exemption. Configured Origin policy and service readiness still apply. Anyone holding a valid complete link can read until expiry; owner is checked at issuance, not re-resolved from the unsigned reader. Supports one byte range, open-ended/suffix ranges, and private caching bounded by remaining TTL. The implementation also supplies HEAD with the same validation and no body; HEAD is not selected as another public catalog operation.", + "resultDelivery": "Full media bytes.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/media-routes.ts", + "packages/agent-service/src/media/media-store.ts", + "packages/shared/src/utils/media-upload-policy.ts", + "packages/agent-service/src/auth/instance-auth.ts", + "packages/agent-service/src/auth/app-authority-gate.ts", + "packages/agent-service/src/auth/media-access-signing.ts", + "packages/agent-service/src/security/origin-policy.ts", + "packages/agent-service/src/readiness.ts", + "deploy/Caddyfile" + ] + }, + "schemaRefs": [], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/schema/auth review. Standalone uploader: 12 local HTTP contract tests with a fake server, not production storage or model execution. Root records actual fixed-source route validation separately." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "SignedMediaSignature" + ] + }, + { + "operationId": "listSessionHistory", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Lists ordinary user-visible history by updatedAt descending. Excludes delegated, manual and dedicated schedule/Webhook/Mention timelines. Preserves fork generations; do not concatenate branches as a deduplicated transcript. Pagination is not an immutable snapshot.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/run-meta-read.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionSummarySchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getSessionSnapshot", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/:runId", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Checks trusted user ownership before body/image reads. Missing, cleared and unowned Runs return 404. Delegated Runs return 403 delegated_run_requires_inspect_view; use the messages inspection view instead. pendingAssistant is temporary and separate from durable messages. Returned configuration is not execution authority.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/session/persistent-state/history.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionSnapshotSchema", + "packages/schemas/src/agent-service/session.ts:sessionMessageSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "listSessionMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/:runId/messages", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Checks ownership before content reads. Delegated Runs require view=inspect-run. total counts source rows, not returned messages; advance offset by the requested limit and stop at total, even if a page is empty. Optional live is a temporary delegated display checkpoint, not a durable message or terminal. There is no cross-page consistency revision.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/message-index.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/schemas/src/agent-service/session.ts:sessionMessageSchema", + "packages/schemas/src/agent-service/session.ts:liveRunInspectionSchema" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "searchSessionMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/sessions/messages/search", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Valid deployment-owner instance credential; exact history is filtered by the trusted user before preview/body reads. Knowing a Run ID does not grant access.", + "resultDelivery": "Matches extracted message text case-insensitively, newest timestamp first. Includes independent manual conversations; excludes archived and delegated Runs. Default-conversation fork families select the visible history window. preferredRunId is neither a credential nor an exact-Run filter. limit/offset and their sum must be safe nonnegative integers. limit=0 returns all remaining hits and hasMore=false. Match original text before cropping previews.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/history-routes.ts", + "packages/agent-service/src/session/store.ts", + "packages/agent-service/src/session/lineage.ts", + "packages/agent-service/src/session/history-search-page.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/session/store.ts:FlattenedMessage" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getConversationOverview", + "service": "agent-service", + "method": "GET", + "path": "/api/conversation/:agentId/overview", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Deployment owner only: conversation memory is selected by the instance's current local profile. This is not a multi-tenant isolation API. Matter Run sources additionally verify user and Agent ownership.", + "resultDelivery": "Reads the instance's current local profile, not a caller-supplied tenant ID. Intended for the deployment owner. Returns all active matters and at most ten recent entries, not a complete transcript. No pagination. An empty overview does not establish Agent existence or absence of history.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/conversation-routes.ts", + "packages/agent-service/src/conversation-memory/overview-projection.ts", + "packages/agent-service/src/conversation-memory/types.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/conversation-memory/types.ts:ConversationOverview" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] + }, + { + "operationId": "getConversationMatterMessages", + "service": "agent-service", + "method": "GET", + "path": "/api/conversation/:agentId/matters/:id/full", + "doc": "api/session-history-and-messages", + "deploymentModes": [ + "standalone-owner" + ], + "authentication": "X-Agent-Service-Token from the deployment administrator", + "permissions": "Deployment owner only: conversation memory is selected by the instance's current local profile. This is not a multi-tenant isolation API. Matter Run sources additionally verify user and Agent ownership.", + "resultDelivery": "Reads the instance owner's local-profile conversation memory. Supports active, recent and archived source data. Underlying Run reads recheck that user and Agent ownership. Reads at most 120 source messages and sets truncated=true if more exist; projection may omit entries. No pagination or limit-override parameter. Entries are not guaranteed to have the per-Run message shape.", + "source": { + "repository": "desirecore/desirecore", + "revision": "7fe016c76ab98e0643ab4f2710c3733f757ef4fb", + "files": [ + "packages/agent-service/src/routes/conversation-routes.ts", + "packages/agent-service/src/conversation-memory/conversation-store.ts", + "packages/agent-service/src/session/run-meta-read.ts", + "packages/agent-service/src/session/public-session-projection.ts", + "packages/agent-service/src/auth/instance-auth.ts" + ] + }, + "schemaRefs": [ + "packages/agent-service/src/conversation-memory/conversation-store.ts:MatterMessagesResult" + ], + "effectiveVersion": null, + "versionEvidence": null, + "verification": { + "record": "api/history-attachments-verification.md", + "scope": "Fixed-source route/owner/pagination review; real local source history routes and SessionStore fixture verifies authentication, owner isolation, public projection, search validation and empty/404 matter reads. The independent example uses 13 local HTTP contract tests, including paging gaps and truncation; no deployed-instance claim." + }, + "spec": "static/openapi/agent-service.json", + "securitySchemes": [ + "InstanceToken" + ] } ] } diff --git a/api/examples/agent-client/README.md b/api/examples/agent-client/README.md index 7ae33dc..df3c0b7 100644 --- a/api/examples/agent-client/README.md +++ b/api/examples/agent-client/README.md @@ -1,6 +1,6 @@ # DesireCore Node conversation client -This example calls an administrator's existing standalone Agent Service using the same owner credential for HTTP and Socket.IO. Obtain its HTTPS origin, existing instance token, and target Agent ID from the administrator. The Agent needs a working model configuration and permissions appropriate for your prompt. +These examples call an administrator's existing standalone Agent Service using the same owner credential for HTTP and Socket.IO. Obtain its HTTPS origin, existing instance token, and target Agent ID from the administrator. The Agent needs a working model configuration and permissions appropriate for your prompt. Use Node.js 22 or newer. Install dependencies **in this directory**, independently of the documentation website: @@ -21,11 +21,13 @@ The client performs the authenticated read-only handshake, verifies the data-spa Text streams go to stdout. JSON progress records go to stderr. A `202` progress record means HTTP admission; it is not a final answer, durable commitment, or proof of a successful tool action. Only a matching `query:complete` with `terminal.status: "completed"` records completion of the current turn. Run `idle`, message `result`/`completed_idle`, and user message delivery are not treated as completion. An interrupted turn exits with status 1. Completion of a turn does not independently verify that every requested business operation succeeded. +If the source Run is observed as `handed_off`, or its runtime-state precheck is unavailable, the client stops automatic follow-ups and retains the pending text. The current HTTP runtime-state route returns `404` for handed-off Runs, even though Run details can report `handed_off`. A `404` does not establish failure or missing history. The client does not follow `handoffTo` automatically. + Each follow-up gets a new `clientRequestId` and message ID and reuses the current session. The client waits for the preceding turn to complete and checks that its admission belongs to the current server process. If the session is unavailable, it reports the failure; it does not silently create a new Run. Approval and question prompts need the instance's supported user interface; this CLI does not approve operations or answer questions automatically. ## State and recovery -`--state` must name a new file in an existing directory. The client saves request identity before sending, with file mode `0600`. The state contains prompts, queued follow-ups, and request/session/Run IDs, and never stores the access token. Keep it private and leave its frozen request bodies unchanged. Reusing an existing `--state` path is rejected. +`--state` must name a new file in an existing directory. The client saves request identity before sending, with file mode `0600`. The state contains prompts, queued follow-ups, and request/session/Run IDs, and selected file references, and never stores the access token. Keep it private and leave its frozen request bodies unchanged. Reusing an existing `--state` path is rejected. ```bash # Observe the recorded request without resending it or starting a new query. @@ -40,7 +42,7 @@ node client.mjs --cancel ./request.json --timeout-ms 30000 `--resume`, `--recover`, and `--cancel` are separate modes. They use the instance origin and Agent ID in the state file; `DESIRECORE_TOKEN` must still contain a valid token. If `DESIRECORE_URL` is also set, it must match the saved origin. A different handshake `instanceId` prevents submission or recovery on the wrong instance. Only one client process should use a state file at a time: a new connection with the same client identity takes over delivery of future events. -On an unknown admission, the client calls the read-only `POST /api/query/client-requests/lookup`. A `committed` request is not resent; `reserved` is awaited. `absent` may be resent using the exact same IDs, prompt, message, and digest within the replay and tombstone windows reported by the server; only `originSocketId` changes after reconnection. `unknown`, a previous-process commitment, or an expired replay window stops automatic resubmission. The `qrd1:` digest follows the shared UTF-8 FNV-1a contract and covers prompt, user message ID, media IDs, and file references; this text-only example uses empty media/file arrays. It is not a security hash and does not cover all query options. +On an unknown admission, the client calls the read-only `POST /api/query/client-requests/lookup`. A `committed` request is not resent; `reserved` is awaited. `absent` may be resent using the exact same IDs, prompt, message, and digest within the replay and tombstone windows reported by the server; only `originSocketId` changes after reconnection. `unknown`, a previous-process commitment, or an expired replay window stops automatic resubmission. The `qrd1:` digest follows the shared UTF-8 FNV-1a contract and covers prompt, user message ID, media IDs, and file references. This CLI uses an empty media array and the initial turn’s selected attachment file references. It is not a security hash and does not cover all query options. Socket reconnection receives future events; it does not replay missed events. Recovery reads admission state, the known Run's raw runtime state, and a bounded page of durable receipts. If the started event was missed but an exact session ID is known, the client searches up to 100 Agent-filtered Runs for a unique `session_id` match. It never guesses the latest Run. No match leaves the Run identity unknown. @@ -50,13 +52,31 @@ The receipt snapshot may contain other turns in the same Run. An idle/completed Exit codes: **0** = current turn explicitly completed; **1** = rejected, failed, or interrupted; **2** = unknown, read-only recovery, or a client/connection error. Timeouts are local waiting limits and do not automatically cancel server execution. `--http-timeout-ms` and `--poll-ms` control individual HTTP calls and admission polling. +## Upload an attachment and submit it + +`attachments.mjs` needs Node.js 22+ and no dependencies. It uploads one non-empty file up to 20 MiB and verifies the server receipt’s size and SHA-256. It refuses redirects and existing output files and never retries the upload automatically. + +```bash +node attachments.mjs --file ./report.txt --mime text/plain --out ./attachment.json +node client.mjs --prompt 'Summarize the uploaded report.' \ + --attachment-receipt ./attachment.json --state ./request-with-file.json +``` + +`--attachment-receipt` can repeat. The conversation client uses the receipts’ unchanged `fileReference` values in the initial Query’s `options.fileReferences`, deduplicates them in order, and freezes them in the request state and digest. Follow-ups remain text-only. Receipts and request state contain private file references; use them only with the instance that issued them. Upload success does not establish that a model or tool has read the file. Lost upload responses leave an unknown outcome; a new POST creates another attachment. + +For media upload, signed playback URLs, ownership compatibility, and Range behavior, see the bilingual [attachments and media guide](../../../docs/07-api/09-attachments-and-media.md). The upload helper handles general attachments; it does not convert media metadata into a query or issue media URLs. + +## Read historical messages + +`history.mjs` also needs Node.js 22+ and no dependencies. See `node history.mjs --help` and the bilingual [history guide](../../../docs/07-api/08-session-history-and-messages.md) for its commands. The helper reads history; it never resumes execution or infers a missing query terminal from persisted messages. It uses source-row windows for per-Run pagination rather than advancing by the returned message count. + ## Verification and maintenance Run `npm test` for the independent HTTP + Socket.IO contract fixture. It verifies request identity, start/follow-up, events arriving before HTTP, lost acknowledgements, reconnection, conservative recovery, body mismatch, interrupted turns, explicit cancellation, and prevention of false completion. The fixture is a fake instance, with no model credentials or real tool effects; passing it does not establish packaged-client or real-model acceptance. `test/source-route.fixture.ts` can be copied into a fixed-source DesireCore checkout as `packages/agent-service/src/__tests__/api-docs-node-client.integration.test.ts`. Set `DESIRECORE_API_EXAMPLE_PATH` to the absolute path of this directory's `client.mjs`, then run that single file in the repository's integration test project. It verifies this client against the actual query routes, HTTP/Socket authentication, request Schema and digest, durable admission registry and lookup, and renderer delivery room. The query runtime and follow-up execution callback remain fake. Use its isolated temporary data root; do not point it at an existing user profile. -Source baseline: `desirecore/desirecore` commit `d3bac348db5f15ce6a59469ffef8f495c84ced59`. +Current source review: `desirecore/desirecore` commit `7fe016c76ab98e0643ab4f2710c3733f757ef4fb`. Historical validation remains in `api/verification.md`; this extension is recorded in `api/history-attachments-verification.md`. - HTTP submission: `packages/agent-service/src/routes/query-routes.ts`. - Admission lookup: `packages/agent-service/src/routes/client-request-lookup-routes.ts`; shared Schema in `packages/schemas/src/agent-service/client-request-admission.ts`. @@ -65,4 +85,6 @@ Source baseline: `desirecore/desirecore` commit `d3bac348db5f15ce6a59469ffef8f49 - Delivery identity: `packages/agent-service/src/handlers/renderer-delivery.ts` and `auth/delivery-principal.ts`. - Run snapshots/receipts: `packages/agent-service/src/routes/runs-routes.ts`. -After changing `client.mjs`, update `static/api-examples/agent-client.mjs` with identical bytes and revalidate the contract. The example is deliberately a small client, not a complete renderer, SDK, or durable execution orchestrator. +Keep the three published downloads byte-identical to their maintained files: `client.mjs` → `static/api-examples/agent-client.mjs`, `history.mjs` → `static/api-examples/history.mjs`, and `attachments.mjs` → `static/api-examples/attachments.mjs`. Revalidate the affected contracts after changes. The example is deliberately a small client, not a complete renderer, SDK, or durable execution orchestrator. + +`test/source-history.fixture.ts` can be copied to the fixed source tree as `packages/agent-service/src/__tests__/api-docs-history.test.ts` and run in the `unit` project. It uses actual instance authentication, history/topic/Run routes and temporary file storage. The local profile and runtime discovery are stubs. It checks owner filtering, public message projection, search validation, empty topic reads, and the actual `handed_off` detail/`404` runtime-state behavior. It does not run a model or real handoff. Website TypeScript checking excludes these two source fixtures because their imports resolve only inside DesireCore’s source repository. diff --git a/api/examples/agent-client/attachments.mjs b/api/examples/agent-client/attachments.mjs new file mode 100644 index 0000000..989c478 --- /dev/null +++ b/api/examples/agent-client/attachments.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// Standalone attachment uploader. Requires Node.js 22+, with no npm dependencies. +import { createHash } from 'node:crypto'; +import { open, rm } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const fields = ['attachmentId', 'name', 'mimeType', 'size', 'sha256', 'fileReference']; +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const textWithin = (value, maximum) => typeof value === 'string' + && [...value].length >= 1 && [...value].length <= maximum; + +export function validateAttachmentReceipt(value) { + if (!value || typeof value !== 'object' || Array.isArray(value) + || Object.keys(value).length !== fields.length || fields.some(key => !Object.hasOwn(value, key)) + || typeof value.attachmentId !== 'string' || !uuid.test(value.attachmentId) + || !textWithin(value.name, 240) || !textWithin(value.mimeType, 128) + || !Number.isSafeInteger(value.size) || value.size < 1 || value.size > MAX_ATTACHMENT_BYTES + || typeof value.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.sha256) + || !textWithin(value.fileReference, 2048)) { + throw new Error('Invalid attachment receipt from the instance.'); + } + return value; +} + +function instanceOrigin(value) { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash + || (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback))) { + throw new Error('Use an HTTPS instance origin; HTTP is allowed only on loopback.'); + } + return url.origin; +} + +export class AttachmentUploadError extends Error { + constructor(status, code) { + super(`Attachment upload rejected: HTTP ${status}${code ? ` ${code}` : ''}`); + this.name = 'AttachmentUploadError'; + this.status = status; + this.code = code; + } +} + +export async function uploadAttachment({ baseUrl, token, file, mimeType = 'application/octet-stream', timeoutMs = 60_000 }) { + const origin = instanceOrigin(baseUrl); + if (typeof token !== 'string' || !token.trim()) throw new Error('Set DESIRECORE_TOKEN.'); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Invalid upload timeout.'); + if (typeof mimeType !== 'string' || mimeType.length > 128 + || !/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(mimeType)) throw new Error('Invalid MIME type.'); + const path = resolve(file); + const handle = await open(path, 'r'); + let bytes; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.size < 1 || stat.size > MAX_ATTACHMENT_BYTES) { + throw new Error('Select one non-empty regular file no larger than 20 MiB.'); + } + // Read at most 20 MiB + 1 from the opened file, even if it grows after stat(). + bytes = Buffer.alloc(Math.min(stat.size + 1, MAX_ATTACHMENT_BYTES + 1)); + let size = 0; + while (size < bytes.length) { + const part = await handle.read(bytes, size, bytes.length - size, null); + if (!part.bytesRead) break; + size += part.bytesRead; + } + if (size !== stat.size) throw new Error('File changed while reading; select it again.'); + bytes = bytes.subarray(0, size); + } finally { await handle.close(); } + const expectedHash = createHash('sha256').update(bytes).digest('hex'); + const form = new FormData(); + form.append('file', new Blob([bytes], { type: mimeType }), basename(path)); + // No automatic retry: each successful POST creates a new stored attachment. + const response = await fetch(`${origin}/api/attachments`, { + method: 'POST', headers: { 'X-Agent-Service-Token': token }, body: form, + redirect: 'error', signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.headers.get('content-type')?.toLowerCase().includes('application/json')) { + throw new Error(`Attachment upload: HTTP ${response.status}; expected JSON from the instance API.`); + } + const value = await response.json(); + if (response.status !== 201) { + throw new AttachmentUploadError(response.status, typeof value?.error === 'string' ? value.error.slice(0, 160) : undefined); + } + const receipt = validateAttachmentReceipt(value); + if (receipt.size !== bytes.length || receipt.sha256 !== expectedHash) { + throw new Error('Attachment receipt does not match the uploaded file size and SHA-256.'); + } + return receipt; +} + +const help = `Usage: node attachments.mjs --file ./report.pdf --out ./attachment.json [--mime application/pdf] +Environment: DESIRECORE_URL, DESIRECORE_TOKEN. Node.js 22+; no npm install required. +The output is the direct attachment receipt, for client.mjs --attachment-receipt. +Keep the receipt private and use it only with the same instance. Uploads are not retried automatically. +`; + +export async function runUploadCli(argv, env = process.env) { + if (argv.includes('--help') || argv.includes('-h')) { process.stdout.write(help); return; } + const options = {}; + for (let i = 0; i < argv.length; i++) { + const name = argv[i]; + if (!['--file', '--out', '--mime'].includes(name) || options[name]) throw new Error(help); + const value = argv[++i]; + if (!value || value.startsWith('--')) throw new Error(help); + options[name] = value; + } + if (!options['--file'] || !options['--out']) throw new Error(help); + // Reserve the output first, so an existing receipt cannot be overwritten after an upload. + const outputPath = resolve(options['--out']); + const output = await open(outputPath, 'wx', 0o600); + let saved = false; + try { + const receipt = await uploadAttachment({ baseUrl: env.DESIRECORE_URL, token: env.DESIRECORE_TOKEN, + file: options['--file'], mimeType: options['--mime'] ?? 'application/octet-stream' }); + await output.writeFile(`${JSON.stringify(receipt, null, 2)}\n`); + saved = true; + process.stderr.write(`Saved attachment receipt to ${options['--out']}\n`); + } finally { await output.close(); if (!saved) await rm(outputPath, { force: true }); } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + runUploadCli(process.argv.slice(2)).catch(error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); +} diff --git a/api/examples/agent-client/client.mjs b/api/examples/agent-client/client.mjs index 514f801..872dec2 100644 --- a/api/examples/agent-client/client.mjs +++ b/api/examples/agent-client/client.mjs @@ -7,9 +7,9 @@ import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { setTimeout as delay } from 'node:timers/promises'; -export function queryDigest(prompt, userMessageId) { +export function queryDigest(prompt, userMessageId, fileReferences = []) { // packages/shared/src/query/request-digest.ts: UTF-8 FNV-1a, not a security hash. - const canonical = JSON.stringify(['qrd1', prompt, userMessageId, [], []]); + const canonical = JSON.stringify(['qrd1', prompt, userMessageId, [], [...fileReferences]]); let hash = 0xcbf29ce484222325n; for (const byte of new TextEncoder().encode(canonical)) { hash = ((hash ^ BigInt(byte)) * 0x100000001b3n) & 0xffffffffffffffffn; @@ -18,7 +18,7 @@ export function queryDigest(prompt, userMessageId) { } const help = `Usage: - node client.mjs --prompt "Hello" [--follow-up "And ..."] --state ./request.json + node client.mjs --prompt "Hello" [--attachment-receipt ./receipt.json] [--follow-up "And ..."] --state ./request.json node client.mjs --resume ./request.json node client.mjs --recover ./request.json # read-only; does not resend node client.mjs --cancel ./request.json # explicit cancellation request @@ -27,12 +27,13 @@ Environment: DESIRECORE_URL, DESIRECORE_TOKEN, DESIRECORE_AGENT_ID (new requests Options: --timeout-ms 300000, --http-timeout-ms 10000, --poll-ms 1000. Use HTTPS remotely; HTTP is accepted only for localhost/127.0.0.1/::1. Exit: 0 observed completed turn; 1 rejected/failed/interrupted; 2 unknown or recovery-only. -State files contain prompts and IDs, never the access token. Do not share them. +--attachment-receipt can repeat; it attaches already-uploaded files to the initial turn only. +State files contain prompts, file references and IDs, never the access token. Do not share them. Ctrl+C exits this client without cancelling the server; use --cancel to request cancellation. `; function argumentsFor(argv) { - const options = { followUps: [], timeoutMs: 300_000, httpTimeoutMs: 10_000, pollMs: 1_000 }; + const options = { followUps: [], attachmentReceipts: [], timeoutMs: 300_000, httpTimeoutMs: 10_000, pollMs: 1_000 }; const names = new Map([ ['--prompt', 'prompt'], ['--state', 'state'], ['--resume', 'resume'], ['--recover', 'recover'], ['--cancel', 'cancel'], ['--timeout-ms', 'timeoutMs'], @@ -44,6 +45,7 @@ function argumentsFor(argv) { const value = argv[++i]; if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}`); if (option === '--follow-up') options.followUps.push(value); + else if (option === '--attachment-receipt') options.attachmentReceipts.push(value); else if (names.has(option)) options[names.get(option)] = value; else throw new Error(`Unknown option ${option}`); } @@ -52,7 +54,7 @@ function argumentsFor(argv) { if (!Number.isSafeInteger(options[key]) || options[key] < 10) throw new Error(`Invalid ${key}`); } const modes = ['resume', 'recover', 'cancel'].filter(key => options[key]); - if (modes.length > 1 || (modes.length && (options.prompt || options.state || options.followUps.length))) { + if (modes.length > 1 || (modes.length && (options.prompt || options.state || options.followUps.length || options.attachmentReceipts.length))) { throw new Error('Use either a new --prompt/--state or one of --resume/--recover/--cancel.'); } if (!modes.length && (!options.prompt?.trim() || !options.state)) throw new Error(help); @@ -71,12 +73,32 @@ function baseUrl(value) { return url.origin; } -function newTurn(prompt, kind, sessionId, agentId, conversationId) { +function attachmentReferences(files) { + const references = []; + const fields = ['attachmentId', 'name', 'mimeType', 'size', 'sha256', 'fileReference']; + const sizedString = (value, maximum) => typeof value === 'string' && [...value].length >= 1 && [...value].length <= maximum; + for (const file of files) { + const receipt = JSON.parse(readFileSync(file, 'utf8')); + if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt) + || Object.keys(receipt).length !== fields.length || fields.some(key => !Object.hasOwn(receipt, key)) + || typeof receipt.attachmentId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(receipt.attachmentId) + || !sizedString(receipt.name, 240) || !sizedString(receipt.mimeType, 128) + || !Number.isSafeInteger(receipt.size) || receipt.size < 1 || receipt.size > 20 * 1024 * 1024 + || typeof receipt.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(receipt.sha256) + || !sizedString(receipt.fileReference, 2048)) { + throw new Error('Invalid attachment receipt; use the complete JSON returned by the same instance upload.'); + } + references.push(receipt.fileReference); + } + return [...new Set(references)]; // Keep the first occurrence order for the request digest. +} + +function newTurn(prompt, kind, sessionId, agentId, conversationId, fileReferences = []) { const clientRequestId = randomUUID(); const userMessageId = randomUUID(); - const requestDigest = queryDigest(prompt, userMessageId); + const requestDigest = queryDigest(prompt, userMessageId, fileReferences); const body = kind === 'start' - ? { prompt, options: { agentId, conversationId }, clientRequestId, userMessageId, requestDigest } + ? { prompt, options: { agentId, conversationId, ...(fileReferences.length ? { fileReferences: [...fileReferences] } : {}) }, clientRequestId, userMessageId, requestDigest } : { sessionId, message: { id: userMessageId, role: 'user', content: prompt, timestamp: Date.now() }, clientRequestId, requestDigest }; return { kind, body, userMessageId, firstDispatchedAt: null, firstDispatchedServerTime: null, attempts: 0 }; } @@ -89,9 +111,11 @@ function readState(file) { state.baseUrl = baseUrl(state.baseUrl); for (const turn of state.turns) { const prompt = turn.kind === 'start' ? turn.body?.prompt : turn.body?.message?.content; + const fileReferences = (turn.kind === 'start' ? turn.body?.options?.fileReferences : turn.body?.fileReferences) ?? []; if (!['start', 'follow-up'].includes(turn.kind) || typeof prompt !== 'string' || !turn.body?.clientRequestId || !turn.userMessageId - || turn.body.requestDigest !== queryDigest(prompt, turn.userMessageId)) { + || !Array.isArray(fileReferences) || fileReferences.some(value => typeof value !== 'string') + || turn.body.requestDigest !== queryDigest(prompt, turn.userMessageId, fileReferences)) { throw new Error('Invalid or edited request state. Create a new state file for a different request.'); } } @@ -104,6 +128,8 @@ export async function runClient(options, env = process.env, output = {}) { const filename = resolve(options.resume || options.recover || options.cancel || options.state); const existing = Boolean(options.resume || options.recover || options.cancel); if (!existing && existsSync(filename)) throw new Error('State file already exists. Use --resume or a new --state path.'); + if (existing && options.attachmentReceipts?.length) throw new Error('Saved requests retain their original attachment references.'); + const fileReferences = existing ? [] : attachmentReferences(options.attachmentReceipts ?? []); const token = env.DESIRECORE_TOKEN; if (!token) throw new Error('Set DESIRECORE_TOKEN to the existing instance access token.'); const origin = existing ? readState(filename).baseUrl : baseUrl(env.DESIRECORE_URL); @@ -150,7 +176,7 @@ export async function runClient(options, env = process.env, output = {}) { }; if (!state.agentId) throw new Error('Set DESIRECORE_AGENT_ID to the target Agent ID.'); if (state.instanceId !== metadata.instanceId) throw new Error('Instance identity changed; refusing to send or recover requests on another instance.'); - if (!existing) state.turns.push(newTurn(options.prompt, 'start', undefined, state.agentId, state.conversationId)); + if (!existing) state.turns.push(newTurn(options.prompt, 'start', undefined, state.agentId, state.conversationId, fileReferences)); function save() { const temporary = `${filename}.${process.pid}.tmp`; writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); @@ -187,6 +213,9 @@ export async function runClient(options, env = process.env, output = {}) { const runtime = await http(`${path}/runtime-state`); const history = await http(`${path}/receipt?offset=0&limit=10`); snapshot.runtime = { httpStatus: runtime.status, ...runtime.data }; + if (runtime.status === 200 && runtime.data.run_id === turn.runId && runtime.data.agent_id === state.agentId) { + turn.runState = { runId: turn.runId, status: runtime.data.status, source: 'runtime-state' }; + } snapshot.receipts = { httpStatus: history.status, ...history.data }; } turn.recovery = snapshot; @@ -251,8 +280,11 @@ export async function runClient(options, env = process.env, output = {}) { save(); log({ event: 'turn_terminal', clientRequestId: data.clientRequestId, ...item.outcome }); }); socket.on('activity:run_status', data => { - if (turn.runId === data.runId) log({ event: 'run_status', runId: data.runId, status: data.status, - note: 'Run state does not identify completion of this turn.' }); + if (turn.runId === data.runId) { + turn.runState = { runId: data.runId, status: data.status, source: 'activity:run_status' }; save(); + log({ event: 'run_status', runId: data.runId, status: data.status, + note: 'Run state does not identify completion of this turn or any handoff target.' }); + } }); async function connected(deadline) { while (!socket.connected) { @@ -343,6 +375,21 @@ export async function runClient(options, env = process.env, output = {}) { || observed.verdict.committedServerInstance !== 'current') { await recovery(turn); log({ event: 'follow_up_unavailable', note: 'A live current-process session was not confirmed.' }); return 2; } + if (turn.runState?.status === 'handed_off') { + log({ event: 'follow_up_unavailable', runId: turn.runId, status: 'handed_off', + note: 'The source Run was handed off. Pending text is retained; no target execution or source replay is inferred.' }); + return 2; + } + if (turn.runId) { + const runtime = await http(`/api/runs/${encodeURIComponent(turn.runId)}/runtime-state`); + if (runtime.status !== 200 || runtime.data.run_id !== turn.runId || runtime.data.agent_id !== state.agentId + || !['running', 'idle', 'completed', 'failed'].includes(runtime.data.status)) { + log({ event: 'follow_up_unavailable', runId: turn.runId, httpStatus: runtime.status, + note: 'Source runtime state is unavailable. Pending text is retained; a 404 can include a handed-off Run.' }); + return 2; + } + turn.runState = { runId: turn.runId, status: runtime.data.status, source: 'runtime-state' }; save(); + } const next = state.pendingFollowUps.shift(); turn = newTurn(next, 'follow-up', turn.sessionId, state.agentId, state.conversationId); state.turns.push(turn); save(); diff --git a/api/examples/agent-client/history.mjs b/api/examples/agent-client/history.mjs new file mode 100644 index 0000000..b1ce2ad --- /dev/null +++ b/api/examples/agent-client/history.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +// Read an administrator-provided DesireCore instance. Node.js 22+, no packages required. +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const HELP = `Usage: + node history.mjs list [--agent-id ID] [--include-archived] [--all-pages] + node history.mjs session RUN_ID + node history.mjs messages RUN_ID [--inspect-run] [--all-pages] + node history.mjs search --agent-id ID [--query TEXT] [--type all|image|file|tool] [--all-pages] + node history.mjs overview --agent-id ID + node history.mjs matter MATTER_ID --agent-id ID + +Environment: DESIRECORE_URL, DESIRECORE_TOKEN, optional DESIRECORE_AGENT_ID. +Pagination: --offset 0; --limit defaults to list=20, messages=100, search=50. +Search alone accepts --limit 0 (all remaining hits), --preferred-run-id ID, +and --preview-chars 1..8000. --all-pages is bounded by --max-pages 20. +HTTP: --timeout-ms 10000. Remote origins must use HTTPS. +Output: one JSON record per page; history may contain private conversation data. +Exit: 0 requested reads succeeded; 1 request/configuration failed; +2 an all-pages read stopped early or a matter response was truncated. +These endpoints do not provide a consistent multi-page history snapshot. +`; + +const PAGED = new Set(['list', 'messages', 'search']); +const NUMERIC = new Set(['limit', 'offset', 'maxPages', 'timeoutMs', 'previewChars']); +const VALUES = new Map([ + ['--agent-id', 'agentId'], ['--limit', 'limit'], ['--offset', 'offset'], + ['--query', 'query'], ['--type', 'type'], ['--preferred-run-id', 'preferredRunId'], + ['--preview-chars', 'previewChars'], ['--max-pages', 'maxPages'], ['--timeout-ms', 'timeoutMs'], +]); +const FLAGS = new Map([ + ['--all-pages', 'allPages'], ['--include-archived', 'includeArchived'], ['--inspect-run', 'inspectRun'], +]); + +export function parseHistoryArgs(argv) { + if (argv.includes('--help') || argv.includes('-h')) return { help: true }; + const [command, ...args] = argv; + const options = { command }; + for (let i = 0; i < args.length; i++) { + const argument = args[i]; + if (FLAGS.has(argument)) options[FLAGS.get(argument)] = true; + else if (VALUES.has(argument)) { + if (args[i + 1] === undefined || args[i + 1].startsWith('--')) throw new Error(`Missing value for ${argument}`); + options[VALUES.get(argument)] = args[++i]; + } else if (!argument.startsWith('-') && !options.id) options.id = argument; + else throw new Error(`Unknown argument: ${argument}`); + } + return options; +} + +function originFor(value) { + let url; + try { url = new URL(value); } catch { throw new Error('Set DESIRECORE_URL to the instance origin.'); } + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + throw new Error('DESIRECORE_URL must be an origin without credentials, a path, query, or fragment.'); + } + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) { + throw new Error('Use HTTPS remotely; HTTP is accepted only on loopback.'); + } + return url.origin; +} + +function identifier(value, label) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value)) throw new Error(`Use the server's ${label}, containing letters, digits, hyphens or underscores.`); + return value; +} + +function optionsFor(input, env) { + const options = { ...input }; + if (!['list', 'session', 'messages', 'search', 'overview', 'matter'].includes(options.command)) throw new Error(HELP); + options.agentId ??= env.DESIRECORE_AGENT_ID; + if (options.agentId !== undefined) identifier(options.agentId, 'Agent ID'); + if (['search', 'overview', 'matter'].includes(options.command) && !options.agentId) throw new Error('Set DESIRECORE_AGENT_ID or --agent-id.'); + if (['session', 'messages'].includes(options.command)) identifier(options.id, 'Run ID'); + else if (options.command === 'matter') { + if (typeof options.id !== 'string' || !options.id || /[\\/\u0000-\u001f\u007f]/.test(options.id) || options.id.includes('..') || options.id === '.') { + throw new Error('Use a matter ID returned by the conversation overview.'); + } + } else if (options.id !== undefined) throw new Error('This command does not take a positional ID.'); + for (const key of NUMERIC) { + if (options[key] !== undefined) { + if (typeof options[key] === 'string' && !/^(0|[1-9][0-9]*)$/.test(options[key])) throw new Error(`Invalid ${key}: use a decimal integer.`); + options[key] = Number(options[key]); + if (!Number.isSafeInteger(options[key]) || options[key] < 0) throw new Error(`Invalid ${key}.`); + } + } + options.limit ??= options.command === 'list' ? 20 : options.command === 'messages' ? 100 : 50; + options.offset ??= 0; + options.maxPages ??= 20; + options.timeoutMs ??= 10_000; + if ((options.limit === 0 && options.command !== 'search') || !Number.isSafeInteger(options.offset + options.limit)) throw new Error('Use a positive limit and a safe offset; only search permits limit=0.'); + if (options.maxPages < 1 || options.timeoutMs < 1) throw new Error('maxPages and timeoutMs must be positive.'); + if (options.previewChars !== undefined && (options.previewChars < 1 || options.previewChars > 8000)) throw new Error('previewChars must be 1..8000.'); + if (options.type !== undefined && !['all', 'image', 'file', 'tool'].includes(options.type)) throw new Error('type must be all, image, file or tool.'); + if (options.preferredRunId !== undefined) identifier(options.preferredRunId, 'preferred Run ID'); + if (options.includeArchived && options.command !== 'list') throw new Error('--include-archived applies only to list.'); + if (options.inspectRun && options.command !== 'messages') throw new Error('--inspect-run applies only to messages.'); + if (!PAGED.has(options.command) && (options.allPages || input.limit !== undefined || input.offset !== undefined)) throw new Error('This endpoint is not paginated.'); + if (options.command !== 'search' && ['query', 'type', 'preferredRunId', 'previewChars'].some(key => options[key] !== undefined)) throw new Error('Search filters apply only to search.'); + return options; +} + +function endpoint(options, offset) { + const query = new URLSearchParams(); + let path; + if (options.command === 'list') { + path = '/api/sessions'; + if (options.agentId) query.set('agentId', options.agentId); + if (options.includeArchived) query.set('includeArchived', 'true'); + } else if (options.command === 'search') { + path = '/api/sessions/messages/search'; + query.set('agentId', options.agentId); + for (const [key, value] of Object.entries({ q: options.query, type: options.type, preferredRunId: options.preferredRunId, previewChars: options.previewChars })) { + if (value !== undefined) query.set(key, String(value)); + } + } else if (options.command === 'overview') path = `/api/conversation/${encodeURIComponent(options.agentId)}/overview`; + else if (options.command === 'matter') path = `/api/conversation/${encodeURIComponent(options.agentId)}/matters/${encodeURIComponent(options.id)}/full`; + else { + path = `/api/sessions/${encodeURIComponent(options.id)}${options.command === 'messages' ? '/messages' : ''}`; + if (options.inspectRun) query.set('view', 'inspect-run'); + } + if (PAGED.has(options.command)) { query.set('offset', String(offset)); query.set('limit', String(options.limit)); } + return path + (query.size ? `?${query}` : ''); +} + +export async function runHistory(input, env = process.env, output = {}) { + const options = optionsFor(input, env); + const origin = originFor(env.DESIRECORE_URL); + const token = env.DESIRECORE_TOKEN; + if (typeof token !== 'string' || !token || /[\r\n]/.test(token)) throw new Error('Set DESIRECORE_TOKEN to an existing single-line instance access token.'); + const emit = output.emit ?? (record => process.stdout.write(`${JSON.stringify(record)}\n`)); + async function get(path) { + let response; + try { + response = await fetch(origin + path, { + method: 'GET', redirect: 'error', signal: AbortSignal.timeout(options.timeoutMs), + headers: { 'X-Agent-Service-Token': token, Accept: 'application/json' }, + }); + } catch { throw new Error('HTTP request failed or timed out; no automatic retry was made.'); } + if (!response.headers.get('content-type')?.includes('application/json')) throw new Error(`HTTP ${response.status}: expected a JSON API response.`); + let data; + try { data = await response.json(); } catch { throw new Error(`HTTP ${response.status}: invalid JSON response.`); } + if (!response.ok) { + const error = new Error(`HTTP ${response.status}: ${typeof data?.error === 'string' ? data.error : 'request rejected'}`); + error.status = response.status; + error.code = typeof data?.code === 'string' ? data.code : undefined; + throw error; + } + return data; + } + const handshake = await get('/api/client-handshake'); + if (handshake.service !== 'desirecore-agent-service' || handshake.apiProtocolVersion !== '1' || typeof handshake.instanceId !== 'string' || !handshake.instanceId) { + throw new Error('Unexpected service identity or API protocol; history was not requested.'); + } + let offset = options.offset, pages = 0, firstTotal; + while (true) { + const data = await get(endpoint(options, offset)); + if (!PAGED.has(options.command)) { + emit({ kind: options.command, instanceId: handshake.instanceId, data }); + return { exitCode: options.command === 'matter' && data?.truncated === true ? 2 : 0, pages: 1 }; + } + const field = options.command === 'list' ? 'sessions' : options.command === 'search' ? 'items' : 'messages'; + if (!Array.isArray(data?.[field]) || !Number.isSafeInteger(data.total) || data.total < 0 + || (options.command !== 'messages' && typeof data.hasMore !== 'boolean')) throw new Error('Unexpected history page response.'); + if (firstTotal !== undefined && data.total !== firstTotal) { + emit({ kind: 'stopped', reason: 'history-total-changed', offset, nextOffset: offset, previousTotal: firstTotal, observedTotal: data.total }); + return { exitCode: 2, pages, nextOffset: offset }; + } + firstTotal ??= data.total; + pages++; + const hasMore = options.command === 'messages' ? offset + options.limit < data.total : data.hasMore; + const nextOffset = offset + options.limit; + emit({ kind: options.command, instanceId: handshake.instanceId, offset, limit: options.limit, data }); + if (!hasMore || !options.allPages) return { exitCode: 0, pages, ...(hasMore && { nextOffset }) }; + if (pages >= options.maxPages || options.limit === 0 || !Number.isSafeInteger(nextOffset)) { + emit({ kind: 'stopped', reason: pages >= options.maxPages ? 'max-pages' : 'invalid-next-offset', nextOffset }); + return { exitCode: 2, pages, nextOffset }; + } + // Consume the requested source window even if bad rows were omitted from a messages page. + offset = nextOffset; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const options = parseHistoryArgs(process.argv.slice(2)); + if (options.help) process.stdout.write(HELP); + else process.exitCode = (await runHistory(options)).exitCode; + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/api/examples/agent-client/test/attachments.test.mjs b/api/examples/agent-client/test/attachments.test.mjs new file mode 100644 index 0000000..cb6fd58 --- /dev/null +++ b/api/examples/agent-client/test/attachments.test.mjs @@ -0,0 +1,128 @@ +// Local HTTP contract fixture; this does not run DesireCore storage or a model. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { createHash } from 'node:crypto'; +import { mkdtemp, writeFile, readFile, rm, open, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { uploadAttachment, validateAttachmentReceipt, AttachmentUploadError, MAX_ATTACHMENT_BYTES } from '../attachments.mjs'; + +const bytes = Buffer.from('Quarterly summary\n'); +const receipt = { attachmentId: '00000000-0000-4000-8000-000000000001', name: 'report.txt', mimeType: 'text/plain', + size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), fileReference: '/srv/attachments/owner/00000000-0000-4000-8000-000000000001/data' }; + +async function fixture(t, respond = (_request, response) => json(response, 201, receipt)) { + const directory = await mkdtemp(join(tmpdir(), 'desirecore-attachment-example-')); + const file = join(directory, 'report.txt'); + await writeFile(file, bytes); + const requests = []; + const server = createServer(async (request, response) => { + let body = Buffer.alloc(0); + for await (const chunk of request) body = Buffer.concat([body, chunk]); + requests.push({ url: request.url, method: request.method, headers: request.headers, body }); + respond(request, response, body); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + await rm(directory, { recursive: true, force: true }); + }); + return { directory, file, requests, baseUrl: `http://127.0.0.1:${server.address().port}`, token: 'fixture-only-token', mimeType: 'text/plain' }; +} +function json(response, status, value) { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(value)); } +function cli(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(new URL('../attachments.mjs', import.meta.url)), ...args], { env: { ...process.env, ...env } }); + let stdout = '', stderr = ''; + child.stdout.on('data', value => stdout += value); child.stderr.on('data', value => stderr += value); + child.on('error', reject); child.on('close', code => resolve({ code, stdout, stderr })); + }); +} + +test('uploads a real multipart request and validates the returned bytes/hash receipt', async t => { + const f = await fixture(t); + assert.deepEqual(await uploadAttachment(f), receipt); + assert.equal(f.requests.length, 1); + const request = f.requests[0]; + assert.equal(request.url, '/api/attachments'); assert.equal(request.method, 'POST'); + assert.equal(request.headers['x-agent-service-token'], f.token); + assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/); + assert.ok(request.body.includes(Buffer.from('name="file"; filename="report.txt"'))); + assert.ok(request.body.includes(Buffer.from('Content-Type: text/plain'))); + assert.ok(request.body.includes(bytes)); +}); + +for (const [status, code] of [[401, 'attachment_identity_required'], [413, 'attachment_too_large'], + [429, 'attachment_storage_limit'], [503, 'attachment_storage_unavailable']]) { + test(`preserves HTTP ${status} and machine error without retrying POST`, async t => { + const f = await fixture(t, (_request, response) => json(response, status, { error: code })); + await assert.rejects(uploadAttachment(f), error => error instanceof AttachmentUploadError && error.status === status && error.code === code); + assert.equal(f.requests.length, 1); + }); +} + +test('rejects a successful-looking receipt for different bytes or an unexpected wrapper', async t => { + const f = await fixture(t, (_request, response) => json(response, 201, { ...receipt, sha256: 'a'.repeat(64) })); + await assert.rejects(uploadAttachment(f), /does not match/); + assert.throws(() => validateAttachmentReceipt({ success: true, data: receipt }), /Invalid attachment receipt/); + assert.throws(() => validateAttachmentReceipt({ ...receipt, extra: true }), /Invalid attachment receipt/); + for (const invalid of [{ size: 0 }, { size: MAX_ATTACHMENT_BYTES + 1 }, { attachmentId: '../escape' }, + { fileReference: '' }, { sha256: 'A'.repeat(64) }]) { + assert.throws(() => validateAttachmentReceipt({ ...receipt, ...invalid }), /Invalid attachment receipt/); + } +}); + +test('does not mistake HTML or HTTP 200 for the upload success contract', async t => { + const f = await fixture(t, (_request, response) => { response.writeHead(200, { 'Content-Type': 'text/html' }); response.end('

SPA

'); }); + await assert.rejects(uploadAttachment(f), /expected JSON/); + const g = await fixture(t, (_request, response) => json(response, 200, receipt)); + await assert.rejects(uploadAttachment(g), error => error instanceof AttachmentUploadError && error.status === 200); +}); + +test('refuses redirects instead of forwarding an instance credential', async t => { + const f = await fixture(t, (_request, response) => { response.writeHead(307, { Location: '/other' }); response.end(); }); + await assert.rejects(uploadAttachment(f), /fetch failed/); + assert.equal(f.requests.length, 1); +}); + +test('rejects empty/oversized/local-directory inputs before sending HTTP', async t => { + const f = await fixture(t), empty = join(f.directory, 'empty'), large = join(f.directory, 'large'); + await writeFile(empty, ''); const handle = await open(large, 'w'); await handle.truncate(MAX_ATTACHMENT_BYTES + 1); await handle.close(); + for (const file of [empty, large, f.directory]) await assert.rejects(uploadAttachment({ ...f, file }), /non-empty regular file/); + assert.equal(f.requests.length, 0); +}); + +test('rejects non-HTTPS remote origins, embedded credentials, paths, and missing tokens', async t => { + const f = await fixture(t); + for (const baseUrl of ['http://example.com', 'https://user:password@example.com', f.baseUrl + '/api', f.baseUrl + '?token=secret']) { + await assert.rejects(uploadAttachment({ ...f, baseUrl }), /HTTPS instance origin/); + } + await assert.rejects(uploadAttachment({ ...f, token: '' }), /DESIRECORE_TOKEN/); + assert.equal(f.requests.length, 0); +}); + +test('CLI persists a direct private receipt and refuses to overwrite it or repeat the upload', async t => { + const f = await fixture(t), out = join(f.directory, 'receipt.json'); + const args = ['--file', f.file, '--out', out, '--mime', 'text/plain']; + const env = { DESIRECORE_URL: f.baseUrl, DESIRECORE_TOKEN: f.token }; + const first = await cli(args, env); + assert.equal(first.code, 0, first.stderr); assert.equal(first.stdout, ''); + assert.deepEqual(JSON.parse(await readFile(out, 'utf8')), receipt); + if (process.platform !== 'win32') assert.equal((await stat(out)).mode & 0o777, 0o600); + assert.ok(!first.stderr.includes(f.token)); + const second = await cli(args, env); + assert.equal(second.code, 1); assert.equal(f.requests.length, 1); + assert.deepEqual(JSON.parse(await readFile(out, 'utf8')), receipt); +}); + +test('CLI removes its uncompleted receipt after an upload rejection', async t => { + const f = await fixture(t, (_request, response) => json(response, 401, { error: 'unauthorized' })); + const out = join(f.directory, 'failed.json'); + const result = await cli(['--file', f.file, '--out', out], { DESIRECORE_URL: f.baseUrl, DESIRECORE_TOKEN: f.token }); + assert.equal(result.code, 1); await assert.rejects(stat(out), { code: 'ENOENT' }); + assert.equal(f.requests.length, 1); assert.ok(!result.stderr.includes(f.token)); +}); diff --git a/api/examples/agent-client/test/client.test.mjs b/api/examples/agent-client/test/client.test.mjs index 1c61185..bd54401 100644 --- a/api/examples/agent-client/test/client.test.mjs +++ b/api/examples/agent-client/test/client.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Server } from 'socket.io'; @@ -23,6 +23,7 @@ async function instance(scenario = 'normal') { if (url.pathname.endsWith('/runtime-state')) { observations.push('runtime-state'); const record = [...records.values()].find(record => url.pathname.includes(record.run.run_id)); + if (record?.run.status === 'handed_off') return json(response, 404, { error: 'Run runtime state not found: run-test' }); return json(response, record ? 200 : 404, record ? record.run : { error: 'Run not found' }); } if (url.pathname.endsWith('/receipt')) { @@ -53,7 +54,7 @@ async function instance(scenario = 'normal') { assert.equal(socket.handshake.auth.token, request.headers['x-agent-service-token']); const prompt = body.prompt ?? body.message.content; const messageId = body.userMessageId ?? body.message.id; - assert.equal(body.requestDigest, queryDigest(prompt, messageId)); + assert.equal(body.requestDigest, queryDigest(prompt, messageId, body.options?.fileReferences ?? body.fileReferences ?? [])); if (scenario === 'conflict') return json(response, 409, { ok: false, code: 'CLIENT_REQUEST_BODY_MISMATCH', retryable: false }); if (['lost-before-admission', 'expired', 'unknown'].includes(scenario) && requests.length === 1) { request.socket.destroy(); @@ -75,11 +76,12 @@ async function instance(scenario = 'normal') { } } })); if (scenario === 'missed-terminal') later(15, () => socket.conn.close()); later(25, () => { - run.status = 'idle'; + run.status = scenario.startsWith('handed-off') ? 'handed_off' : 'idle'; emit('query:message', { ...data, message: { type: 'result', status: 'completed_idle', text: 'not a terminal' } }); emit('query:message', { ...data, message: { type: 'user:message_delivery', message: 'delivery is not completion' } }); - emit('activity:run_status', { runId: run.run_id, status: 'idle' }); - if (scenario !== 'false-terminal-only') emit('query:complete', { + // The snapshot-only scenario deliberately misses the status event too. + if (scenario !== 'handed-off-snapshot') emit('activity:run_status', { runId: run.run_id, status: run.status }); + if (!['false-terminal-only', 'handed-off-no-terminal'].includes(scenario)) emit('query:complete', { ...data, terminal: { status: scenario === 'interrupted' ? 'interrupted' : 'completed', runId: run.run_id }, }); }); @@ -109,7 +111,14 @@ async function execute(t, scenario, extra = {}) { const filename = join(directory, 'request.json'); const logs = [], chunks = []; const env = { DESIRECORE_URL: service.origin, DESIRECORE_TOKEN: 'owner-test-token', DESIRECORE_AGENT_ID: 'agent-test' }; - const options = { state: filename, prompt: 'hello', followUps: [], timeoutMs: 300, httpTimeoutMs: 200, pollMs: 20, ...extra }; + const { receiptContents, ...overrides } = extra; + const attachmentReceipts = receiptContents?.map((receipt, index) => { + const file = join(directory, `attachment-${index}.json`); + writeFileSync(file, JSON.stringify(receipt)); + return file; + }); + const options = { state: filename, prompt: 'hello', followUps: [], timeoutMs: 300, httpTimeoutMs: 200, pollMs: 20, + ...(attachmentReceipts ? { attachmentReceipts } : {}), ...overrides }; const code = await runClient(options, env, { log: data => logs.push(data), text: data => chunks.push(data) }); return { service, filename, env, code, logs, chunks, state: JSON.parse(readFileSync(filename, 'utf8')) }; } @@ -166,6 +175,54 @@ test('missed terminal is recovered by read-only snapshots, without claiming comp assert.equal(result.state.turns[0].outcome, undefined); }); +test('handed_off observations do not finish a turn or start pending follow-ups', async t => { + const result = await execute(t, 'handed-off-no-terminal', { followUps: ['do not send'] }); + assert.equal(result.code, 2); + assert.equal(result.service.requests.length, 1); + assert.equal(result.state.turns[0].outcome, undefined); + assert.equal(result.state.turns[0].recovery.runtime.httpStatus, 404); + assert.equal(result.state.turns[0].runState.status, 'handed_off'); + assert.deepEqual(result.state.pendingFollowUps, ['do not send']); +}); + +test('an unavailable runtime snapshot prevents continuing a completed source turn after handoff', async t => { + const result = await execute(t, 'handed-off-snapshot', { followUps: ['retain this'] }); + assert.equal(result.code, 2); + assert.equal(result.state.turns[0].outcome.status, 'completed'); + assert.equal(result.service.requests.length, 1); + assert.equal(result.state.turns[0].runState, undefined); + assert.deepEqual(result.state.pendingFollowUps, ['retain this']); + assert.ok(result.logs.some(item => item.event === 'follow_up_unavailable' && item.httpStatus === 404)); +}); + +const attachmentReceipt = (id, fileReference) => ({ + attachmentId: `00000000-0000-4000-8000-${String(id).padStart(12, '0')}`, + name: `report-${id}.txt`, mimeType: 'text/plain', size: 7, sha256: 'a'.repeat(64), fileReference, +}); + +test('attachment receipts add ordered unique start references and participate in the frozen digest', async t => { + const first = attachmentReceipt(1, 'instance-attachment/first.txt'); + const second = attachmentReceipt(2, 'instance-attachment/second.txt'); + const result = await execute(t, 'lost-before-admission', { timeoutMs: 5000, receiptContents: [first, second, first] }); + assert.equal(result.code, 0); + assert.equal(result.service.requests.length, 2); + const body = result.service.requests[0].body; + assert.deepEqual(body.options.fileReferences, [first.fileReference, second.fileReference]); + assert.equal(body.fileReferences, undefined); + assert.notEqual(body.requestDigest, queryDigest(body.prompt, body.userMessageId)); + const { originSocketId: originalSocket, ...original } = body; + const { originSocketId: retrySocket, ...retry } = result.service.requests[1].body; + assert.notEqual(originalSocket, retrySocket); + assert.deepEqual(retry, original); + assert.deepEqual(result.state.turns[0].body.options.fileReferences, body.options.fileReferences); + assert.equal(await runClient({ recover: result.filename }, result.env, { log() {}, text() {} }), 2); +}); + +test('invalid or non-closed attachment receipts are rejected before query admission', async t => { + await assert.rejects(execute(t, 'normal', { receiptContents: [{ ...attachmentReceipt(1, 'file.txt'), size: 0 }] }), /Invalid attachment receipt/); + await assert.rejects(execute(t, 'normal', { receiptContents: [{ ...attachmentReceipt(1, 'file.txt'), extra: true }] }), /Invalid attachment receipt/); +}); + for (const scenario of ['unknown', 'expired']) test(`${scenario} admission never generates a new execution`, async t => { const result = await execute(t, scenario); assert.equal(result.code, 2); diff --git a/api/examples/agent-client/test/history.test.mjs b/api/examples/agent-client/test/history.test.mjs new file mode 100644 index 0000000..7f58a99 --- /dev/null +++ b/api/examples/agent-client/test/history.test.mjs @@ -0,0 +1,179 @@ +// Local HTTP contract fixture; actual DesireCore route coverage is recorded separately. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { parseHistoryArgs, runHistory } from '../history.mjs'; + +const token = 'history-owner-test-token'; +const handshake = { service: 'desirecore-agent-service', apiProtocolVersion: '1', instanceId: 'inst_history_test' }; +function json(response, status, body) { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); +} + +async function fixture(t, handler, customHandshake) { + const requests = []; + const server = createServer((request, response) => { + const url = new URL(request.url, 'http://localhost'); + requests.push({ method: request.method, url, headers: request.headers }); + if (request.headers['x-agent-service-token'] !== token) return json(response, 401, { error: 'unauthorized' }); + if (url.pathname === '/api/client-handshake') { + if (customHandshake) return customHandshake(request, response); + return json(response, 200, handshake); + } + handler(url, response); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); })); + return { requests, env: { DESIRECORE_URL: `http://127.0.0.1:${server.address().port}`, DESIRECORE_TOKEN: token } }; +} + +async function read(options, env) { + const records = []; + const result = await runHistory(options, env, { emit: value => records.push(value) }); + return { records, result }; +} + +test('lists bounded pages with owner authentication and preserves query scope', async t => { + const f = await fixture(t, (url, response) => { + assert.equal(url.pathname, '/api/sessions'); + assert.equal(url.searchParams.get('agentId'), 'agent-a'); + assert.equal(url.searchParams.get('includeArchived'), 'true'); + const offset = Number(url.searchParams.get('offset')); + json(response, 200, { sessions: [{ runId: `run-${offset}` }], total: 3, hasMore: offset === 0 }); + }); + const out = await read({ command: 'list', agentId: 'agent-a', includeArchived: true, limit: 2, allPages: true }, f.env); + assert.deepEqual(f.requests.slice(1).map(item => item.url.searchParams.get('offset')), ['0', '2']); + assert.ok(f.requests.every(item => item.method === 'GET')); + assert.equal(out.result.exitCode, 0); + assert.equal(out.records.length, 2); +}); + +test('message paging consumes the requested source window, including an empty damaged window', async t => { + const f = await fixture(t, (url, response) => { + assert.equal(url.pathname, '/api/sessions/run-child/messages'); + assert.equal(url.searchParams.get('view'), 'inspect-run'); + const offset = Number(url.searchParams.get('offset')); + json(response, 200, { messages: offset === 0 ? [] : [{ id: `m-${offset}`, lineIndex: offset, offset: offset * 100, role: 'assistant', content: 'saved' }], + total: 5, ...(offset === 0 && { live: { text: 'not durable', toolCalls: [], timestamp: 1 } }) }); + }); + const out = await read({ command: 'messages', id: 'run-child', inspectRun: true, limit: 2, allPages: true }, f.env); + assert.deepEqual(f.requests.slice(1).map(item => item.url.searchParams.get('offset')), ['0', '2', '4']); + assert.equal(out.result.pages, 3); + assert.equal(out.result.exitCode, 0); + assert.deepEqual(out.records[0].data.messages, []); + assert.equal(out.records[0].data.live.text, 'not durable'); +}); + +test('stops all-pages reads when the observed total changes', async t => { + const f = await fixture(t, (url, response) => { + const first = url.searchParams.get('offset') === '0'; + json(response, 200, { messages: [], total: first ? 3 : 4 }); + }); + const out = await read({ command: 'messages', id: 'run-a', limit: 2, allPages: true }, f.env); + assert.equal(out.result.exitCode, 2); + assert.equal(out.result.pages, 1); + assert.equal(out.records.at(-1).reason, 'history-total-changed'); + assert.equal(out.records.at(-1).nextOffset, 2); + assert.equal(f.requests.length, 3); +}); + +test('preserves Unicode search, filters, preferred branch and preview size over pages', async t => { + const f = await fixture(t, (url, response) => { + assert.equal(url.pathname, '/api/sessions/messages/search'); + assert.equal(url.searchParams.get('q'), '交付 & release'); + assert.equal(url.searchParams.get('type'), 'tool'); + assert.equal(url.searchParams.get('previewChars'), '40'); + assert.equal(url.searchParams.get('preferredRunId'), 'run-old'); + const offset = Number(url.searchParams.get('offset')); + json(response, 200, { items: [], total: 3, hasMore: offset === 0 }); + }); + const out = await read({ command: 'search', agentId: 'agent-a', query: '交付 & release', type: 'tool', preferredRunId: 'run-old', previewChars: 40, limit: 2, allPages: true }, f.env); + assert.deepEqual(f.requests.slice(1).map(item => item.url.searchParams.get('offset')), ['0', '2']); + assert.equal(out.result.exitCode, 0); +}); + +test('search limit=0 reads one unbounded result response without a zero-step loop', async t => { + const f = await fixture(t, (url, response) => { + assert.equal(url.searchParams.get('limit'), '0'); + assert.equal(url.searchParams.get('offset'), '2'); + json(response, 200, { items: [], total: 2, hasMore: false }); + }); + const out = await read({ command: 'search', agentId: 'agent-a', limit: 0, offset: 2, allPages: true }, f.env); + assert.equal(out.result.pages, 1); + assert.equal(out.result.exitCode, 0); + assert.equal(f.requests.length, 2); +}); + +test('bounds all-pages requests and reports the next offset', async t => { + const f = await fixture(t, (_url, response) => json(response, 200, { sessions: [], total: 10, hasMore: true })); + const out = await read({ command: 'list', limit: 2, maxPages: 2, allPages: true }, f.env); + assert.deepEqual(out.result, { exitCode: 2, pages: 2, nextOffset: 4 }); + assert.equal(out.records.at(-1).reason, 'max-pages'); + assert.equal(f.requests.length, 3); +}); + +test('uses the exact Run snapshot endpoint and leaves the public payload intact', async t => { + const snapshot = { runId: 'run-a', messages: [], pendingAssistant: { role: 'assistant', content: 'in progress' }, status: 'idle' }; + const f = await fixture(t, (url, response) => { assert.equal(url.pathname, '/api/sessions/run-a'); json(response, 200, snapshot); }); + const out = await read({ command: 'session', id: 'run-a' }, f.env); + assert.deepEqual(out.records[0].data, snapshot); + assert.equal(out.result.exitCode, 0); +}); + +test('overview and matter are unpaginated; a truncated matter exits 2', async t => { + const f = await fixture(t, (url, response) => { + if (url.pathname.endsWith('/overview')) return json(response, 200, { agentId: 'agent-a', activeMatters: [], recentMatters: [], totalMatters: 0, totalArchived: 0 }); + assert.equal(url.pathname, '/api/conversation/agent-a/matters/mat_a/full'); + json(response, 200, { matterId: 'mat_a', messages: [], truncated: true }); + }); + const overview = await read({ command: 'overview' }, { ...f.env, DESIRECORE_AGENT_ID: 'agent-a' }); + assert.equal(overview.result.exitCode, 0); + const matter = await read({ command: 'matter', id: 'mat_a', agentId: 'agent-a' }, f.env); + assert.equal(matter.result.exitCode, 2); + assert.ok(f.requests.every(item => !item.url.search)); +}); + +test('authentication failures stop at the handshake without printing the credential', async t => { + const f = await fixture(t, () => assert.fail('history must not be requested')); + await assert.rejects(runHistory({ command: 'list' }, { ...f.env, DESIRECORE_TOKEN: 'wrong-owner-token' }), error => { + assert.equal(error.status, 401); + assert.equal(error.message, 'HTTP 401: unauthorized'); + assert.ok(!error.message.includes('wrong-owner-token')); + return true; + }); + assert.equal(f.requests.length, 1); +}); + +test('reports delegated-view rejection and missing Run without retry or ID substitution', async t => { + const f = await fixture(t, (url, response) => json(response, url.pathname.includes('delegated') ? 403 : 404, + { error: url.pathname.includes('delegated') ? 'delegated_run_requires_inspect_view' : 'Session not found' })); + await assert.rejects(runHistory({ command: 'session', id: 'run-delegated' }, f.env), error => error.status === 403); + await assert.rejects(runHistory({ command: 'messages', id: 'missing-run' }, f.env), error => error.status === 404); + assert.deepEqual(f.requests.filter(item => !item.url.pathname.includes('handshake')).map(item => item.url.pathname), ['/api/sessions/run-delegated', '/api/sessions/missing-run/messages']); +}); + +test('rejects HTML and unsupported service identities before reading history', async t => { + const html = await fixture(t, () => assert.fail('unexpected read'), (_request, response) => { response.writeHead(200, { 'Content-Type': 'text/html' }); response.end(''); }); + await assert.rejects(runHistory({ command: 'list' }, html.env), /expected a JSON API response/); + const other = await fixture(t, () => assert.fail('unexpected read'), (_request, response) => json(response, 200, { ...handshake, service: 'another-service' })); + await assert.rejects(runHistory({ command: 'list' }, other.env), /Unexpected service identity/); +}); + +test('does not follow a redirect carrying the instance credential', async t => { + const f = await fixture(t, () => assert.fail('redirect must not be followed'), (_request, response) => { + response.writeHead(302, { Location: '/credential-target' }); response.end(); + }); + await assert.rejects(runHistory({ command: 'list' }, f.env), /no automatic retry/); + assert.equal(f.requests.length, 1); +}); + +test('validates flags, IDs, finite pagination and HTTPS before network access', async () => { + assert.deepEqual(parseHistoryArgs(['messages', 'run-a', '--inspect-run', '--limit', '2']), { command: 'messages', id: 'run-a', inspectRun: true, limit: '2' }); + assert.deepEqual(parseHistoryArgs(['--help']), { help: true }); + assert.throws(() => parseHistoryArgs(['list', '--wat']), /Unknown/); + for (const options of [{ command: 'list', limit: 0 }, { command: 'list', offset: '-1' }, { command: 'messages', id: '../other' }, { command: 'session', id: 'run-a', allPages: true }, { command: 'search', agentId: 'a', previewChars: 8001 }]) { + await assert.rejects(runHistory(options, {})); + } + await assert.rejects(runHistory({ command: 'list' }, { DESIRECORE_URL: 'http://remote.example', DESIRECORE_TOKEN: token }), /Use HTTPS/); +}); diff --git a/api/examples/agent-client/test/source-history.fixture.ts b/api/examples/agent-client/test/source-history.fixture.ts new file mode 100644 index 0000000..32de3dc --- /dev/null +++ b/api/examples/agent-client/test/source-history.fixture.ts @@ -0,0 +1,122 @@ +/** 复制到固定源码的 packages/agent-service/src/__tests__/api-docs-history.test.ts 后运行。 + * 真实 HTTP 鉴权、历史路由、SessionStore 和临时文件;运行时发现与本机用户配置为替身。 + * 不启动模型,不读取个人数据。 + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fastify, { type FastifyInstance } from 'fastify' +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const { rootRef } = vi.hoisted(() => ({ rootRef: { current: '' } })) +vi.mock('../agent/agentfs', async importOriginal => ({ + ...await importOriginal(), + getRootDir: () => rootRef.current, + readUserProfile: () => ({ id: 'docs-owner' }), + ensureUserProfile: () => ({ id: 'docs-owner' }), +})) +vi.mock('../runtime', () => ({ getAllActiveSessions: () => [], getRuntime: () => ({ registerPersistentSessionReference: vi.fn() }) })) + +import { instanceAuthOnRequest, INSTANCE_TOKEN_HEADER } from '../auth/instance-auth' +import { registerHistoryRoutes } from '../routes/history-routes' +import { registerConversationRoutes } from '../routes/conversation-routes' +import { registerRunsRoutes } from '../routes/runs-routes' + +const token = 'docs-history-fixture-token' +const headers = { [INSTANCE_TOKEN_HEADER]: token } +let app: FastifyInstance + +function createRun(runId: string, userId = 'docs-owner', extra: Record = {}) { + const directory = join(rootRef.current, 'runs', runId) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'meta.json'), JSON.stringify({ + runId, userId, agentId: 'docs-agent', engine: 'desirecore-engine', options: {}, + status: 'completed', messageCount: 2, createdAt: '2026-10-15T00:00:00.000Z', updatedAt: '2026-10-15T00:01:00.000Z', ...extra, + })) + writeFileSync(join(directory, 'messages.jsonl'), [ + { id: 'docs-user-message', role: 'user', content: 'needle attachment history', timestamp: 1 }, + { id: 'docs-assistant-message', role: 'assistant', content: 'needle answer', timestamp: 2, + providerReplay: { version: 1, turns: [{ blocks: [{ type: 'thinking', thinking: 'PRIVATE-REPLAY', signature: 'PRIVATE-SIGNATURE' }] }] } }, + ].map(value => JSON.stringify(value)).join('\n') + '\n') +} + +describe('公开历史 API 的实际路由合同', () => { + beforeEach(async () => { + rootRef.current = mkdtempSync(join(tmpdir(), 'dc-api-docs-history-')) + vi.stubEnv('DESIRECORE_TEST_ROOT', rootRef.current) + vi.stubEnv('AGENT_SERVICE_TOKEN', token) + vi.stubEnv('AGENT_SERVICE_MODE', 'standalone') + app = Fastify() + app.addHook('onRequest', instanceAuthOnRequest) + await registerHistoryRoutes(app) + await registerConversationRoutes(app) + await registerRunsRoutes(app) + }) + afterEach(async () => { + await app.close() + rmSync(rootRef.current, { recursive: true, force: true }) + vi.unstubAllEnvs() + }) + + it('六个公开读取入口均由实例鉴权保护', async () => { + for (const url of ['/api/sessions', '/api/sessions/docs-run', '/api/sessions/docs-run/messages', '/api/sessions/messages/search?agentId=docs-agent', '/api/conversation/docs-agent/overview', '/api/conversation/docs-agent/matters/docs-matter/full']) { + expect((await app.inject({ method: 'GET', url })).statusCode).toBe(401) + expect((await app.inject({ method: 'GET', url, headers: { [INSTANCE_TOKEN_HEADER]: 'wrong-token' } })).statusCode).toBe(401) + } + }) + + it('列表过滤用户,详情与分页读取真实记录并去除私有重放数据', async () => { + createRun('docs-run') + createRun('other-run', 'other-user') + const list = await app.inject({ method: 'GET', url: '/api/sessions?agentId=docs-agent&limit=20&offset=0', headers }) + expect(list.statusCode).toBe(200) + expect(list.json().total).toBe(1) + expect(list.json().sessions[0].runId).toBe('docs-run') + for (const suffix of ['', '/messages?offset=0&limit=2']) { + const response = await app.inject({ method: 'GET', url: '/api/sessions/docs-run' + suffix, headers }) + expect(response.statusCode).toBe(200) + expect(response.json().messages).toHaveLength(2) + expect(response.body).toContain('needle answer') + expect(response.body).not.toMatch(/providerReplay|PRIVATE-REPLAY|PRIVATE-SIGNATURE/) + expect(response.headers['cache-control']).toBe('private, no-store') + } + expect((await app.inject({ method: 'GET', url: '/api/sessions/other-run/messages', headers })).statusCode).toBe(404) + expect((await app.inject({ method: 'GET', url: '/api/sessions/missing-run', headers })).statusCode).toBe(404) + }) + + it('搜索使用真实历史,缺少Agent或非法分页返回400', async () => { + createRun('search-run') + const response = await app.inject({ method: 'GET', url: '/api/sessions/messages/search?agentId=docs-agent&q=needle&limit=1&offset=0&previewChars=20', headers }) + expect(response.statusCode).toBe(200) + expect(response.json().items).toHaveLength(1) + expect(response.json().total).toBe(2) + expect(response.json().hasMore).toBe(true) + for (const query of ['', '?agentId=docs-agent&limit=-1', '?agentId=docs-agent&previewChars=8001']) { + expect((await app.inject({ method: 'GET', url: '/api/sessions/messages/search' + query, headers })).statusCode).toBe(400) + } + }) + + it('Run 详情保留转交事实,runtime-state 的现行四值过滤对该状态返回404', async () => { + const runId = 'handed-off-run' + const directory = join(rootRef.current, 'runs', runId) + mkdirSync(directory, { recursive: true }) + writeFileSync(join(directory, 'meta.json'), JSON.stringify({ + run_id: runId, user_id: 'docs-owner', agent_id: 'docs-agent', + started_at: '2026-10-15T00:00:00.000Z', status: 'handed_off', + handoffTo: 'target-run', handoff_attempted: true, handoff_accepted: true, + })) + const meta = await app.inject({ method: 'GET', url: '/api/runs/' + runId, headers }) + expect(meta.statusCode).toBe(200) + expect(meta.json()).toMatchObject({ status: 'handed_off', handoffTo: 'target-run', handoff_attempted: true, handoff_accepted: true }) + const state = await app.inject({ method: 'GET', url: '/api/runs/' + runId + '/runtime-state', headers }) + expect(state.statusCode).toBe(404) + expect(state.json()).toEqual({ error: 'Run runtime state not found: ' + runId }) + }) + + it('无话题时总览仍返回结构,未知话题完整读取返回404', async () => { + const overview = await app.inject({ method: 'GET', url: '/api/conversation/docs-agent/overview', headers }) + expect(overview.statusCode).toBe(200) + expect(overview.json()).toHaveProperty('activeMatters') + expect((await app.inject({ method: 'GET', url: '/api/conversation/docs-agent/matters/missing-matter/full', headers })).statusCode).toBe(404) + }) +}) diff --git a/api/examples/agent-client/test/source-route.fixture.ts b/api/examples/agent-client/test/source-route.fixture.ts index f1c85a8..01a641a 100644 --- a/api/examples/agent-client/test/source-route.fixture.ts +++ b/api/examples/agent-client/test/source-route.fixture.ts @@ -1,13 +1,15 @@ /** Copy into desirecore/packages/agent-service/src/__tests__/ as api-docs-node-client.integration.test.ts. - * Real Fastify, query routes, auth, digest, durable registry, lookup, delivery room and HTTP/Socket.IO. + * Real Fastify, attachment upload/storage/download, query routes, auth, digest, + * durable registry, lookup, delivery room and HTTP/Socket.IO. * The query pipeline, follow-up runtime callback and Run snapshots are fake; no LLM/tools run. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import fastify from 'fastify' +import multipart from '@fastify/multipart' import { Server } from 'socket.io' const h = vi.hoisted(() => ({ io: null as any, pipeline: vi.fn(), seen: new Set() })) @@ -31,6 +33,7 @@ vi.mock('../service-metadata', () => ({ import { registerQueryRoutes } from '../routes/query-routes' import { registerClientRequestLookupRoutes } from '../routes/client-request-lookup-routes' import { registerClientHandshakeRoutes } from '../routes/client-handshake-routes' +import { registerAttachmentRoutes } from '../routes/attachment-routes' import { instanceAuthOnRequest, verifySocketHandshakeAuth, resolveIdentity, clearEmbeddedInstanceToken, __resetHostedEmbeddedAuthForTest } from '../auth/instance-auth' import { bindRendererDeliverySocket, resetRendererDeliveryRegistry } from '../handlers/renderer-delivery' import { SocketEventSink } from '../handlers/query-event-sink' @@ -54,9 +57,18 @@ describe('public Node client with real admission routes and fake query runtime', ensureInstanceIdentity() app = fastify({ logger: false }) app.addHook('onRequest', instanceAuthOnRequest) + await app.register(multipart) await registerClientHandshakeRoutes(app) + await registerAttachmentRoutes(app) await registerQueryRoutes(app) await registerClientRequestLookupRoutes(app) + // Only a fake read snapshot for the client's continuation precheck. The real + // Run route is covered separately; its successful wire shape has three fields. + app.get('/api/runs/:id/runtime-state', async (request, reply) => { + const { id } = request.params as { id: string } + if (id !== 'run-fixture') return reply.code(404).send({ error: `Run runtime state not found: ${id}` }) + return { run_id: 'run-fixture', agent_id: 'agent-fixture', status: 'idle' } + }) h.io = new Server(app.server, { path: '/socket.io/' }) h.io.use((socket: any, next: (error?: Error) => void) => { const decision = verifySocketHandshakeAuth(socket.handshake.auth.token) @@ -104,13 +116,25 @@ describe('public Node client with real admission routes and fake query runtime', for (const key of keys) if (env[key] === undefined) delete process.env[key]; else process.env[key] = env[key] rmSync(temporary, { recursive: true, force: true }) }) - it('runs the CLI through real start/follow-up and durable admission lookup, without a model', async () => { + it.each([false, true])('runs real start/follow-up and digest admission, with attachment receipts=%s and no model', async (withAttachments) => { const modulePath = process.env.DESIRECORE_API_EXAMPLE_PATH if (!modulePath) throw new Error('Set DESIRECORE_API_EXAMPLE_PATH to the public example client.mjs') const { runClient } = await import(/* @vite-ignore */ pathToFileURL(modulePath).href) const address = app.server.address() as { port: number } const stateFile = join(temporary, 'request.json'), chunks: string[] = [] - const code = await runClient({ state: stateFile, prompt: 'hello', followUps: ['again'], timeoutMs: 3000, httpTimeoutMs: 1000, pollMs: 20 }, { + const fileReferences = withAttachments ? ['fixture-attachment/first.txt', 'fixture-attachment/second.txt'] : [] + const attachmentReceipts = withAttachments ? [0, 1, 0].map((index, sequence) => { + const file = join(temporary, `receipt-${sequence}.json`) + // Schema-valid receipts are local fixtures, not successful upload evidence. + writeFileSync(file, JSON.stringify({ + attachmentId: `00000000-0000-4000-8000-00000000000${index + 1}`, + name: `report-${index + 1}.txt`, mimeType: 'text/plain', size: 7, + sha256: 'a'.repeat(64), fileReference: fileReferences[index], + })) + return file + }) : [] + const code = await runClient({ state: stateFile, prompt: 'hello', followUps: ['again'], attachmentReceipts, + timeoutMs: 3000, httpTimeoutMs: 1000, pollMs: 20 }, { DESIRECORE_URL: `http://127.0.0.1:${address.port}`, DESIRECORE_TOKEN: 'owner-test-token', DESIRECORE_AGENT_ID: 'agent-fixture', }, { log() {}, text: (value: string) => chunks.push(value) }) expect(code).toBe(0) @@ -118,10 +142,52 @@ describe('public Node client with real admission routes and fake query runtime', const state = JSON.parse(readFileSync(stateFile, 'utf8')) expect(state.turns).toHaveLength(2) expect(state.turns.map((turn: any) => turn.outcome.status)).toEqual(['completed', 'completed']) - expect(state.turns[0].body.requestDigest).toBe(computeQueryRequestDigest({ prompt: 'hello', userMessageId: state.turns[0].userMessageId })) + expect(state.turns[0].body.requestDigest).toBe(computeQueryRequestDigest({ + prompt: 'hello', userMessageId: state.turns[0].userMessageId, fileReferences, + })) + expect(state.turns[0].body.options.fileReferences ?? []).toEqual(fileReferences) + expect(state.turns[0].body.fileReferences).toBeUndefined() + expect(state.turns[1].body.fileReferences).toBeUndefined() + expect(h.pipeline.mock.calls[0][0].options.fileReferences ?? []).toEqual(fileReferences) expect(state.turns[0].admission.status).toBe('committed') expect(h.pipeline).toHaveBeenCalledTimes(1) const denied = await app.inject({ method: 'POST', url: '/api/query/start', body: state.turns[0].body }) expect(denied.statusCode).toBe(401) }, 15000) + + it('uses the public uploader receipt in real query admission and downloads identical bytes', async () => { + const modulePath = process.env.DESIRECORE_API_EXAMPLE_PATH + if (!modulePath) throw new Error('Set DESIRECORE_API_EXAMPLE_PATH to the public example client.mjs') + const { runClient } = await import(/* @vite-ignore */ pathToFileURL(modulePath).href) + const { uploadAttachment } = await import(/* @vite-ignore */ new URL('./attachments.mjs', pathToFileURL(modulePath)).href) + const address = app.server.address() as { port: number } + const origin = `http://127.0.0.1:${address.port}` + const bytes = Buffer.from('temporary attachment for API docs\n') + const file = join(temporary, 'report.txt'), receiptFile = join(temporary, 'receipt.json') + writeFileSync(file, bytes) + const receipt = await uploadAttachment({ baseUrl: origin, token: 'owner-test-token', file, mimeType: 'text/plain' }) + expect(receipt.size).toBe(bytes.length) + expect(readFileSync(receipt.fileReference)).toEqual(bytes) + writeFileSync(receiptFile, JSON.stringify(receipt), { mode: 0o600 }) + const stateFile = join(temporary, 'upload-query.json') + const code = await runClient({ state: stateFile, prompt: 'summarize attached report', attachmentReceipts: [receiptFile], + followUps: [], timeoutMs: 3000, httpTimeoutMs: 1000, pollMs: 20 }, { + DESIRECORE_URL: origin, DESIRECORE_TOKEN: 'owner-test-token', DESIRECORE_AGENT_ID: 'agent-fixture', + }, { log() {}, text() {} }) + expect(code).toBe(0) + const state = JSON.parse(readFileSync(stateFile, 'utf8')), turn = state.turns[0] + expect(turn.body.options.fileReferences).toEqual([receipt.fileReference]) + expect(turn.body.fileReferences).toBeUndefined() + expect(turn.body.requestDigest).toBe(computeQueryRequestDigest({ + prompt: turn.body.prompt, userMessageId: turn.userMessageId, fileReferences: [receipt.fileReference], + })) + expect(h.pipeline.mock.calls[0][0].options.fileReferences).toEqual([receipt.fileReference]) + expect(turn.outcome.status).toBe('completed') + const downloaded = await fetch(`${origin}/api/attachments/${receipt.attachmentId}`, { + headers: { 'X-Agent-Service-Token': 'owner-test-token' }, redirect: 'error', + }) + expect(downloaded.status).toBe(200) + expect(downloaded.headers.get('content-type')).toBe('application/octet-stream') + expect(Buffer.from(await downloaded.arrayBuffer())).toEqual(bytes) + }, 15000) }) diff --git a/api/history-attachments-verification.md b/api/history-attachments-verification.md new file mode 100644 index 0000000..5f14cd1 --- /dev/null +++ b/api/history-attachments-verification.md @@ -0,0 +1,68 @@ +# Run、会话历史与附件媒体验证 + +核查日期:2026-10-15。本记录对应本批公开文档 PR;不改写既有部署或搜索验收结果。 + +## 固定来源与范围 + +| 项目 | 固定版本 | +|---|---| +| 源码 `desirecore/desirecore` 的 `dev` | `7fe016c76ab98e0643ab4f2710c3733f757ef4fb` | +| 源码中的内部文档 gitlink | `b17e6a7a2d3dc4d969ea422ee37825ae39eb6177` | +| 公开文档 `main` 基线 | `3e0e3de7168e2392802d37d295f73a65af070f5e` | + +新增六个会话历史/消息/话题读取操作、五个附件/媒体操作。加上原有24个,共35个操作、10组双语页面。Agent Service规范为27个操作;Compat Proxy的3个和Workflow Service的5个保留原来源基线。未公开管理写入、内部同步查询、媒体上游代理、原始历史追加或受额外授权门控的Workflow执行。 + +原有16个Agent Service操作沿固定源码重新核对来源。自公开基线以来,所选接口的请求、认证和Query摘要合同保持一致;相关文件中未公开的删除、压缩等逻辑变更另行核对。本批范围内的契约差异主要是Run转交语义和历史投影行为。清单中的历史测试说明保留原测试基线;更新来源SHA不代表所有既有接口在此新SHA上全部重跑。 + +## 关键合同与更正 + +### Run状态必须看实际HTTP路由 + +`packages/schemas/src/agent-service/run.ts`共享状态枚举已有`handed_off`,Run详情和列表按该元信息呈现转交事实。但`packages/agent-service/src/routes/runs-routes.ts`的`RUNTIME_STATE_STATUSES`仍仅四值,`GET /api/runs/:id/runtime-state`会对已转交Run返回404。上一轮覆盖审计仅从共享Schema推断该HTTP入口也支持五态,这项推断在本批按实际路由和实测更正。 + +公开RunMeta使用五值;HTTP RunRuntimeState保留四值并说明404。只读转交字段不作为请求参数、续接凭据或完成证据。示例遇到已观测转交,或续问前无法取得来源Run的运行态时保留待发文本并停止;不自动追踪目标执行。 + +### 历史与话题 + +- `history-routes.ts`:列表、搜索、精确Run读取使用可信用户归属;委派完整快照返回403,消息仅显式`view=inspect-run`可读取。 +- `conversation-routes.ts`:两个话题入口使用当前本机profile用户;公开用途限定部署所有者,不承诺共享服务多租户隔离。 +- 逐Run分页的请求offset是源行位置,消息中的offset是字节位置;返回消息可能少于源窗口,按请求limit推进。公开投影不是持久协议数据的无损镜像。 +- 搜索参数单独校验;limit=0表示剩余全部命中。话题消息最多120条且有truncated标记,没有续页参数。 +- 不存在跨页一致性版本。示例发现total变化会停止,但不能检测总数不变的内容变化。 + +### 附件与媒体 + +- `attachment-routes.ts`和`attachments.ts`:单个非空文件、20MiB、用户配额,返回直接收据;下载按可信用户隔离。 +- 通用附件收据中的fileReference原样用于Query start的`options.fileReferences`。客户端去重保序、计入qrd1摘要并冻结至恢复/重试;后续文本续问不自动重附文件。 +- `media-routes.ts`及签名认证:上传和签发接口使用实例认证;原生`/media/:filename` GET/HEAD精确路径使用expires和signature,五分钟有效、允许重复及单段Range。 +- 新媒体核对owner;无owner侧车的历史媒体保留兼容读取,不夸大历史隔离保证。应用身份、Origin和readiness仍有独立检查。 +- 默认Caddy已转发`/media/*`;自定义代理须保留该路径、签名查询参数和Range。 + +## 已完成定向验证 + +| 验证层 | 结果 | 实际覆盖与限制 | +|---|---|---| +| 固定源码既有定向用例 | 5文件、47项通过 | run-meta-read 26、附件真实存储/下载1、媒体路由13、签名6、话题公开投影1 | +| 本批历史/Run源码夹具 | 1文件、5项通过 | 六入口无/错误token拒绝、列表归属、完整/分页消息与私有重放字段移除、真实搜索及非法参数、空话题/404、Run详情handed_off及runtime-state404 | +| 公开客户端与源码集成 | 1文件、3项通过 | 文本start/follow-up、附件引用与源码摘要、真实上传helper→multipart存储→Query准入→下载原字节 | +| 独立Node示例 | 3文件、41项通过 | 对话16、附件12、历史13;实际回环HTTP/Socket连接,服务响应或执行为夹具 | + +源码验证使用隔离的固定提交checkout,子模块已初始化并fetch,依赖复用既有本机安装。历史夹具仅替换本机用户配置及运行时发现,鉴权、路由和SessionStore使用实际代码与临时数据。客户端源码集成中的握手、模型管线、续问回调及Run快照仍为替身;附件存储与Query准入是真实实现。没有运行商业模型、真实会话转交或用户业务操作。 + +可维护的源码夹具在`api/examples/agent-client/test/source-history.fixture.ts`与`source-route.fixture.ts`;按示例README复制到对应源码测试目录执行。前者使用unit项目,后者使用integration项目并设置`DESIRECORE_API_EXAMPLE_PATH`。两者在网站tsconfig中精确排除;不把私有源码导入混进网站编译。 + +## 文档与规范验证 + +- `pnpm api:check`:10组双语页、35操作、66个JSON代码块、3份规范/35操作,通过。 +- `pnpm typecheck`、双语`pnpm build`、`pnpm api:check -- --build`通过;本地HTML、canonical、语言链接、sitemap及所有规范/三份Node下载副本一致。 +- Redocly CLI `2.63.0`校验三份OpenAPI,0错误。剩余50条告警为既有47条条件Schema属性告警和3份规范未填license的告警;未将有告警写成零告警。 +- AJV Draft 7编译68个组件Schema、验证44个JSON媒体示例通过;包括历史页的7种响应示例。另附件媒体Schema定向8正例、6反例通过,双语样例逐值一致。 +- 独立审阅发现并修正两处文字/输出一致性:附件引用在Query start中应为`options.fileReferences`;历史total变化的停止记录补`nextOffset`。后者13项测试重新通过,最终全部独立示例41项再次通过。 +- 本机生产构建浏览器检查:中英新增历史/附件页在375像素视口无整页水平溢出;1440像素下历史页与OpenAPI页无整页溢出。中文历史页语言链接指向对应英文页。英文OpenAPI页实际下载均200且操作数为27/3/5;history下载200。 +- 将示例和OpenAPI下载链接使用`pathname:`保留为稳定静态路径,避免被构建器替换为带内容哈希的资产文件名;每种语言的下载字节另由构建检查核对。 + +构建保留既有Browserslist数据过期和语言服务器动态require提示。未更新无关依赖。未声明剪贴板内容、商业模型附件处理、桌面发行安装包或生产部署已验收。 + +## 发布边界 + +本批只提交PR。`publication.json`、`production-verification.json`和搜索验收JSON继续表示已发布批次,不把本地构建、PR预览或URL Tester当作生产发布和搜索收录。合并后自动部署,随后另做生产页面、规范下载和每日爬虫索引验收。 diff --git a/api/verification.md b/api/verification.md index 042cb8f..dfd4f78 100644 --- a/api/verification.md +++ b/api/verification.md @@ -1,3 +1,9 @@ +# 当前扩展验证入口 + +Run状态、会话历史与消息、附件媒体的新增实现见[本批核查与验证](./history-attachments-verification.md)。下面保留既有已发布批次的来源和运行结果;其中8组页面、24个操作的数字属于当时范围,不代表本批扩展后的总数。 + +--- + # API 完整实施核查与验证 更新日期:2026-10-15。本次在首批基础上实现全部选定公开能力;以下验证按层记录,不将本机假上游、真实服务 listener 与商业模型或安装包验收混为一项。 diff --git a/docs/07-api/01-connection-and-auth.md b/docs/07-api/01-connection-and-auth.md index cc486e5..9f9a29b 100644 --- a/docs/07-api/01-connection-and-auth.md +++ b/docs/07-api/01-connection-and-auth.md @@ -127,6 +127,8 @@ Cache-Control: no-store `GET /health` 供直连服务监听地址的探针使用,不要求实例访问令牌;它只报告服务状态。在默认 standalone 反向代理部署中,该路径没有作为对外 API 转发,外部调用使用上面的认证握手。 +附件和历史接口沿用 `/api/*` 的实例认证。媒体播放链接使用独立的 `/media/*` 路径;默认部署会转发该路径,自定义代理也需要保留它及签名查询参数,并支持 `Range` 请求。详见[附件与媒体](./09-attachments-and-media.md)。 + 直连探针在启动未就绪时也会收到 `503 service_not_ready`;可以接收请求后,响应示例如下: ```json diff --git a/docs/07-api/04-conversations-and-runs.md b/docs/07-api/04-conversations-and-runs.md index d4ab6a9..750b4d0 100644 --- a/docs/07-api/04-conversations-and-runs.md +++ b/docs/07-api/04-conversations-and-runs.md @@ -129,7 +129,7 @@ curl --silent --show-error \ |---|---| | `run_id`、`user_id`、`agent_id` | 必需字符串;Run、发起用户与执行智能体的标识 | | `started_at` | 必需 ISO 8601 时间,表示 Run 首次创建时间;同一 Run 可跨多轮复用 | -| `status` | 必需,`running`、`idle`、`completed`、`failed` | +| `status` | 必需,`running`、`idle`、`completed`、`failed`、`handed_off` | | `conversation_id`、`session_id` | 可选,对话与实时运行会话标识;不要与一次提交的请求 ID 混用 | | `turn_started_at`、`completed_at`、`updatedAt` | 可选,当前轮次开始、Run 完成和最近活动时间 | | `failure_reason` | 失败原因可能为 `process_restart`、`timeout`、`stale`、`error` | @@ -138,10 +138,15 @@ curl --silent --show-error \ | `background`、`inbound` | 可选当前轮次身份信息,分别描述后台处理和来自另一个智能体的消息 | | `title`、`messageCount`、`archived_at`、`cleared` | 可选标题、消息数量和历史生命周期信息 | | `team_id`、`work_context_id`、`execution_attachment` | 可选执行归属信息;读取这些字段不授予相应资源权限 | +| `handoffFrom`、`handoffTo` | 可选字符串,转交来源与目标 Run ID;用于关联记录,不授予目标 Run 的读取或续接权限 | +| `handoff_pending` | 可选字符串,服务端记录的进行中转交标记;不是调用凭据,也不能用来发起或重放转交 | +| `handoff_attempted`、`handoff_accepted` | 可选布尔值,分别记录目标已进入可能产生副作用的启动阶段、目标运行时已接纳转交;均不表示目标业务已完成 | 响应还可能包含上下文、恢复和工作目录等可选元信息。缺席字段表示没有这项记录;不要据此补造完成时间、失败原因或执行归属。 -`idle` 表示当前未在执行且可以等待后续对话,不等于整项业务已经成功。中止后的 Run 也可能回到 `idle`。`completed`、`failed` 是 Run 的生命周期状态,判断某次提交仍应关联该轮的终态和回执。 +`idle` 表示当前未在执行,不等于整项业务已经成功,也不单独保证可以续问;中止后的 Run 也可能回到 `idle`。`handed_off` 表示该 Run 的会话已转交,不是 `completed` 的别名,也不证明目标 Run 已完成。`completed`、`failed` 和 `handed_off` 都描述 Run 的生命周期;判断某次提交仍应关联该轮的终态和回执。 + +转交字段都是只读观察信息。看到 `handoff_pending` 时不要自动重放来源工作;看到 `handed_off` 时不要继续向来源运行会话自动续问。可以在有权限时按 `handoffTo` 另行读取目标 Run,但读取链路不能替代目标的接纳与终态证据,缺席字段也不能证明没有发生转交。 部分历史分支可在列表中出现,而此详情端点返回 `404`。需要判断运行态或读取回执时,分别使用下面的端点,并按它们自己的读取结果处理。 @@ -165,7 +170,9 @@ curl --silent --show-error \ } ``` -`status` 只接受 `running`、`idle`、`completed`、`failed`。状态不能读取或不在支持范围时返回 `404`。`running` 不会被改成 `idle`,但这仍是一份快照;它不授权取消、回滚或其它修改操作,也不保证接下来提交时状态未变化。 +此端点成功返回的 `status` 只包含 `running`、`idle`、`completed`、`failed`,`running` 不会被改成 `idle`。已转交的 Run 当前会返回 `404`,不会从此端点返回 `handed_off` 或归一化后的 `completed`。其它无法读取或不支持的状态也返回 `404`,因此不能仅凭 `404` 判断记录不存在或任务失败。 + +此端点不返回 `handoffTo` 等转交详情;需要查看链路时使用 Run 详情,并处理该端点自己的可读范围。这仍是一份快照,不授权取消、回滚或其它修改操作,也不保证接下来提交时状态未变化。 ## 读取执行回执 {#list-run-receipts} @@ -238,7 +245,7 @@ curl --silent --show-error --get \ | Run 列表与分页回执 | `400`,`error` 以 `Invalid pagination:` 开头 | 使用单个符合要求的十进制整数参数 | | Run 详情 | `404`,`error: "Run not found: ..."` | 核对 Run ID 和详情可读范围 | | 运行态与回执 | `400`,`error: "Invalid run ID"` | 使用服务返回的安全 Run ID,不拼接路径 | -| 运行态 | `404`,`error: "Run runtime state not found: ..."` | 无法取得支持的运行态;不要默认成 `idle` | +| 运行态 | `404`,`error: "Run runtime state not found: ..."` | 包括已转交的 Run 和无法读取的状态;不要默认成 `idle`、失败或不存在,必要时另查 Run 详情 | | 回执 | `401`,`error: "无法解析用户身份"` | 请管理员检查实例身份配置 | | 回执 | `404`,`error: "Run not found: ..."` | Run 不存在、不可读取或不属于可信用户;不能据此跳过归属检查 | | 读取失败 | `500` | 保存脱敏响应,请管理员检查服务日志;错误正文以实际返回为准 | @@ -271,7 +278,18 @@ node agent-client.mjs \ --state ./request.json ``` -`--follow-up` 可以重复提供,示例只在上一轮得到明确完成信息、接纳查询确认会话属于当前服务进程后尝试续问。首次调用不覆盖已有状态文件;为独立的新任务使用新的 `--state` 路径。状态文件保存原请求、业务文本与关联信息,不保存实例令牌,应按业务数据保管。 +`--follow-up` 可以重复提供,示例只在上一轮得到明确完成信息、接纳查询确认会话属于当前服务进程后尝试续问;若观察到来源 Run 为 `handed_off` 或无法取得其运行态,会保留待发送文本并停止自动续问。首次调用不覆盖已有状态文件;为独立的新任务使用新的 `--state` 路径。状态文件保存原请求、业务文本、文件引用与关联信息,不保存实例令牌,应按业务数据保管。 + +要提交通用文件,先按[上传附件并交给对话](./09-attachments-and-media.md#upload-and-query)将文件上传到同一实例,保存服务返回的完整 JSON 回执,再交给示例: + +```bash +node agent-client.mjs \ + --prompt 'Summarize the attached report.' \ + --attachment-receipt ./receipt.json \ + --state ./attachment-request.json +``` + +`--attachment-receipt` 可以重复提供。示例校验回执形状,将去重后按首次出现顺序排列的 `fileReference` 放入首轮的 `options.fileReferences` 并计入请求摘要;它不再次上传文件,也不把回执当作访问凭据。附文件仍需提供非空 `--prompt`。顺序 `--follow-up` 只发送文本,不自动重复附上首轮文件;恢复或重试沿用状态文件中的原引用,不能再加新的附件参数。 程序先验证握手,再注册事件监听并建立 Socket.IO 连接。连接路径为 `/socket.io/`,认证为 `auth: { token, clientInstanceId }`;`token` 与 HTTP 请求的 `X-Agent-Service-Token` 相同。`clientInstanceId` 使用稳定的 16—128 字符标识,只包含字母、数字、`.`、`_`、`:`、`-`。认证连接会绑定相应客户端接收通道,示例无需另发订阅消息。 @@ -284,6 +302,7 @@ node agent-client.mjs \ | `prompt` | 本轮用户文本;示例发送非空字符串 | | `options.agentId` | 目标智能体 ID | | `options.conversationId` | 对话范围;示例为其保存稳定值,后续请求沿用该范围 | +| `options.fileReferences` | 可选,已上传通用文件的引用数组;示例从附件回执取得,不放在 start 请求顶层 | | `clientRequestId` | 一次提交的稳定 ID;同一请求恢复和有限重发时保留,新一轮使用新 ID | | `userMessageId` | 本轮用户消息的稳定 ID,与请求一起保存 | | `requestDigest` | `qrd1:` 开头的请求内容摘要,由示例按协议计算;它不是认证凭据 | @@ -328,7 +347,7 @@ node agent-client.mjs \ | `query:error` | `error`,可能有 `code`、请求和运行标识 | 保存可关联的错误;不要把未关联的错误归给另一次请求 | | `activity:run_status` | `runId`、`status`,可能有 `sessionId` | 更新 Run 状态观察;它不是单次提交的终态 | -`query:complete.terminal.status` 为 `completed` 时,该轮正常收口;为 `interrupted` 时,该轮被中止,可继续读取其 `reason`、`partial`、`runId` 等已有信息。业务操作是否达到目标仍需核对输出和回执。`result`、某条主动交付消息或 Run 的 `idle` 都不能代替该轮的终态。 +`query:complete.terminal.status` 为 `completed` 时,该轮正常收口;为 `interrupted` 时,该轮被中止,可继续读取其 `reason`、`partial`、`runId` 等已有信息。业务操作是否达到目标仍需核对输出和回执。`result`、某条主动交付消息或 Run 的 `idle`、`handed_off` 都不能代替该轮的终态;来源轮次正常收口也不代表转交目标已经完成。 没有匹配的 `clientRequestId` 或没有结构化 `terminal` 时,示例不会把事件判成这次请求已经成功。状态文件保留已观察的信息,供后续对账。 @@ -396,7 +415,7 @@ node agent-client.mjs --resume ./request.json node agent-client.mjs --recover ./request.json ``` -这个模式不建立 Socket.IO 订阅、不发送 start 或 follow-up。已有 `runId` 时读取运行态与回执;只有 `sessionId` 时查找准确匹配的 Run,不猜“最近一条”就是本次运行。对账数据不能重放离线期间全部事件,也不能自动补发曾经发生的外部操作。缺少该轮终态证据时仍保留结果未知。 +这个模式不建立 Socket.IO 订阅、不发送 start 或 follow-up。已有 `runId` 时读取运行态与回执;只有 `sessionId` 时查找准确匹配的 Run,不猜“最近一条”就是本次运行。对账数据不能重放离线期间全部事件,也不能自动补发曾经发生的外部操作。缺少该轮终态证据时仍保留结果未知。示例不会把 `handed_off` 当成成功,不自动追随转交目标;顺序续问前若观察到来源 Run 已转交,会停止自动续问并保留待发送文本。 示例退出码为 `0`(观察到完成轮次)、`1`(拒绝、失败或中止)、`2`(结果未知或只读恢复模式)。`--recover` 完成只读检查后仍返回 `2`,这不是重新执行任务或确认任务失败的信号。 diff --git a/docs/07-api/07-openapi.md b/docs/07-api/07-openapi.md index 29bd28f..cd45e37 100644 --- a/docs/07-api/07-openapi.md +++ b/docs/07-api/07-openapi.md @@ -9,9 +9,9 @@ sidebar_position: 7 你可以把下面的 JSON 文件导入支持 [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0.html) 的 API 工具,查看参数、响应和认证要求。请求与响应声明使用 JSON Schema Draft 7 方言;文件只描述表中列出的公开 HTTP 端点。 -**[下载 Agent Service OpenAPI JSON](/openapi/agent-service.json)** +**[下载 Agent Service OpenAPI JSON](pathname:///openapi/agent-service.json)** -另外提供 **[模型协议代理 OpenAPI JSON](/openapi/compat-proxy.json)** 和 **[Workflow Service OpenAPI JSON](/openapi/workflow-service.json)**。三份文件各自使用对应服务的地址与凭据。 +另外提供 **[模型协议代理 OpenAPI JSON](pathname:///openapi/compat-proxy.json)** 和 **[Workflow Service OpenAPI JSON](pathname:///openapi/workflow-service.json)**。三份文件各自使用对应服务的地址与凭据。 ## Agent Service 覆盖范围 @@ -33,6 +33,17 @@ sidebar_position: 7 | `POST /api/query/start` | 提交一次新对话请求 | [对话与运行](./04-conversations-and-runs.md) | | `POST /api/query/follow-up` | 向已建立的会话提交下一轮 | [对话与运行](./04-conversations-and-runs.md) | | `POST /api/query/client-requests/lookup` | 按稳定请求 ID 对账接纳状态 | [对话与运行](./04-conversations-and-runs.md) | +| `GET /api/sessions` | 列出历史会话 | [会话历史与消息](./08-session-history-and-messages.md) | +| `GET /api/sessions/{runId}` | 读取完整会话快照 | [会话历史与消息](./08-session-history-and-messages.md) | +| `GET /api/sessions/{runId}/messages` | 分页读取持久消息 | [会话历史与消息](./08-session-history-and-messages.md) | +| `GET /api/sessions/messages/search` | 搜索某个智能体的历史消息 | [会话历史与消息](./08-session-history-and-messages.md) | +| `GET /api/conversation/{agentId}/overview` | 读取话题总览 | [会话历史与消息](./08-session-history-and-messages.md) | +| `GET /api/conversation/{agentId}/matters/{id}/full` | 读取话题消息 | [会话历史与消息](./08-session-history-and-messages.md) | +| `POST /api/attachments` | 上传通用附件并取得文件引用 | [附件与媒体](./09-attachments-and-media.md) | +| `GET /api/attachments/{attachmentId}` | 下载当前用户的附件 | [附件与媒体](./09-attachments-and-media.md) | +| `POST /api/media/upload` | 上传图片、音频或视频 | [附件与媒体](./09-attachments-and-media.md) | +| `POST /api/media/access-urls` | 签发短期媒体访问链接 | [附件与媒体](./09-attachments-and-media.md) | +| `GET /media/{filename}` | 使用签名链接读取媒体与 Range | [附件与媒体](./09-attachments-and-media.md) | 花括号表示需要替换的路径参数,对应端点指南中的冒号写法。Webhook 管理操作要求认证握手中的 `capabilities.webhookManagement` 为 `true`,并使用实例所有者凭据。旧实例缺少该能力时,使用管理员已启用并验证的事件。运行列表和详情按部署所有者的实例读取范围使用;回执读取另外检查可信用户归属。 @@ -70,6 +81,7 @@ sidebar_position: 7 |---|---|---| | `InstanceToken` | 已有实例访问令牌 | `X-Agent-Service-Token: <实例令牌>` | | `WebhookSecret` | 目标智能体的专用 Webhook 密钥 | `Authorization: Bearer ` | +| `SignedMediaSignature` | 媒体签发接口返回的短期签名 | URL 查询参数 `signature`,并保留 `expires` | 上表适用于 Agent Service 文件。根据具体操作选择对应的方案。供应商 API Key 用于 DesireCore 连接模型供应商;模型代理和 Workflow 文件使用各自的地址与认证方案。 @@ -86,3 +98,5 @@ Socket.IO 的连接、订阅与异步事件需要配套客户端逻辑。OpenAPI 对话提交的 `202` 也不是运行完成反馈。先连接 Socket.IO,再使用当前连接的 `socket.id` 作为 `originSocketId` 提交;HTTP 请求与 Socket 连接必须属于同一认证主体。稳定的 `clientInstanceId` 位于 Socket 认证数据中。断线后按[对话与运行](./04-conversations-and-runs.md)的对账步骤处理,不把 Run 的 `idle` 状态当作某次提交成功的证据。 文件的 `info.version` 是这份 HTTP 接口描述的版本。它不代表 DesireCore 客户端的生效版本,也不代替握手中的 `serverVersion` 或 `apiProtocolVersion`。 + +媒体原生播放使用签发端点返回的完整 `/media/{filename}?expires=…&signature=…` 链接;该读取入口不使用实例令牌,链接应按短期凭据保管。附件下载仍使用实例认证。历史、话题和媒体的权限与响应范围见各自指南。 diff --git a/docs/07-api/08-session-history-and-messages.md b/docs/07-api/08-session-history-and-messages.md new file mode 100644 index 0000000..aaad6c5 --- /dev/null +++ b/docs/07-api/08-session-history-and-messages.md @@ -0,0 +1,290 @@ +--- +title: 会话历史与消息 +description: 查询自己的会话历史、逐 Run 消息、搜索结果与对话话题,正确处理分页、委派检查视图和截断。 +slug: /api/session-history-and-messages +sidebar_position: 8 +keywords: [API, 会话历史, 消息, 搜索, 话题, 分页, Run] +--- + +# 会话历史与消息 + +部署所有者可以用这些接口读取自己 standalone 实例中的会话历史。先按[连接实例与认证](./01-connection-and-auth.md)取得真实实例地址和所有者令牌;所有请求均携带 `X-Agent-Service-Token`,不需要 Socket.IO 连接。 + +## 读取范围与标识 {#history-scope} + +会话列表、搜索和精确 Run 历史按服务端解析的用户归属读取。精确 Run 读取在返回正文前检查该归属;不存在、不属于该用户或已经清除的 Run 均按 `404` 处理。知道 Run ID 不等于有读取权限,也不能通过请求参数自报 `userId` 来选择其他用户。 + +两个话题接口读取所连接实例当前本机用户资料所属的智能体对话记忆。它们适用于实例所有者,不能作为共享服务的多租户隔离接口。资料缺失时返回 `401`;请实例管理员检查当前用户资料。 + +| 标识 | 从哪里取得 | 在本页中的用途 | +|---|---|---| +| Agent ID | [智能体列表](./04-conversations-and-runs.md#list-agents)的 `id` | `agentId` 筛选与话题路径 | +| Run ID | 会话列表项的 `runId`,或已知的授权 Run 记录 | `/api/sessions/:runId` 及其消息路径 | +| 运行时会话 ID | 查询接管事件或可用的运行时会话引用 | 用于续聊;不能直接替代这里的 Run ID | +| 话题 ID | 对话总览的 `activeMatters[].id` 或 `recentMatters[].id` | `/matters/:id/full`;不能据此计算 Run ID | + +本页的读取结果供显示和检索。历史快照中的配置、消息或工具记录不会授予执行权限,也不是可以原样重放给模型供应商的协议存档。当前请求是否完成,仍按[对话与运行记录](./04-conversations-and-runs.md)的请求身份和终态规则判断。 + +## 列出会话历史 {#list-sessions} + +### `GET /api/sessions` + +| 查询参数 | 含义 | +|---|---| +| `agentId` | 可选,按一个 Agent 筛选;省略时查询当前用户的普通会话列表 | +| `includeArchived` | 只有字符串 `true` 才包含已归档会话;默认不包含 | +| `limit` | 默认 `20`;请传正的十进制整数 | +| `offset` | 默认 `0`;请传非负十进制整数 | + +列表按 `updatedAt` 从新到旧排序。归档与运行状态是不同的属性;归档项可带 `archivedAt`。列表保留可打开的历史分支,某个分支可能包含前代已有的消息,不能把各分支直接拼接成去重后的完整对话。 + +此列表不枚举委派子 Run、手动独立会话及调度、Webhook、Mention 等专用时间线。接口没有 `includeDelegated` 或 `includeManual` 开关。后台来信可能带 `background: true`,不能仅因为一项排在首位就把它选作下一轮续聊目标。 + +```bash +curl --silent --show-error --get \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --data-urlencode "agentId=${AGENT_ID}" \ + --data-urlencode 'limit=20' --data-urlencode 'offset=0' \ + "${BASE_URL}/api/sessions" +``` + +```json +{ + "sessions": [ + { + "runId": "run-example", + "agentId": "your-agent-id", + "status": "idle", + "messageCount": 1, + "createdAt": "2026-10-15T08:00:00Z", + "updatedAt": "2026-10-15T08:00:00Z", + "preview": "Please review the release notes" + } + ], + "total": 1, + "hasMore": false +} +``` + +列表项还可能包含 `forkedFrom`、`conversationId`、`title`、`titleSource`、`origin`、`background`、`archivedAt`。`status` 的历史显示值为 `idle`、`completed`、`failed` 或 `handed_off`,不能用它代替实时执行状态或某次请求的成功凭证。 + +`hasMore: true` 时用 `offset + limit` 读取下一页。这里及逐 Run 消息接口没有声明固定页大小上限;建议使用小页。两者会兼容解析整数参数,请始终发送上述整数形式,不依赖非法值的转换结果。 + +## 读取一个会话快照 {#get-session} + +### `GET /api/sessions/:runId` + +`runId` 使用字母、数字、连字符或下划线。成功返回会话快照本身,不包在 `data` 或 `session` 中。快照包含整个持久消息数组,长会话优先使用下一节的分页接口。 + +```bash +curl --silent --show-error \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + "${BASE_URL}/api/sessions/${RUN_ID}" +``` + +```json +{ + "runId": "run-example", + "agentId": "your-agent-id", + "userId": "your-instance-user-id", + "engine": "desirecore-engine", + "messages": [ + { + "id": "d8d53a7a-478e-4d96-970e-657dece814f7", + "role": "user", + "content": "Please review the release notes", + "timestamp": 1792051200000 + } + ], + "options": {}, + "status": "idle", + "createdAt": "2026-10-15T08:00:00Z", + "updatedAt": "2026-10-15T08:00:00Z" +} +``` + +快照还可能带 `conversationId`、`forkedFrom`、团队或工作目录信息,以及可用的 `runtimeSession` 引用。`pendingAssistant` 若出现,是尚未定稿的输出检查点,独立于 `messages`;不要把它追加成已保存消息。该响应会移除运行时私有回放字段,图片引用可能还原成 data URL,响应体因此可能较大。 + +委派子 Run 在这个快照接口返回 `403 delegated_run_requires_inspect_view`。即使增加 `view` 参数也不会把它变成委派检查接口;请使用下面的逐 Run 消息检查视图。 + +## 分页读取逐 Run 消息 {#session-messages} + +### `GET /api/sessions/:runId/messages` + +| 查询参数 | 含义 | +|---|---| +| `offset` | 默认 `0`,源记录的行位置,不是字节偏移 | +| `limit` | 默认 `100`,本次要读取的源记录窗口大小;建议使用正整数 | +| `view` | 普通会话可省略;委派子 Run 必须显式使用 `inspect-run` | + +```bash +curl --silent --show-error --get \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --data-urlencode 'offset=0' --data-urlencode 'limit=100' \ + "${BASE_URL}/api/sessions/${RUN_ID}/messages" +``` + +```json +{ + "messages": [ + { + "id": "d8d53a7a-478e-4d96-970e-657dece814f7", + "role": "user", + "content": "Please review the release notes", + "timestamp": 1792051200000, + "lineIndex": 0, + "offset": 0 + } + ], + "total": 1 +} +``` + +该接口没有 `hasMore`。下一页从**当前请求的 `offset + limit`** 开始,达到本次响应的 `total` 时停止。`total` 按源记录行数计数;空白、损坏记录可能被跳过,工具记录也可能经过显示去重,所以 `messages.length` 可能小于窗口大小,甚至整页为空。不能用返回长度推进或据此提前结束。 + +消息中的 `lineIndex` 是 Run 内行号,`offset` 是字节位置,二者仅供导航;稳定消息身份优先使用 `id`。`content` 可为字符串或内容块数组,不应当作始终是纯文本。图片、媒体和外置工具结果可能保留引用或预览;本接口不保证返回附件或工具输出的完整二进制/正文。附件读取见[附件与媒体](./09-attachments-and-media.md#download-attachment)。 + +### 委派检查视图 + +对一个已知且有权读取的委派 Run,使用相同地址并增加 `view=inspect-run`。这不会放宽用户归属检查,也不会让该 Run 出现在普通会话列表中。 + +仍在执行且有未落盘输出时,响应可能额外带 `live`: + +```json +{ + "messages": [], + "total": 0, + "live": { + "turnId": "turn-example", + "text": "Working on the delegated task", + "toolCalls": [], + "timestamp": 1792051200000 + } +} +``` + +`live` 是可替换的临时显示快照,可能回退,也可能在终态或最终消息落盘后消失;它不计入 `total`,不能当作持久消息、回执或成功结论。只包含普通输出文本和有限工具状态;不要据此恢复工具输入或执行。`text` 最多 16,384 字符,`toolCalls` 最多 128 项。 + +## 搜索一个 Agent 的消息 {#search-messages} + +### `GET /api/sessions/messages/search` + +| 查询参数 | 含义 | +|---|---| +| `agentId` | 必填,目标 Agent ID | +| `q` | 可选,大小写不敏感的文本子串;省略时仅按内容类型筛选 | +| `type` | `all`、`image`、`file` 或 `tool`;省略或其他值按 `all` 处理 | +| `limit` | 默认 `50`;非负安全整数。`0` 表示从 `offset` 起返回全部剩余命中,此时 `hasMore` 为 `false`;大历史建议始终分页 | +| `offset` | 默认 `0`;非负安全整数,且与 `limit` 之和也必须是安全整数 | +| `preferredRunId` | 可选,选择默认会话的历史分支窗口;不是访问凭据,也不是“只查这一个 Run” | +| `previewChars` | 可选,整数 `1`–`8000`;先匹配原文,再裁剪展示预览。省略时保留完整提取文本 | + +安全整数最大值为 `9007199254740991`。搜索按消息时间戳从新到旧返回文本命中;内容块中的普通文本会参与匹配,不是附件正文或工具结果的通用全文索引。`image` 标记沿消息的媒体/图片字段判断,不能据此断言所有附件均为图片。 + +搜索包含该用户的手动独立会话,默认不包含归档会话或委派子 Run。默认会话的 fork 家族会选择可见分支,避免重复命中;`preferredRunId` 限定相应历史窗口,但不排除独立的手动会话。无有效匹配分支时不会借此取得其他 Run 的权限。 + +```bash +curl --silent --show-error --get \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --data-urlencode "agentId=${AGENT_ID}" --data-urlencode 'q=release' \ + --data-urlencode 'limit=50' --data-urlencode 'offset=0' \ + --data-urlencode 'previewChars=240' \ + "${BASE_URL}/api/sessions/messages/search" +``` + +```json +{ + "items": [ + { + "runId": "run-example", + "messageIndex": 0, + "offset": 0, + "messageId": "d8d53a7a-478e-4d96-970e-657dece814f7", + "role": "user", + "text": "Please review the release notes", + "timestamp": 1792051200000, + "hasImages": false, + "hasFiles": false, + "hasToolUse": false + } + ], + "total": 1, + "hasMore": false +} +``` + +命中还可能带 `toolNames` 和 `conversationId`。用 `runId` 与 `messageId` 关联消息;`messageIndex` 是源行号,命中里的 `offset` 是字节位置,不能直接当作下一页查询参数。搜索本身按请求的 `offset + limit` 翻页,并读取 `hasMore`。 + +## 读取对话话题总览 {#conversation-overview} + +### `GET /api/conversation/:agentId/overview` + +无查询参数或分页。返回活跃话题、最多 10 个近期话题,以及总话题数和已归档话题数;它是对话记忆总览,不能替代会话列表或全部原始消息。 + +```json +{ + "agentId": "your-agent-id", + "activeMatters": [{ "id": "mat_example", "title": "Release review", "type": "task" }], + "recentMatters": [], + "totalMatters": 1, + "totalArchived": 0 +} +``` + +活跃项包含 `id`、`title`、`type`;近期项还可能带 `completedAt` 和 `summary`。没有对话记忆时可返回空数组和零计数;这不表示该 Agent 没有任何历史消息,也不证明 Agent ID 存在。 + +## 读取话题的原始消息 {#matter-messages} + +### `GET /api/conversation/:agentId/matters/:id/full` + +用总览返回的话题 ID 读取;无分页参数。来源可为活跃、近期或归档话题。成功返回 `{ "matterId": "…", "messages": [...] }`,数组是话题来源的公开条目,不能假定每项都与逐 Run 消息完全同形。 + +**每次最多读取 120 条来源消息**;超过时额外返回 `"truncated": true`。公开投影还可能省略内部条目,返回长度不代表来源总数。接口没有提高上限或继续翻页的参数;需要逐 Run 完整历史时,使用有权读取的真实 Run ID 和消息分页接口。 + +```json +{ + "matterId": "mat_example", + "messages": [{ "role": "user", "content": "Please review the release notes", "timestamp": 1792051200000 }], + "truncated": true +} +``` + +## 可运行的读取示例 {#history-example} + +**[下载 history.mjs](pathname:///api-examples/history.mjs)**。需要 Node.js 22 或之后版本,不需要安装 npm 依赖。 + +```bash +export DESIRECORE_URL='https://your-instance.example' +export DESIRECORE_TOKEN='your-existing-owner-token' +export DESIRECORE_AGENT_ID='your-agent-id' + +node history.mjs list --limit 20 --all-pages +node history.mjs session run-example +node history.mjs messages run-example --limit 100 --all-pages +node history.mjs messages delegated-run-id --inspect-run +node history.mjs search --query 'release' --preview-chars 240 --all-pages +node history.mjs overview +node history.mjs matter mat_example +``` + +示例先验证实例握手,再发起 GET;不会提交查询、续聊或撤销运行。默认每个命令只读一页,`--all-pages` 最多读取 20 页,可用 `--max-pages` 调整。输出为逐行 JSON,包含历史正文,请按私密数据保存。 + +`list` 用 `--include-archived` 包含归档;`search` 另支持 `--type`、`--preferred-run-id` 和 `--preview-chars`。`messages` 的检查视图仅通过显式 `--inspect-run` 开启。 + +退出码 `0` 表示请求的读取成功,`1` 表示配置或 HTTP 请求失败,`2` 表示翻页达到设定上限、观测到总数变化而停止,或话题响应被截断。后续读取位置见停止记录中的 `nextOffset`;重新开始前应确认历史是否变化。 + +这些接口没有提供跨页一致性版本。执行中追加、编辑或清理历史,都可能让多次读取出现重复或遗漏。示例发现 `total` 变化会停止,但不能检测总数不变的内容改写;不要把一次分页扫描当成固定时点的审计快照。 + +## 常见错误 {#history-errors} + +| HTTP / 错误 | 处理方式 | +|---|---| +| `400 agentId is required` | 搜索时提供真实 Agent ID | +| `400 Invalid history page parameters` | 搜索的 `limit`、`offset` 使用非负安全整数,并检查 `previewChars` 范围 | +| `400 Invalid runId` | 使用会话列表返回的 Run ID,不传路径或运行时 session ID | +| `401` | 检查实例令牌;若为 `User not authenticated` 或 `User not found`,请管理员检查当前实例用户资料 | +| `403 delegated_run_requires_inspect_view` | 对已授权的委派 Run 使用 `/messages?view=inspect-run`,不调用完整快照接口 | +| `404 Session not found` 或带 Run ID 的同类错误 | 记录不存在、已清除、不可读或归属不符;不要换身份或猜其他 ID 重试 | +| `404 Matter messages not found: …` | 话题或可读取的来源消息不存在;从当前总览重新选择;空的归档来源也可能无法返回消息 | + +认证、服务尚未就绪、来源限制及代理返回 HTML 的处理,见[错误与排查](./03-errors-and-troubleshooting.md)。 diff --git a/docs/07-api/09-attachments-and-media.md b/docs/07-api/09-attachments-and-media.md new file mode 100644 index 0000000..319dfd2 --- /dev/null +++ b/docs/07-api/09-attachments-and-media.md @@ -0,0 +1,225 @@ +--- +title: 附件与媒体 +description: 上传通用附件或图片、音频、视频,将附件引用用于对话,并安全读取文件和签名媒体链接。 +slug: /api/attachments-and-media +sidebar_position: 9 +--- + +# 附件与媒体 + +通用文件先上传为附件,再将返回的文件参考交给智能体;图片、音频和视频也可以上传为媒体,再取得供原生媒体标签使用的短期链接。上传成功只表示文件已存储,不能据此判断智能体已经读取或处理文件。 + +先按[连接实例与认证](./01-connection-and-auth.md)准备 `BASE_URL` 和实例访问令牌。`BASE_URL` 是目标 Agent Service 的地址,不含 `/api`;远程访问使用 HTTPS。除签名媒体读取外,本页请求均使用 `X-Agent-Service-Token`,身份由服务解析。 + +| 用途 | 方法与路径 | 成功响应 | +|---|---|---| +| 存储 PDF、文本等通用文件 | `POST /api/attachments` | `201`,附件收据 | +| 下载自己的附件 | `GET /api/attachments/:attachmentId` | `200`,原始文件字节 | +| 存储图片、音频或视频 | `POST /api/media/upload` | `200`,媒体元信息 | +| 为可读取的媒体取得短链 | `POST /api/media/access-urls` | `200`,`items` 与 `missing` | +| 通过短链读取媒体 | `GET /media/:filename` | `200` 或单段 Range 的 `206` | + +## 上传通用附件 {#upload-attachment} + +### `POST /api/attachments` + +发送一个 `multipart/form-data` 文件。使用 `curl --form`、浏览器 `FormData` 或下面的上传示例时,让工具自动生成包含 boundary 的 `Content-Type`;不要手写成不带 boundary 的 `multipart/form-data`。 + +```bash +curl --fail-with-body --silent --show-error \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --form 'file=@./report.txt;type=text/plain' \ + "${BASE_URL}/api/attachments" +``` + +成功返回直接的收据对象,没有 `data` 或 `success` 外壳: + +```json +{ + "attachmentId": "00000000-0000-4000-8000-000000000001", + "name": "report.txt", + "mimeType": "text/plain", + "size": 18, + "sha256": "0932d7133fdafd99d58020fca2539266aa11731a72f741067b1009c4c8c66553", + "fileReference": "/srv/desirecore/attachments/owner/00000000-0000-4000-8000-000000000001/data" +} +``` + +| 字段 | 含义 | +|---|---| +| `attachmentId` | 服务签发的 UUID,用于下载;不是本地文件名 | +| `name` | 清理路径分隔符和控制字符后的显示名称,最长 240 个字符 | +| `mimeType` | 上传时的内容类型描述,最长 128 个字符 | +| `size` | 完整文件的字节数,整数且至少为 `1` | +| `sha256` | 文件字节的小写 SHA-256 摘要,64 个十六进制字符 | +| `fileReference` | 目标服务上的文件参考;原样放入同一实例 Query 的 `options.fileReferences`,不要在调用方机器上打开它或自行拼接 | + +此入口接受通用文件,不采用下方媒体入口的 MIME 清单。它只存储文件,不解压、不执行,也不保证模型或工具能解析相应格式。把参考交给智能体不会自动授予工具执行、文件编辑或跨用户读取权限。 + +单次只上传一个非空文件,最大 **20 MiB(20,971,520 字节)**。每个用户最多保留 256 个附件目录,总文件内容最多 **512 MiB**。额度不足返回 `429`。本页没有附件删除接口,不要靠反复上传相同文件做重试;每次成功 POST 都会创建新附件。没有收到完整收据时,保留上传结果未知的状态,检查错误后再决定是否重新上传。 + +## 上传后交给智能体 {#upload-and-query} + +[下载附件上传示例](pathname:///api-examples/attachments.mjs),保存为 `attachments.mjs`。它使用 **Node.js 22+**,不需要安装依赖;校验本地文件大小,以及成功收据中的大小和 SHA-256。示例不自动重试 POST,已有输出文件也不会被覆盖。 + +```bash +export DESIRECORE_URL='https://agent.example.com' +export DESIRECORE_TOKEN='replace-with-your-existing-instance-token' +export DESIRECORE_AGENT_ID='your-agent-id' + +node attachments.mjs --file ./report.txt --mime text/plain --out ./attachment.json +``` + +`attachment.json` 是直接收据对象。按[对话与运行](./04-conversations-and-runs.md)下载 `agent-client.mjs`、安装其依赖,再提交: + +```bash +node agent-client.mjs \ + --prompt '请阅读附件并概括要点。' \ + --attachment-receipt ./attachment.json \ + --state ./request-with-attachment.json +``` + +可重复提供 `--attachment-receipt` 添加多个收据。示例仅将这些附件用于首轮 start,后续 `--follow-up` 是文本续问。请求状态保存已选文件参考,恢复或传输重试沿用同一参考,不重新上传。收据和状态文件可能暴露文件参考或消息内容,请妥善保存,并只用于原实例。 + +自己实现客户端时,将收据放到以下位置;`originSocketId` 必须是已认证 Socket.IO 连接的当前 ID,其他提交、摘要与终态处理按[对话指南](./04-conversations-and-runs.md)执行: + +```json +{ + "prompt": "Summarize the attachment.", + "options": { + "agentId": "your-agent-id", + "conversationId": "your-agent-id", + "fileReferences": [ + "/srv/desirecore/attachments/owner/00000000-0000-4000-8000-000000000001/data" + ] + }, + "clientRequestId": "00000000-0000-4000-8000-000000000002", + "userMessageId": "00000000-0000-4000-8000-000000000003", + "originSocketId": "current-authenticated-socket-id" +} +``` + +`fileReference` 不是公开 URL。HTTP `202` 表示请求被接纳;仍需观察本轮终态或读取可关联的回执,确认是否已处理附件。 + +## 下载通用附件 {#download-attachment} + +### `GET /api/attachments/:attachmentId` + +使用同一实例、同一用户身份读取上传收据中的 `attachmentId`: + +```bash +curl --fail-with-body --silent --show-error \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --output ./downloaded-report.txt \ + "${BASE_URL}/api/attachments/${ATTACHMENT_ID}" +``` + +成功正文是文件字节,`Content-Type: application/octet-stream`,带文件名的 `Content-Disposition`、实际 `Content-Length`、`Cache-Control: no-store` 和 `X-Content-Type-Options: nosniff`。不要把它当 JSON 解析。本端点没有媒体的 Range 合同。 + +ID 非法、不存在、属于另一个用户,或文件/收据不可读时返回 `404 attachment_not_found`。[历史消息](./08-session-history-and-messages.md#session-messages)中出现的路径或附件 ID 不代替本次下载的身份检查。 + +## 上传图片、音频和视频 {#upload-media} + +### `POST /api/media/upload` + +同样使用单文件 multipart,但只接受以下 MIME: + +| 类别 | MIME | +|---|---| +| 图片 | `image/jpeg`、`image/png`、`image/gif`、`image/webp` | +| 音频 | `audio/mpeg`、`audio/mp3`、`audio/opus`、`audio/aac`、`audio/flac`、`audio/wav`、`audio/ogg` | +| 视频 | `video/mp4`、`video/webm` | + +不支持的 MIME 返回 `415`;空文件返回 `400`。此入口使用服务的单文件 multipart 上限 **2 GiB(2,147,483,648 字节)**,不是通用附件的 20 MiB;反向代理和部署限制可能更小。大文件应先压缩或缩短时长。 + +```bash +curl --fail-with-body --silent --show-error \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --form 'file=@./diagram.png;type=image/png' \ + "${BASE_URL}/api/media/upload" +``` + +```json +{ + "type": "image", + "mediaId": "00000000-0000-4000-8000-000000000004.png", + "name": "diagram.png", + "mimeType": "image/png", + "size": 68, + "width": 1, + "height": 1 +} +``` + +`type` 为 `image`、`audio` 或 `video`;`width`、`height` 仅在取得图片尺寸时存在。保存完整元信息;需要随 Query 发送媒体时,把它放入顶层 `mediaRefs` 数组,通用文件参考仍放在 `options.fileReferences`。上传成功不保证后续模型支持该媒体格式。 + +新上传媒体归当前认证用户所有。签发链接时会核对归属和媒体实际内容,不能只凭猜到的 `mediaId` 为别人的新媒体换链接。旧实例保存的部分媒体没有用户归属记录,读取保留兼容行为;不要把历史媒体 ID 当成跨用户隔离的凭证。 + +## 为原生媒体取得签名链接 {#signed-media} + +### `POST /api/media/access-urls` + +携带实例访问令牌,发送 1–100 个 `mediaId`。重复项按第一次出现的顺序去重,额外顶层字段不接受。 + +```bash +curl --fail-with-body --silent --show-error \ + --header "X-Agent-Service-Token: ${AGENT_SERVICE_TOKEN}" \ + --header 'Content-Type: application/json' \ + --data '{"mediaIds":["00000000-0000-4000-8000-000000000004.png"]}' \ + "${BASE_URL}/api/media/access-urls" +``` + +```json +{ + "items": [ + { + "mediaId": "00000000-0000-4000-8000-000000000004.png", + "url": "/media/00000000-0000-4000-8000-000000000004.png?expires=1792112700&signature=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "expiresAt": "2026-10-16T01:05:00.000Z" + } + ], + "missing": [] +} +``` + +使用真实响应中的 `url`,不要复制上述示例签名。`url` 相对于 Agent Service 地址,`expiresAt` 是到期时间。缺失文件列入 `missing`,其他可用文件仍可以得到链接。语法非法的 ID 使整次请求失败为 `400`;可用文件中存在无权读取的媒体时返回 `403`,不要假设这时仍有部分 `items`。 + +### `GET /media/:filename` + +将返回的 URL 交给 ``、`