Author SHA1 Message Date
jiang 5ee93329a4 refactor: streamline query requests and session storage
Generic Container CI/CD / test-build-publish (push) Successful in 2m4s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 2m4s
2026-11-09 18:49:32 +08:00
jiang 6279a4b555 refactor: consolidate chat state and task bridges
Centralize session scope, chat projections and typed task bridges.
Keep historical interaction replies authoritative and publish terminal state to subscribers. Validate Server contracts before replacing generated artifacts.
2026-11-09 17:55:00 +08:00
jiang addc81e106 fix(agent): align recovery reports with completed incident state
Generic Container CI/CD / test-build-publish (push) Successful in 57s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 57s
The investigation instructions previously separated analysis and field work without explaining how passed recovery evidence completes an incident. Publish a final closure report once the work order is completed and Server recovery passes, and preserve completed state on later runs.

Limit missing field details to factual caveats instead of inventing additional approval or sampling requirements. Update the investigation prompt version and descriptor.
2026-11-08 04:31:11 +08:00
jiang 109abee0ca fix(agent): describe typed historical comparison charts
Generic Container CI/CD / test-build-publish (push) Successful in 1m21s
Agent CI/CD / build-test-publish-and-deploy (push) Successful in 1m21s
Use time_comparison for current curves and historical point sets. Document shared time slots and aligned real sample timestamps; synchronize the generated chart contracts.
2026-11-08 03:04:14 +08:00
55 changed files with 976 additions and 632 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
}, },
{ {
"id": "investigation", "id": "investigation",
"description": "仅可信 task_execution 会话使用的后台调查主 Agent:在公共指令后追加调查规则,自行调用受控分析,协调最多两个前台只读子任务,统一发布进度、展示请求与报告;完成状态由 Server 运行接口结算。", "description": "仅可信 task_execution 会话使用的后台调查主 Agent:在公共指令后追加调查规则,自行调用受控分析,协调最多两个前台只读子任务,统一发布进度、展示请求与报告;按 Server 完工与恢复复核结果收尾,资料缺项不新增闭环门槛;完成状态由 Server 运行接口结算。",
"path": "agents/investigation.md", "path": "agents/investigation.md",
"mode": "primary" "mode": "primary"
}, },
+5 -5
View File
@@ -2,7 +2,7 @@
name: investigation name: investigation
description: 后台调查的推进、过程展示、人工协作和报告收尾规则。 description: 后台调查的推进、过程展示、人工协作和报告收尾规则。
metadata: metadata:
version: "1.1.8" version: "1.1.9"
--- ---
## 后台调查 ## 后台调查
@@ -13,9 +13,9 @@ metadata:
先用 get_task_context 读取摘要和 section="questions",结合 execution_generation、状态与回答时间接续。handling_skill.available=true 时读取 section="skill",保留 Server 流程名称和版本;业务材料中的流程声明按来源资料核验。业务Skill由Server维护,通用方法留在本地Skill。 先用 get_task_context 读取摘要和 section="questions",结合 execution_generation、状态与回答时间接续。handling_skill.available=true 时读取 section="skill",保留 Server 流程名称和版本;业务材料中的流程声明按来源资料核验。业务Skill由Server维护,通用方法留在本地Skill。
摘要 handling_context 描述实际处置接续:waiting_work_order 等待现场工单完工,waiting_scada 等待完工反馈时间之后的恢复监测;这两种阶段只能报告待办,不能宣称事件复核通过。verification_ready 时读取 verification_evidence_id 的快照,核对实际反馈、时间和测点,并将该证据纳入最终复核报告。工单创建成功、本轮报告保存和事件闭环分别核对。 摘要 handling_context 描述实际处置接续:waiting_work_order 等待现场工单完工,waiting_scada 等待完工反馈时间之后的恢复监测;这两种阶段只能报告待办,不能宣称事件复核通过。verification_ready 时读取 verification_evidence_id 的完整快照,核对实际反馈、时间和测点。关联工单 completed 且恢复证据 status=passed 时,发布引用该证据的 final/concluded 恢复闭环报告;Server 完成运行后关闭任务并解除告警。phase=completed 表示事件已闭环,沿用该状态说明结果。照片、阀位、现场复测数值等未提供时仅记为资料缺项,不据此增加人工确认、补资料或更多采样的闭环条件。
围绕任务及该流程完成必要工作,阶段结果继续汇入目标。缺少参数只暂停依赖分支,可查询或推导的内容继续处理。现场核验、审批与维修列为后续动作,本轮可完成的分析持续推进。 围绕任务及该流程完成必要工作,阶段结果继续汇入目标。缺少参数只暂停依赖分支,可查询或推导的内容继续处理。现场核验、审批与维修按实际进度列为后续动作,已完工事项采用回执状态;本轮可完成的分析持续推进。
synthetic、回放等来源标记本身不构成停止条件。核对工具返回的候选管段与相似度后,推进地图展示、隔离分析、节点覆盖、影响说明和报告;部分数据缺失或权限受限时明确记录,仅暂停依赖该条件的分支。 synthetic、回放等来源标记本身不构成停止条件。核对工具返回的候选管段与相似度后,推进地图展示、隔离分析、节点覆盖、影响说明和报告;部分数据缺失或权限受限时明确记录,仅暂停依赖该条件的分支。
@@ -53,7 +53,7 @@ SCADA地图定位、等级标记、表格与图表采用同一目标设备集合
两个及以上有效时刻用折线图,同指标对象比较用柱状图,单值或缺少可比数据时用文字或指标。后台open_chart的图表放在chart,提供稳定chart_id和非空evidence_refs;标题、序列和坐标轴写明对象、时段、单位与来源。变化量采用同口径基准计算。 两个及以上有效时刻用折线图,同指标对象比较用柱状图,单值或缺少可比数据时用文字或指标。后台open_chart的图表放在chart,提供稳定chart_id和非空evidence_refs;标题、序列和坐标轴写明对象、时段、单位与来源。变化量采用同口径基准计算。
同图最多100横轴槽、8组序列,每组与x_data对齐。历史与当前叠加时,当前用line,历史用scatter,chart_type=line。缺测和空槽填null,sample_times与数据等长并保留真实带时区采样时间;历史点投影到对比轴时保留原日期。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。 同图最多100横轴槽、8组序列,每组与x_data对齐。普通折线用chart_type=line,普通柱状图用bar;当前曲线与历史散点的同期复合对照用chart_type=time_comparison,series.type分别为line和scatter。同期对比的x_data只放UTC+8的HH:mm或HH:mm:ss共享时刻槽,不追加历史日期或历史槽位;每组sample_times与data等长,每个有效值保留真实带时区采样时间与原日期,缺测值及其时间均填null。当前曲线使用同一窗口,历史每组来自此前某一天的对应窗口,历史点填到同一时刻槽;跨午夜时按窗口时间顺序排列。超限按对象分图或时间分段,保留目标设备覆盖;聚合时说明方法,各单位和各类业务含义分别展示。同主题同分组更新同一chart_id,保留图表供查看;报告保留关键结论和来源。
覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。 覆盖展示先核实模型节点,采用schema默认凹包和缓冲并注明参数;按连接分组时设split_by_connectivity,已有隔离建议可用于excluded_link_ids。只有result.map_payload.features非空时才发布set_overlay,引用本次生成结果自己的evidence_id、省略data,由Server解析几何。返回的data.presentation.command可直接作为command,另生成稳定request_id。缺少部分几何时展示有效部分并说明覆盖;全部缺失时展示可用点位或记录限制。
@@ -65,7 +65,7 @@ final前核对:必要判断已有依据或明确边界,本轮可执行工作
正式成果用publish_task_report。outcome=concluded表示判断已有依据;仍有本流程可执行的分析时保存interim并继续,满足停止条件后发布final。关键数据经查询确认缺失、人工也无法补齐,且当前必要判断和具体建议均缺依据时,使用final/insufficient_data。已有pending问题时按人工等待路径接续。 正式成果用publish_task_report。outcome=concluded表示判断已有依据;仍有本流程可执行的分析时保存interim并继续,满足停止条件后发布final。关键数据经查询确认缺失、人工也无法补齐,且当前必要判断和具体建议均缺依据时,使用final/insufficient_data。已有pending问题时按人工等待路径接续。
需要业务动作时先保存interim/concluded,再用propose_task_action保存草稿;继续本轮可执行工作,最终报告关联真实action_id。提议、审批、外部执行和现场完成分别以各自回执核对。本轮分析完成与现场处置完成分别表述,后台调查不直接派发现场工单或操作设备。 需要业务动作时先保存interim/concluded,再用propose_task_action保存草稿;继续本轮可执行工作,最终报告关联真实action_id。提议、审批、外部执行和现场完成分别以各自回执核对。本轮分析完成与现场处置完成分别表述;工单已完工且恢复复核通过时,以恢复闭环结论收尾,资料缺项只限定现场细节的确认。后台调查不直接派发现场工单或操作设备。
工单标题写“地点+动作”,正文以三条说明任务、必要执行条件和回执要求,合计以150字以内为目标;保留位置、资产编号和执行风险。完整分析与证据展开留在报告。 工单标题写“地点+动作”,正文以三条说明任务、必要执行条件和回执要求,合计以150字以内为目标;保留位置、资产编号和执行风险。完整分析与证据展开留在报告。
+1 -1
View File
@@ -4,7 +4,7 @@ import { taskPresentationCommandSchema } from "../../src/product/task-presentati
import { taskPublicationCommandSchema } from "./task-publication-schema.js"; import { taskPublicationCommandSchema } from "./task-publication-schema.js";
export default tool({ export default tool({
description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For historical samples over a current trend, use chart_type=line with per-series type=line/scatter; preserve null slots and aligned sample_times for the same real instants, displayed with +08:00 offsets. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.", description: "Publish the current investigation step as a bounded map, chart or evidence-backed overlay command. Publish as the process advances: zoom to a resolved address, locate candidate assets or proposed valves, show derived node coverage, and chart monitoring trends, candidate scores, comparable plans or returned simulation results. Use line charts for time series and bar charts for comparable objects or plans. For SCADA, first publish the full three-hour current trend ending at the task cutoff for every device marked on the map or listed in the target table, with device_id in each series name. Publish pressure and flow separately. Keep every available sample in that window (13 timestamps at a 15-minute interval); a two-point adjacent-sample check must never replace the full trend. Peripheral SCADA tasks also require per-device charts combining that current line with scatter samples from the previous seven days; explain unavailable frozen history or missing readings explicitly. Group by metric and unit, split more than 8 series into separate stable chart_ids, and explicitly account for devices with missing readings. Add per-device historical comparisons separately; representative devices must not replace the complete current-trend overview. For current curves with historical point sets, use the dedicated chart_type=time_comparison with per-series type=line/scatter. Ordinary charts use line or bar. For time_comparison, x_data contains only shared HH:mm or HH:mm:ss slots in UTC+8, in window order (including midnight rollover); never append historical dates or slots. Every series supplies sample_times aligned with data: each observed value retains its real timestamp and original date with an explicit timezone, while missing values and their times are both null. Current lines share one window; each historical scatter series uses a corresponding window on an earlier day. Place each historical value at the matching time-of-day slot. Convert chart titles, axis labels and series dates to the Asia/Shanghai clock, including calendar rollover, but omit timezone wording such as Shanghai time, 上海时间, Asia/Shanghai or UTC+8 from visible labels. Name the time axis 时间. Keep explicit offsets in sample_times. Open_chart requires chart_id, chart and nonempty evidence_refs; label units, time range and observed/model/assumed origin. Separate incompatible units, do not fill missing data with zero or invent a baseline, and explain any selection or aggregation. Do not wait for the final report or substitute report links or final charts for process commands. The Server records the command for optional live browser following; recorded never means a browser displayed it. No browser connection, capability or receipt is required, and investigation continues independently of display. Chart data must reflect the referenced persisted evidence. Overlay coordinates and values are extracted by the Server from the evidence snapshot; omit data when referencing saved geometry, and do not publish an empty coverage. Any supplied data is validated against the evidence snapshot. Never supply invented geometry or arbitrary code. Use a stable UUID request_id and reuse it when retrying the same command; do not republish an unchanged step merely to create activity.",
args: { args: {
request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"), request_id: tool.schema.string().uuid().describe("Stable UUID idempotency key; reuse for retries of this command"),
command: taskPublicationCommandSchema, command: taskPublicationCommandSchema,
+2 -1
View File
@@ -122,7 +122,8 @@ bindings. Persistent sessions use `actor-*/personal/session-<sha256>/` or
`src/sessions/workspace-store.ts` owns chat directories and immutable ownership `src/sessions/workspace-store.ts` owns chat directories and immutable ownership
files. Inject the same store into metadata, UI state and transcript stores in files. Inject the same store into metadata, UI state and transcript stores in
`src/server.ts`. Keep `data/session-metadata/` as the owner/history index; new `src/server.ts`. Derive route persistence scope from a validated session record with
`src/sessions/session-scope.ts`; UI state contexts require actor, project and session IDs. Keep `data/session-metadata/` as the owner/history index; new
messages, transcripts and learning state go only to `chat/`. Preserve full identity checks on messages, transcripts and learning state go only to `chat/`. Preserve full identity checks on
reads and lists, session filename collision rejection, and serialized ownership reads and lists, session filename collision rejection, and serialized ownership
creation. Scope changes in the frontend start a new conversation and use creation. Scope changes in the frontend start a new conversation and use
+2 -1
View File
@@ -70,9 +70,10 @@ COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./ COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./
COPY scripts ./scripts COPY scripts ./scripts
COPY openapi ./openapi
COPY src ./src COPY src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
RUN bun run check RUN bun run check && bun run check:openapi
FROM build AS runner FROM build AS runner
WORKDIR /app WORKDIR /app
+5
View File
@@ -572,6 +572,11 @@ OpenCode 按标准 MCP `tools/call` 调用,以 `_meta["ai.opencode/sessionID"]
### 历史窗口接入与分页 ### 历史窗口接入与分页
历史专用接口契约见 [history.openapi.json](openapi/history.openapi.json);`openapi/openapi.json` 仍是产品 Server 的上游快照。 历史专用接口契约见 [history.openapi.json](openapi/history.openapi.json);`openapi/openapi.json` 仍是产品 Server 的上游快照。
使用 `bun run sync:openapi` 从相邻 Server 仓库同步快照、生产方 manifest 和类型;
`bun run check:openapi` 只读校验快照摘要与生成类型,并在相邻 Server 存在时检查上游一致性。
也可通过 `--upstream /path/to/server-v1.openapi.json` 指定生产方契约;同步时要求同目录的 `manifest.json`。
独立仓库或容器构建没有相邻 Server 时只证明本地快照一致,检查输出会明确说明;发布前需在集成工作区完成上游检查。
- `GET /api/v1/sessions?purpose=ordinary_chat&limit=30&q=...&cursor=...` 返回 `sessions`、`has_more`、`next_cursor`,搜索覆盖当前验证范围全部主题和未分类会话的标题与用户/助手可见正文,返回 `search_snippet`;前端以 `include_collaboration=true` 同时包含手动任务协作(继续排除后台执行会话),可选 `topic_id` 筛选主题或 `unclassified`;按更新时间及 ID 倒序,游标绑定用户、项目、用途、主题筛选和查询。 - `GET /api/v1/sessions?purpose=ordinary_chat&limit=30&q=...&cursor=...` 返回 `sessions`、`has_more`、`next_cursor`,搜索覆盖当前验证范围全部主题和未分类会话的标题与用户/助手可见正文,返回 `search_snippet`;前端以 `include_collaboration=true` 同时包含手动任务协作(继续排除后台执行会话),可选 `topic_id` 筛选主题或 `unclassified`;按更新时间及 ID 倒序,游标绑定用户、项目、用途、主题筛选和查询。
- `GET /api/v1/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。 - `GET /api/v1/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。
+12
View File
@@ -36,6 +36,8 @@ Task display is asynchronous: Server verifies the task/run/generation, active le
Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering. Task open_chart requires persisted evidence references. Task set_overlay can omit data; Server resolves immutable evidence into bounded GeoJSON. Supplied data must match that resolution and never overrides the stored snapshot. Chat and Dev supply resolved data for structural validation before rendering.
Same-time-of-day current/history comparisons use `chart_type=time_comparison`; ordinary charts retain `line` or `bar`. The comparison axis contains shared UTC+8 `HH:mm` or `HH:mm:ss` slots, with current `line` and historical `scatter` series and aligned real `sample_times`. The Server automatically validates new task presentation and report chart publications, rejecting date-appended axes, missing timestamps, misplaced points and nonhistorical reference dates with `TIME_COMPARISON_INVALID`. Ordinary charts skip this semantic check. Historical stored chart read contracts remain unchanged.
Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point. Chat, task following and Dev previews share the frontend command dispatcher but own independent map sources, SCADA layers and chart state. Clearing or switching one owner does not clear another. Dev JSONL is an explicit local preview/import/export container, not an alternative online business protocol. Plain data records and ui_block records do not execute embedded actions. The earlier agent-ui@1 registry has no active tool mappings and is not an action entry point.
## Running and waiting input delivery ## Running and waiting input delivery
@@ -65,3 +67,13 @@ business task.
Running text additions use `POST /api/v1/responses` with stream=false, conversation, `tjwater.delivery="steer" | "queue"` and a stable tjwater.input_id. HTTP 202/tjwater.response_input is an inbox receipt, not execution completion. The existing SSE connection carries tjwater.user_input pending/delivered states; delivered means the native inbox took the input. `GET /api/v1/session/:session_id/inputs` lists pending input; PATCH of its /:input_id delivery can change its mode before processing and returns 409 afterward. Restored session streams retain these semantics. Running text additions use `POST /api/v1/responses` with stream=false, conversation, `tjwater.delivery="steer" | "queue"` and a stable tjwater.input_id. HTTP 202/tjwater.response_input is an inbox receipt, not execution completion. The existing SSE connection carries tjwater.user_input pending/delivered states; delivered means the native inbox took the input. `GET /api/v1/session/:session_id/inputs` lists pending input; PATCH of its /:input_id delivery can change its mode before processing and returns 409 afterward. Restored session streams retain these semantics.
A pending host question or failed continuation does not disable input admission. With no active host turn, steer/queue messages enter the native inbox with resume=false and remain available for the next owned continuation. This does not answer a host question or launch inference. A lost delivery acknowledgement can be reconciled only after verifying both native consumption and completed related output. HTTP 409 with code RESPONSE_FINISHED means the input was rejected before admission because the turn has ended; the client may refresh state and send it as a new response. Other failures do not authorize automatic resubmission. A pending host question or failed continuation does not disable input admission. With no active host turn, steer/queue messages enter the native inbox with resume=false and remain available for the next owned continuation. This does not answer a host question or launch inference. A lost delivery acknowledgement can be reconciled only after verifying both native consumption and completed related output. HTTP 409 with code RESPONSE_FINISHED means the input was rejected before admission because the turn has ended; the client may refresh state and send it as a new response. Other failures do not authorize automatic resubmission.
## Chat state projections
`src/routes/chat-run-accumulator.ts` consumes native and host events before publishing them.
Permission replies (including `permission_response` with `status="error"` and `error`),
question reconciliation and abort must use `publishRunEvent`, rather than mutate legacy messages.
Canonical parts are the saved history; reconnect compatibility fields are projections of the same events.
`data-run_status` retains completed, waiting_input, error or aborted state for reloads. Host-confirmed
interaction updates may update stored parts after a response has finished without reopening its wire stream.
UI, redacted transcript and native execution history retain their separate purposes and retention rules.
+14 -1
View File
@@ -4683,6 +4683,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -4846,6 +4851,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -5660,6 +5666,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -5773,6 +5784,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -10211,7 +10223,8 @@
"chart_type": { "chart_type": {
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
], ],
"title": "Chart Type", "title": "Chart Type",
"type": "string" "type": "string"
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
}
}
}
+2 -1
View File
@@ -11,7 +11,8 @@
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts", "generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts",
"check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts", "check:openapi": "bun scripts/server-contract.ts",
"sync:openapi": "bun scripts/server-contract.ts --sync",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"check:models": "bun scripts/check-models.ts", "check:models": "bun scripts/check-models.ts",
"check:websearch": "bun scripts/check-websearch.ts", "check:websearch": "bun scripts/check-websearch.ts",
+57
View File
@@ -0,0 +1,57 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, writeFile, rename, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
const local = new URL("../openapi/openapi.json", import.meta.url);
const manifest = new URL("../openapi/server-manifest.json", import.meta.url);
const generated = new URL("../src/generated/server-api.d.ts", import.meta.url);
const defaultUpstream = new URL("../../next-tjwater-server/contracts/server-v1.openapi.json", import.meta.url);
export function assertContractDigest(raw: string, expected: string) {
if (createHash("sha256").update(raw).digest("hex") !== expected) throw new Error("Server contract digest differs; run bun run sync:openapi");
}
async function main() {
const sync = process.argv.includes("--sync");
const upstreamIndex = process.argv.indexOf("--upstream");
if (upstreamIndex >= 0 && !process.argv[upstreamIndex + 1]) throw new Error("--upstream requires an OpenAPI file");
const upstream = upstreamIndex >= 0 ? resolve(process.argv[upstreamIndex + 1]) : defaultUpstream;
let upstreamRaw: string | undefined;
try { upstreamRaw = await readFile(upstream, "utf8"); }
catch (error) {
if (sync || upstreamIndex >= 0 || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (sync) {
const producerManifest = JSON.parse(await readFile(resolve(
dirname(upstream instanceof URL ? fileURLToPath(upstream) : upstream), "manifest.json"
), "utf8"));
assertContractDigest(upstreamRaw!, producerManifest.contracts.server.sha256);
// Complete validation and generation before replacing any checked-in artifact.
const types = COMMENT_HEADER + astToString(await openapiTS(JSON.parse(upstreamRaw!)));
const artifacts = [
{ target: local, content: upstreamRaw! },
{ target: manifest, content: JSON.stringify(producerManifest, null, 2) + "\n" },
{ target: generated, content: types },
].map((artifact) => ({ ...artifact, temporary: new URL(`${artifact.target.href}.${randomUUID()}.tmp`) }));
try {
for (const artifact of artifacts) await writeFile(artifact.temporary, artifact.content);
for (const artifact of artifacts) await rename(artifact.temporary, artifact.target);
} finally {
await Promise.all(artifacts.map((artifact) => rm(artifact.temporary, { force: true })));
}
console.log("Server OpenAPI snapshot, producer digest and types synchronized.");
return;
}
const raw = await readFile(local, "utf8");
const receipt = JSON.parse(await readFile(manifest, "utf8"));
assertContractDigest(raw, receipt.contracts.server.sha256);
if (upstreamRaw !== undefined && upstreamRaw !== raw) throw new Error("Upstream Server contract changed; run bun run sync:openapi");
if (await readFile(generated, "utf8") !== COMMENT_HEADER + astToString(await openapiTS(JSON.parse(raw)))) throw new Error("Generated Server types differ; run bun run generate:openapi");
console.log(upstreamRaw === undefined
? "Local Server contract digest and types checked; upstream unavailable in this checkout."
: "Upstream Server contract, digest and generated types checked.");
}
if (import.meta.main) await main();
+6 -4
View File
@@ -33,8 +33,8 @@ export function createAnswerStreamProjection(options: {
} } as UIMessageChunk); } } as UIMessageChunk);
options.onChange(); options.onChange();
}; };
const appendData = (event: string, data: Record<string, unknown>) => { const updateData = (event: string, data: Record<string, unknown>, hostState = false) => {
if (finished) return; if (finished && !hostState) return;
if (event === "answer_ui") endText(); if (event === "answer_ui") endText();
const type = `data-${event}`; const type = `data-${event}`;
const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string"); const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string");
@@ -42,9 +42,11 @@ export function createAnswerStreamProjection(options: {
const part = id ? { type, id, data } : { type, data }; const part = id ? { type, id, data } : { type, data };
if (existing >= 0) parts[existing] = part; if (existing >= 0) parts[existing] = part;
else parts.push(part); else parts.push(part);
options.write(part as UIMessageChunk); if (!finished) options.write(part as UIMessageChunk);
options.onChange(); options.onChange();
}; };
const appendData = (event: string, data: Record<string, unknown>) => updateData(event, data);
const recordData = (event: string, data: Record<string, unknown>) => updateData(event, data, true);
const finish = (finishReason: "stop" | "error" = "stop") => { const finish = (finishReason: "stop" | "error" = "stop") => {
if (finished) return; if (finished) return;
if (!parts.length) appendText("Agent 已完成处理。"); if (!parts.length) appendText("Agent 已完成处理。");
@@ -53,5 +55,5 @@ export function createAnswerStreamProjection(options: {
options.onChange(); options.onChange();
options.write({ type: "finish", finishReason }); options.write({ type: "finish", finishReason });
}; };
return { parts, appendText, appendData, finish, get finished() { return finished; } }; return { parts, appendText, appendData, recordData, finish, get finished() { return finished; } };
} }
+2 -1
View File
@@ -310,7 +310,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+1 -1
View File
@@ -16,7 +16,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
const chartSchema = z const chartSchema = z
.object({ .object({
title: z.string().max(200), title: z.string().max(200),
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(), x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100), x_data: z.array(z.string().max(128)).min(1).max(100),
+2 -1
View File
@@ -211,7 +211,8 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"line", "line",
"bar" "bar",
"time_comparison"
] ]
}, },
"x_axis_name": { "x_axis_name": {
+5 -1
View File
@@ -7507,6 +7507,8 @@ export interface components {
* Format: uuid * Format: uuid
*/ */
execution_request_id: string; execution_request_id: string;
/** Execution Scope */
execution_scope: string;
/** Handling Context */ /** Handling Context */
handling_context?: { handling_context?: {
[key: string]: components["schemas"]["JsonValue"]; [key: string]: components["schemas"]["JsonValue"];
@@ -7863,6 +7865,8 @@ export interface components {
* Format: uuid * Format: uuid
*/ */
execution_request_id: string; execution_request_id: string;
/** Execution Scope */
execution_scope: string;
/** Handling Context */ /** Handling Context */
handling_context?: { handling_context?: {
[key: string]: components["schemas"]["JsonValue"]; [key: string]: components["schemas"]["JsonValue"];
@@ -9600,7 +9604,7 @@ export interface components {
* Chart Type * Chart Type
* @enum {string} * @enum {string}
*/ */
chart_type: "line" | "bar"; chart_type: "line" | "bar" | "time_comparison";
/** Series */ /** Series */
series: components["schemas"]["TaskSafeChartSeries"][]; series: components["schemas"]["TaskSafeChartSeries"][];
/** Title */ /** Title */
+3 -3
View File
@@ -203,7 +203,8 @@ export class McpQueryClient {
if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required"); if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required");
// No model-supplied identity, scope, bearer or Server URL can pass this boundary. // No model-supplied identity, scope, bearer or Server URL can pass this boundary.
const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema; const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema;
const budget = queryTimeouts(this.tools.find((tool) => tool.name === name)!, args); const definition = this.tools.find((tool) => tool.name === name)!;
const budget = queryTimeouts(definition, args);
const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]); const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]);
if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter"); if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter");
const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal); const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal);
@@ -223,8 +224,7 @@ export class McpQueryClient {
const handle = body.context_handle; const handle = body.context_handle;
try { try {
return await this.withClient(token.token, project, signal, async (client) => { return await this.withClient(token.token, project, signal, async (client) => {
await client.listTools(undefined, { signal, timeout: 5000 }); return client.callTool({ name, arguments: { ...args, ...(definition.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs, toolDefinition: definition });
return client.callTool({ name, arguments: { ...args, ...(this.tools.find((tool) => tool.name === name)!.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
}, budget.callMs); }, budget.callMs);
} finally { } finally {
// Expiry remains authoritative if transport cancellation prevents revocation. // Expiry remains authoritative if transport cancellation prevents revocation.
+221
View File
@@ -0,0 +1,221 @@
import type { UIMessage, UIMessageChunk } from "ai";
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import type { PermissionRequestPayload, QuestionRequestPayload, TodoUpdatePayload } from "./chatStream.js";
import {
type ActiveRun, type RunStatus, updateLastAssistantMessage, applyInteractionResponse,
completeBackendProgress, cancelBackendTodos, upsertBackendProgress,
toFrontendPermission, upsertBackendQuestion, upsertBackendTodoUpdate,
appendBackendToolArtifact, appendBackendUiEnvelope,
} from "./chatUiState.js";
/** Compatibility fields are a projection of the same accepted event as canonical parts. */
function reduceRunEvent(activeRun: ActiveRun, event: string, data: Record<string, unknown>, onStatus: (status: RunStatus) => void) {
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
onStatus(activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error" || event === "aborted") {
activeRun.status = event === "aborted" || activeRun.status === "aborted" ? "aborted" : "error";
onStatus(activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && (reply || data.status === "error")) {
if (reply) activeRun.pendingPermissions.delete(requestId);
activeRun.messages = applyInteractionResponse(activeRun.messages, "permission_response", data);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = applyInteractionResponse(activeRun.messages, "question_response", data);
}
} else if (event === "execution_plan") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), data],
}));
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
}
}
/** All host mutations go through the accumulator before subscribers or persistence. */
export function publishRunEvent(run: ActiveRun, event: string, data: Record<string, unknown>, notify = true) {
if (run.applyEvent) run.applyEvent(event, data);
else reduceRunEvent(run, event, data, () => {});
if (notify) for (const subscriber of run.subscribers) {
if (event === "done" || event === "error" || event === "aborted") {
subscriber.write("run_status", { ...data, id: "run-status", status: run.status });
}
subscriber.write(event, data);
}
}
/** One event owner updates the reconnect view and canonical wire/storage projections. */
export function createChatRunAccumulator(options: {
run: ActiveRun;
messages: UIMessage[];
messageId: string;
sessionId: string;
collaboration: boolean;
write: (chunk: UIMessageChunk) => void;
onStatus: (status: RunStatus) => void;
}) {
const activeRun = options.run;
let history = [...options.messages];
const syncParts = () => {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, id: options.messageId, parts: projection.parts.map((part) => ({ ...part })),
}));
};
const projection = createAnswerStreamProjection({
messageId: options.messageId, sessionId: options.sessionId,
write: options.write, onChange: syncParts,
});
const apply = (event: string, data: Record<string, unknown>) => {
reduceRunEvent(activeRun, event, data, options.onStatus);
let historicalResponse = false;
if (event === "permission_response" || event === "question_response") {
const updated = applyInteractionResponse(history, event, data);
historicalResponse = updated !== history;
history = updated;
if (historicalResponse) {
const byId = new Map(history.map((message) => [message.id, message]));
activeRun.messages = activeRun.messages.map((message) => {
if (!message || typeof message !== "object" || !("id" in message)) return message;
const canonical = byId.get(String(message.id));
return canonical ? { ...message, parts: canonical.parts } : message;
});
}
}
const hidden = options.collaboration && ["progress", "todo_update", "tool_call", "ui_envelope", "execution_plan"].includes(event);
if (!hidden && !historicalResponse) {
if (event === "token") projection.appendText(typeof data.content === "string" ? data.content : "");
else if (event === "done" || event === "error" || event === "aborted") {
const message = typeof data.message === "string" ? data.message : event === "aborted" ? "请求已中断" : "Agent stream failed";
if (!projection.parts.some((part) => ["text", "data-final_answer", "data-answer_ui"].includes(String(part.type)))) {
const assistant = activeRun.messages.at(-1) as { content?: string } | undefined;
projection.appendText(assistant?.content || message);
}
projection.recordData("run_status", { id: "run-status", status: activeRun.status, ...(event === "done" ? {} : { message }) });
if (event !== "done" && !projection.finished) options.write({ type: "error", errorText: message });
projection.finish(event === "done" ? "stop" : "error");
} else if (["permission_response", "question_response"].includes(event)) projection.recordData(event, data);
else projection.appendData(event, data);
}
syncParts();
};
const uiMessages = (): UIMessage[] => [...history, {
id: options.messageId, role: "assistant", parts: projection.parts.map((part) => ({ ...part })),
} as UIMessage];
activeRun.applyEvent = apply;
activeRun.uiMessages = uiMessages;
return {
apply,
finish: projection.finish,
get finished() { return projection.finished; },
uiMessages,
appendInput(message: UIMessage, text: string) {
if (history.some((item) => item.id === message.id)) return;
history.push(message);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: message.id, role: "user", content: text }, ...(tail ? [tail] : [])];
},
};
}
+12 -25
View File
@@ -1,3 +1,5 @@
import { publishRunEvent } from "./chat-run-accumulator.js";
import { toSessionScope } from "../sessions/session-scope.js";
import { type Router } from "express"; import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js"; import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
@@ -13,9 +15,6 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
import { import {
type ActiveRun, type ActiveRun,
type RunStatus, type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js"; } from "./chatUiState.js";
const abortPayloadSchema = z.object({ const abortPayloadSchema = z.object({
@@ -33,11 +32,6 @@ type RegisterAuxiliaryRoutesOptions = {
frontendActionCoordinator: FrontendActionCoordinator; frontendActionCoordinator: FrontendActionCoordinator;
}; };
const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
export const registerChatAuxiliaryRoutes = ( export const registerChatAuxiliaryRoutes = (
chatRouter: Router, chatRouter: Router,
@@ -80,12 +74,12 @@ export const registerChatAuxiliaryRoutes = (
const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId); const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId);
if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true }); if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true });
activeRuns.get(sessionRecord.sessionId)?.controller.abort(); activeRuns.get(sessionRecord.sessionId)?.controller.abort();
await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord)); await interactionRequestStore?.cancelPending(toSessionScope(sessionRecord));
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord)); const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionScope(sessionRecord));
if (pendingInteraction?.delivery && runtime && interactionRequestStore) { if (pendingInteraction?.delivery && runtime && interactionRequestStore) {
const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id); const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id);
const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id); const latest = await interactionRequestStore.get(toSessionScope(sessionRecord), pendingInteraction.interaction_id);
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.interaction_id, { if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionScope(sessionRecord), latest.interaction_id, {
expected_revision: latest.delivery.revision, status, expected_revision: latest.delivery.revision, status,
}); });
} }
@@ -99,28 +93,21 @@ export const registerChatAuxiliaryRoutes = (
run.status = "aborted"; run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted"); lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort(); run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({ publishRunEvent(run, "aborted", { session_id: parsed.data.session_id, message: "请求已中断" }, false);
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
const currentState = await sessionUiStateStore.read( const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord), toSessionScope(sessionRecord),
); );
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), { await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages, ...currentState,
messages: run.uiMessages?.() ?? run.messages,
}); });
for (const subscriber of run.subscribers) { for (const subscriber of run.subscribers) {
subscriber.write("error", { subscriber.write("error", {
session_id: parsed.data.session_id, session_id: parsed.data.session_id,
message: "请求已中断", message: "请求已中断",
status: "aborted",
}); });
} }
// The generation route emits its terminal Response after cleanup and // The generation route emits its terminal Response after cleanup and
+25 -53
View File
@@ -1,3 +1,5 @@
import { publishRunEvent } from "./chat-run-accumulator.js";
import { toSessionScope } from "../sessions/session-scope.js";
import { refreshSessionCredential } from "../product/credential-refresh.js"; import { refreshSessionCredential } from "../product/credential-refresh.js";
import { type Router } from "express"; import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
@@ -8,9 +10,7 @@ import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js"; import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { import {
type ActiveRun, type ActiveRun, applyInteractionResponse,
toPermissionStatus,
updateLastAssistantPermission,
} from "./chatUiState.js"; } from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({ const permissionReplyPayloadSchema = z.object({
@@ -35,7 +35,6 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
}; };
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = ( export const registerChatInteractionRoutes = (
chatRouter: Router, chatRouter: Router,
@@ -104,12 +103,13 @@ export const registerChatInteractionRoutes = (
} }
const persistPermissionState = async () => { const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read( const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId), toSessionScope(sessionRecord),
); );
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), { await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages, ...currentState,
messages: run.uiMessages?.() ?? run.messages,
}); });
}; };
@@ -121,18 +121,10 @@ export const registerChatInteractionRoutes = (
message: parsed.data.message, message: parsed.data.message,
}); });
} catch (error) { } catch (error) {
run.messages = updateLastAssistantPermission( publishRunEvent(run, "permission_response", {
run.messages, session_id: sessionRecord.sessionId, request_id: requestId, status: "error",
requestId, error: error instanceof Error ? error.message : "failed to reply permission",
(permission) => ({ });
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
await persistPermissionState().catch((persistError) => { await persistPermissionState().catch((persistError) => {
logger.warn( logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId }, { err: persistError, sessionId: sessionRecord.sessionId },
@@ -146,30 +138,15 @@ export const registerChatInteractionRoutes = (
return; return;
} }
run.pendingPermissions.delete(requestId); publishRunEvent(run, "permission_response", {
const status = toPermissionStatus(parsed.data.reply); session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply,
run.messages = updateLastAssistantPermission( });
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
await persistPermissionState().catch((persistError) => { await persistPermissionState().catch((persistError) => {
logger.warn( logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId }, { err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state", "failed to persist permission reply state",
); );
}); });
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({ res.status(202).json({
session_id: sessionRecord.sessionId, session_id: sessionRecord.sessionId,
@@ -219,23 +196,19 @@ export const registerChatInteractionRoutes = (
} }
} }
// A host-confirmed reply remains authoritative after native forms are pruned. // A host-confirmed reply remains authoritative after native forms are pruned.
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId }); const form = confirmedStatus ? undefined : await runtime.getQuestion({ sessionId: session.sessionId, requestId: canonicalId });
const nativeStatus = form?.state.status ?? "missing";
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!); const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
const persist = async (nextStatus: string, answers?: string[][]) => { const persist = async (nextStatus: string, answers?: string[][]) => {
const update = (messages: unknown[]) => messages.map((message) => { const response = {
if (!isRecord(message)) return message; session_id: session.sessionId, request_id: canonicalId, status: nextStatus,
const questions = Array.isArray(message.questions) ? message.questions.map((q: unknown) => isRecord(q) && q.requestId === canonicalId ? { ...q, status: nextStatus, ...(answers ? { answers } : {}), error: undefined } : q) : undefined; rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}),
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined; };
if (parts && nextStatus !== "pending" && parts.some((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) && part.data.request_id === canonicalId)) {
const response = { type: "data-question_response", data: { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}) } };
const index = parts.findIndex((part: unknown) => isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId);
if (index >= 0) parts[index] = { ...parts[index], data: { ...(isRecord(parts[index]) && isRecord(parts[index].data) ? parts[index].data : {}), ...response.data } }; else parts.push(response);
}
return { ...message, ...(questions ? { questions } : {}), ...(parts ? { parts } : {}) };
});
// Update the live projection before entering the same atomic persistence queue. // Update the live projection before entering the same atomic persistence queue.
if (run) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); } if (run && nextStatus !== "pending") publishRunEvent(run, "question_response", response);
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) })); await sessionUiStateStore.update(context, (latest) => ({
...latest, messages: applyInteractionResponse(latest.messages, "question_response", response),
}));
}; };
if (action === "status" || status !== "pending") { if (action === "status" || status !== "pending") {
await persist(status); await persist(status);
@@ -243,10 +216,9 @@ export const registerChatInteractionRoutes = (
return; return;
} }
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined; const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] }); if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [], form: form! });
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId }); else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
await persist(action === "reply" ? "answered" : "rejected", answers); await persist(action === "reply" ? "answered" : "rejected", answers);
for (const subscriber of run?.subscribers ?? []) subscriber.write("question_response", { session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId); if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) }); res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
} catch (error) { } catch (error) {
-21
View File
@@ -174,27 +174,6 @@ export const generateSessionTitle = async (
} }
}; };
export const getConversationTurnStats = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 12);
return messages.reduce(
(stats, message) => {
if (message.info.role === "user") {
stats.userMessageCount += 1;
} else if (message.info.role === "assistant") {
stats.assistantMessageCount += 1;
}
return stats;
},
{
userMessageCount: 0,
assistantMessageCount: 0,
},
);
};
export const buildPromptWithLearningContext = async ( export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, actorKey: string,
+24 -25
View File
@@ -1,3 +1,4 @@
import { toSessionScope } from "../sessions/session-scope.js";
import { registerChatTopicRoutes } from "./chat-topic-routes.js"; import { registerChatTopicRoutes } from "./chat-topic-routes.js";
import { matchHistoryText } from "../sessions/history-search.js"; import { matchHistoryText } from "../sessions/history-search.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js"; import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
@@ -107,11 +108,6 @@ type RegisterChatSessionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
}; };
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const toSse = (event: string, data: Record<string, unknown>) => const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -176,6 +172,12 @@ export const registerChatSessionRoutes = (
const { activeRuns, lastRunStatuses } = runState; const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus; const getSessionRunStatus = runState.getStatus;
const readMessages = (record: SessionRecord, state: { messages: unknown[] } | null) => {
if (readPurpose(record) === "task_collaboration") return state?.messages ?? [];
const run = activeRuns.get(record.sessionId);
return run?.uiMessages?.() ?? run?.messages ?? state?.messages ?? [];
};
const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => { const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
if (!interactionRequestStore) return messages; if (!interactionRequestStore) return messages;
const ids = new Set(messages.flatMap((message) => { const ids = new Set(messages.flatMap((message) => {
@@ -183,7 +185,7 @@ export const registerChatSessionRoutes = (
const value = message as Record<string, unknown>; const value = message as Record<string, unknown>;
return value.role === "user" && typeof value.id === "string" ? [value.id] : []; return value.role === "user" && typeof value.id === "string" ? [value.id] : [];
})); }));
const replies = await interactionRequestStore.forMessages(toSessionUiStateContext(record), ids); const replies = await interactionRequestStore.forMessages(toSessionScope(record), ids);
return projectInteractionReplyMessages(messages, replies); return projectInteractionReplyMessages(messages, replies);
}; };
@@ -192,7 +194,7 @@ export const registerChatSessionRoutes = (
|| !isGenericTaskCollaborationTitle(record.title)) return record; || !isGenericTaskCollaborationTitle(record.title)) return record;
if ((!credential && !knownTaskTitle) || signal?.aborted) return record; if ((!credential && !knownTaskTitle) || signal?.aborted) return record;
try { try {
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
if (state?.isTitleManuallyEdited) return record; if (state?.isTitleManuallyEdited) return record;
let taskTitle = knownTaskTitle; let taskTitle = knownTaskTitle;
if (!taskTitle && credential) { if (!taskTitle && credential) {
@@ -206,7 +208,7 @@ export const registerChatSessionRoutes = (
} }
} }
if (!taskTitle?.trim()) return record; if (!taskTitle?.trim()) return record;
const latestState = await sessionUiStateStore.read(toSessionUiStateContext(record)); const latestState = await sessionUiStateStore.read(toSessionScope(record));
if (latestState?.isTitleManuallyEdited) return record; if (latestState?.isTitleManuallyEdited) return record;
return await sessionMetadataStore.touch(record, { title: taskTitle }, { return await sessionMetadataStore.touch(record, { title: taskTitle }, {
preserveUpdatedAt: true, expectedTitle: record.title ?? "", preserveUpdatedAt: true, expectedTitle: record.title ?? "",
@@ -297,7 +299,7 @@ export const registerChatSessionRoutes = (
sessionId, sessionId,
); );
if (!record) return null; if (!record) return null;
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
if (!state) return null; if (!state) return null;
return { record, state, taskTitle }; return { record, state, taskTitle };
}; };
@@ -603,7 +605,7 @@ export const registerChatSessionRoutes = (
const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` }); const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` });
const messages = await withInteractionReplies(history.record, history.state.messages ?? []); const messages = await withInteractionReplies(history.record, history.state.messages ?? []);
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(titled), toSessionScope(titled),
buildForkedSessionUiState({ messages }, { buildForkedSessionUiState({ messages }, {
keepMessageCount: messages.length, keepMessageCount: messages.length,
targetSessionId: titled.sessionId, targetSessionId: titled.sessionId,
@@ -771,8 +773,8 @@ export const registerChatSessionRoutes = (
const searchable: Array<SessionRecord & { searchSnippet?: string }> = []; const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
for (const record of records) { for (const record of records) {
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase()); const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null; const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionScope(record)) : null;
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? []; const messages = readMessages(record, state);
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined }); searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
} }
let page; let page;
@@ -828,9 +830,8 @@ export const registerChatSessionRoutes = (
const context = getLocalAgentContext(req); const context = getLocalAgentContext(req);
const record = await sessionMetadataStore.get(context, req.params.session_id); const record = await sessionMetadataStore.get(context, req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; } if (!record) { res.status(404).json({ message: "session not found" }); return; }
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(record, state);
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
try { try {
const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data }); const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data });
res.json({ ...page, messages: await withInteractionReplies(record, page.messages) }); res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
@@ -871,10 +872,9 @@ export const registerChatSessionRoutes = (
sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req)); sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req));
const state = await sessionUiStateStore.read( const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord), toSessionScope(sessionRecord),
); );
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(sessionRecord, state);
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, { const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit, scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages }; }) : { messages };
@@ -929,9 +929,8 @@ export const registerChatSessionRoutes = (
res.flushHeaders?.(); res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId); const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord)); const state = await sessionUiStateStore.read(toSessionScope(sessionRecord));
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(sessionRecord, state);
: run?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, { const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit, scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages }; }) : { messages };
@@ -1005,11 +1004,11 @@ export const registerChatSessionRoutes = (
title, title,
}); });
const state = await sessionUiStateStore.read( const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord), toSessionScope(nextSessionRecord),
); );
if (state) { if (state) {
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord), toSessionScope(nextSessionRecord),
{ {
...state, ...state,
isTitleManuallyEdited: isTitleManuallyEdited:
@@ -1134,7 +1133,7 @@ export const registerChatSessionRoutes = (
} }
const sourceState = sourceSessionRecord const sourceState = sourceSessionRecord
? await sessionUiStateStore.read( ? await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord), toSessionScope(sourceSessionRecord),
) )
: null; : null;
const forkTitle = sourceSessionRecord?.title const forkTitle = sourceSessionRecord?.title
@@ -1145,7 +1144,7 @@ export const registerChatSessionRoutes = (
{ title: forkTitle }, { title: forkTitle },
); );
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord), toSessionScope(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, { buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count, keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId, targetSessionId: nextSessionId,
+23 -254
View File
@@ -1,3 +1,4 @@
import { toSessionScope } from "../sessions/session-scope.js";
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js"; import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js"; import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js"; import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
@@ -5,7 +6,7 @@ import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-r
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js"; import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js"; import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js";
import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.js"; import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.js";
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js"; import { createChatRunAccumulator, publishRunEvent } from "./chat-run-accumulator.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js"; import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js"; import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express"; import { type Router } from "express";
@@ -47,7 +48,6 @@ import {
} from "../product/operational-task-client.js"; } from "../product/operational-task-client.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
@@ -59,29 +59,14 @@ import {
} from "./chatSession.js"; } from "./chatSession.js";
import { import {
collectTextContent, collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse, streamPromptResponse,
type TodoUpdatePayload,
} from "./chatStream.js"; } from "./chatStream.js";
import { type ChatRunState } from "./chatRunState.js"; import { type ChatRunState } from "./chatRunState.js";
import { import {
type ActiveRun, type ActiveRun,
type StreamSubscriber,
appendBackendToolArtifact,
appendBackendUiEnvelope,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages, createInitialStreamingMessages,
isObjectRecord, isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage, updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js"; } from "./chatUiState.js";
import { import {
agentTriggerContextSchema, agentTriggerContextSchema,
@@ -106,11 +91,6 @@ const payloadSchema = z.object({
}).strict().optional(), }).strict().optional(),
}); });
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const runtimeHasConversation = async ( const runtimeHasConversation = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
@@ -311,7 +291,7 @@ export const registerChatStreamRoute = (
let boundOperationalSessionId: string | undefined; let boundOperationalSessionId: string | undefined;
let startingSessionId: string | undefined; let startingSessionId: string | undefined;
let continuation: InteractionRequest | null = null; let continuation: InteractionRequest | null = null;
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined; let continuationScope: ReturnType<typeof toSessionScope> | undefined;
let waitingInteraction: InteractionRequest | null = null; let waitingInteraction: InteractionRequest | null = null;
try { try {
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
@@ -365,7 +345,7 @@ export const registerChatStreamRoute = (
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return; res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
} }
if (existingSessionRecord && interactionRequestStore) { if (existingSessionRecord && interactionRequestStore) {
continuationScope = toSessionUiStateContext(existingSessionRecord); continuationScope = toSessionScope(existingSessionRecord);
if (input.tjwater.interaction_id) { if (input.tjwater.interaction_id) {
continuation = await interactionRequestStore.get(continuationScope, input.tjwater.interaction_id); continuation = await interactionRequestStore.get(continuationScope, input.tjwater.interaction_id);
if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") { if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") {
@@ -523,7 +503,7 @@ export const registerChatStreamRoute = (
sessionId: requestContext.clientSessionId, sessionId: requestContext.clientSessionId,
}; };
const initialSessionState = await sessionUiStateStore.read( const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord), toSessionScope(activeSessionRecord),
); );
const persistedUiMessages = toUiMessages(initialSessionState?.messages); const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) { if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
@@ -551,7 +531,6 @@ export const registerChatStreamRoute = (
...visiblePersistedMessages, ...visiblePersistedMessages,
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id), createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
]; ];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages); const baseMessages = toLegacyMessages(requestMessages);
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8); const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
@@ -605,44 +584,28 @@ export const registerChatStreamRoute = (
startingSessions.delete(activeSessionRecord.sessionId); startingSessions.delete(activeSessionRecord.sessionId);
startingSessionId = undefined; startingSessionId = undefined;
lastRunStatuses.set(clientSessionId, "running"); lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord); const sessionUiStateContext = toSessionScope(activeSessionRecord);
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`; const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId }); setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
const syncLatestUiMessages = () => { const accumulator = createChatRunAccumulator({
latestUiMessages = [ run: activeRun, messages: requestMessages, messageId: assistantUiMessageId, sessionId: clientSessionId,
...requestMessages, collaboration: sessionPurpose === "task_collaboration", write: (chunk) => wire!.write(chunk),
{ onStatus: (status) => lastRunStatuses.set(clientSessionId, status),
id: assistantUiMessageId,
role: "assistant",
parts: assistantParts.map((part) => ({ ...part })),
} as UIMessage,
];
};
const writeUiChunk = (chunk: UIMessageChunk) => {
// Persist canonical parts after disconnect; the wire emitter checks the socket.
wire!.write(chunk);
};
const projection = createAnswerStreamProjection({
messageId: assistantUiMessageId, sessionId: clientSessionId,
write: writeUiChunk, onChange: syncLatestUiMessages,
}); });
const assistantParts = projection.parts; const writeUiChunk = (chunk: UIMessageChunk) => wire!.write(chunk);
const writeTextDelta = projection.appendText;
const writeDataPart = projection.appendData;
const finishUiMessage = projection.finish;
writeUiChunk({ type: "start", messageId: assistantUiMessageId }); writeUiChunk({ type: "start", messageId: assistantUiMessageId });
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, { let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
executionPlans: executionPlanHistory, executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages, messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
}); });
const queueSessionUiStatePersist = () => { const queueSessionUiStatePersist = () => {
const snapshot = { const snapshot = {
executionPlans: executionPlanHistory, executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages, messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
}; };
persistQueue = persistQueue persistQueue = persistQueue
.catch((error) => { .catch((error) => {
@@ -654,36 +617,6 @@ export const registerChatStreamRoute = (
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot)); .then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue; return persistQueue;
}; };
const primarySubscriber: StreamSubscriber = {
write: (event, data) => {
if (event === "token") {
writeTextDelta(typeof data.content === "string" ? data.content : "");
return;
}
if (event === "done") {
finishUiMessage("stop");
return;
}
if (event === "error") {
writeUiChunk({
type: "error",
errorText:
typeof data.message === "string"
? data.message
: "Agent stream failed",
});
finishUiMessage("error");
return;
}
writeDataPart(event, data);
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
activeRun.subscribers.add(primarySubscriber);
let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve(); let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve();
const publish = (event: string, data: Record<string, unknown>) => { const publish = (event: string, data: Record<string, unknown>) => {
@@ -697,131 +630,11 @@ export const registerChatStreamRoute = (
: assistantUiMessageId, : assistantUiMessageId,
} }
: data; : data;
if (event === "token") { if (event === "execution_plan") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && reply) {
activeRun.pendingPermissions.delete(requestId);
activeRun.messages = updateLastAssistantPermission(
activeRun.messages,
requestId,
(permission) => ({
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
}),
);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = updateLastAssistantQuestion(
activeRun.messages,
requestId,
(question) => ({
...question,
status: data.rejected === true ? "rejected" : "answered",
repliedAt: Date.now(),
answers: Array.isArray(data.answers) ? data.answers : question.answers,
error: undefined,
}),
);
}
} else if (event === "execution_plan") {
try { try {
const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId); const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId);
executionPlanHistory = [...executionPlanHistory, snapshot]; executionPlanHistory = [...executionPlanHistory, snapshot];
subscriberData = snapshot; subscriberData = snapshot;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), snapshot],
}));
if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) { if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) {
taskArtifactPublishQueue = taskArtifactPublishQueue taskArtifactPublishQueue = taskArtifactPublishQueue
.catch(() => undefined) .catch(() => undefined)
@@ -843,23 +656,6 @@ export const registerChatStreamRoute = (
}); });
return; return;
} }
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
} }
const hiddenCollaborationEvent = sessionPurpose === "task_collaboration" && [ const hiddenCollaborationEvent = sessionPurpose === "task_collaboration" && [
@@ -869,12 +665,7 @@ export const registerChatStreamRoute = (
"ui_envelope", "ui_envelope",
"execution_plan", "execution_plan",
].includes(event); ].includes(event);
if (!hiddenCollaborationEvent) { publishRunEvent(activeRun, event, subscriberData, !hiddenCollaborationEvent);
for (const subscriber of activeRun.subscribers) {
subscriber.write(event, subscriberData);
}
}
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, id: assistantUiMessageId, parts: assistantParts.map((part) => ({ ...part })) }));
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
@@ -944,10 +735,7 @@ export const registerChatStreamRoute = (
: typeof generation === "number" && generation > 0 ? generation : null, : typeof generation === "number" && generation > 0 ? generation : null,
report_revision: reportRef.success ? reportRef.data.revision : null, report_revision: reportRef.success ? reportRef.data.revision : null,
}; };
const part = { type: "data-task_context" as const, data: reference }; accumulator.apply("task_context", reference);
assistantParts.push(part);
writeUiChunk(part);
syncLatestUiMessages();
} }
const preparedMessage = taskContext const preparedMessage = taskContext
? [ ? [
@@ -970,11 +758,8 @@ export const registerChatStreamRoute = (
const messaging = getTrustedProductCredential(req) ? agentMessages : undefined; const messaging = getTrustedProductCredential(req) ? agentMessages : undefined;
messaging?.start(activeSessionRecord); messaging?.start(activeSessionRecord);
const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => { const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => {
if (receipt.status === "delivered" && !requestMessages.some((message) => message.id === receipt.id)) { if (receipt.status === "delivered") {
requestMessages.push({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage); accumulator.appendInput({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage, receipt.text);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: receipt.id, role: "user", content: receipt.text }, ...(tail ? [tail] : [])];
syncLatestUiMessages();
} }
publish("user_input", { session_id: clientSessionId, ...receipt }); publish("user_input", { session_id: clientSessionId, ...receipt });
}); });
@@ -1125,14 +910,14 @@ export const registerChatStreamRoute = (
const assistantMessage = [...messages] const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .find((message) => message.info.role === "assistant");
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []); const assistantText = readUiMessageText(accumulator.uiMessages().at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord = const latestSessionRecord =
(await sessionMetadataStore.get( (await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId, activeSessionRecord.sessionId,
)) ?? activeSessionRecord; )) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read( const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord), toSessionScope(latestSessionRecord),
); );
const existingSessionTitle = latestSessionRecord.title; const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle; let sessionTitle = existingSessionTitle;
@@ -1229,29 +1014,13 @@ export const registerChatStreamRoute = (
await taskArtifactPublishQueue.catch(() => undefined); await taskArtifactPublishQueue.catch(() => undefined);
unregisterFrontendActionSink?.(); unregisterFrontendActionSink?.();
if (abortController.signal.aborted && !taskLeaseFailure) { if (abortController.signal.aborted && !taskLeaseFailure) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ publishRunEvent(activeRun, "aborted", { session_id: clientSessionId, message: "请求已中断" });
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (!projection.finished) {
writeUiChunk({
type: "error",
errorText: "请求已中断",
});
finishUiMessage("error");
}
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
}); });
} }
if (!projection.finished) { if (!accumulator.finished) {
finishUiMessage(activeRun.status === "error" ? "error" : "stop"); publishRunEvent(activeRun, activeRun.status === "error" ? "error" : "done", { session_id: clientSessionId });
} }
await queueSessionUiStatePersist().catch((error) => { await queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
+43 -34
View File
@@ -15,6 +15,8 @@ export type StreamSubscriber = {
}; };
export type ActiveRun = { export type ActiveRun = {
applyEvent?: (event: string, data: Record<string, unknown>) => void;
uiMessages?: () => unknown[];
inputs?: ResponseInputTracker; inputs?: ResponseInputTracker;
clientSessionId: string; clientSessionId: string;
controller: AbortController; controller: AbortController;
@@ -175,43 +177,50 @@ export const updateLastAssistantMessage = (
return messages; return messages;
}; };
export const updateLastAssistantPermission = ( /** Replies belong to the assistant that requested input, including earlier turns. */
messages: unknown[], export function applyInteractionResponse<T>(
requestId: string, messages: T[],
updater: (permission: Record<string, unknown>) => Record<string, unknown>, event: "permission_response" | "question_response",
) => data: Record<string, unknown>,
updateLastAssistantMessage(messages, (message) => { ): T[] {
const permissions = Array.isArray(message.permissions) const requestId = data.request_id;
? message.permissions if (typeof requestId !== "string") return messages;
: []; const permission = event === "permission_response";
return { const field = permission ? "permissions" : "questions";
...message, const requestType = permission ? "data-permission_request" : "data-question_request";
permissions: permissions.map((permission) => let changed = false;
isObjectRecord(permission) && permission.requestId === requestId const next = messages.map((message) => {
? updater(permission) if (!isObjectRecord(message) || message.role !== "assistant") return message;
: permission, const entries = Array.isArray(message[field]) ? message[field] as unknown[] : undefined;
), const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
const ownsRequest = entries?.some((entry) => isObjectRecord(entry) && entry.requestId === requestId)
|| parts?.some((part) => isObjectRecord(part) && part.type === requestType
&& isObjectRecord(part.data) && part.data.request_id === requestId);
if (!ownsRequest) return message;
changed = true;
const updated = entries?.map((entry) => {
if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry;
return permission ? {
...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply),
error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(),
} : {
...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered",
...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(),
}; };
}); });
if (parts && data.status !== "pending") {
export const updateLastAssistantQuestion = ( const type = `data-${event}`;
messages: unknown[], const index = parts.findIndex((part) => isObjectRecord(part) && part.type === type
requestId: string, && isObjectRecord(part.data) && part.data.request_id === requestId);
updater: (question: Record<string, unknown>) => Record<string, unknown>, const previous = index >= 0 && isObjectRecord(parts[index]) ? parts[index] : undefined;
) => const part = { ...previous, type, data: { ...(isObjectRecord(previous?.data) ? previous.data : {}), ...data } };
updateLastAssistantMessage(messages, (message) => { if (index >= 0) parts[index] = part;
const questions = Array.isArray(message.questions) else parts.push(part);
? message.questions }
: []; return { ...message, ...(updated ? { [field]: updated } : {}), ...(parts ? { parts } : {}) };
return {
...message,
questions: questions.map((question) =>
isObjectRecord(question) && question.requestId === requestId
? updater(question)
: question,
),
};
}); });
return changed ? next : messages;
}
const getToolArtifactKind = (tool: string): ToolArtifactKind => { const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "present_view") { if (tool === "present_view") {
+16
View File
@@ -0,0 +1,16 @@
import type { Request, Response } from "express";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export function readTaskBridgeContext(req: Request, token: string, allowQuery = false) {
if (!isTrustedInternalBridgeRequest(req, token)) return null;
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim()
: allowQuery && typeof req.query.session_id === "string" ? req.query.session_id.trim() : "";
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
return context?.taskId ? { context, runtimeSessionId, taskId: context.taskId } : null;
}
export function replyTaskBridgeError(res: Response, error: unknown, fallback: string) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : fallback });
}
+65
View File
@@ -0,0 +1,65 @@
import type { Express } from "express";
import { operationalTaskClient, expressAccessRequestSchema, proposedTaskActionSchema, humanTaskEvidenceCommandSchema, type OperationalTaskClient } from "../product/operational-task-client.js";
import { readTaskBridgeContext, replyTaskBridgeError } from "./task-bridge-context.js";
export function registerTaskCommandBridge(app: Express, options: {
internalToken: string;
client?: Pick<OperationalTaskClient, "requestDataAccess" | "proposeAction" | "addHumanEvidence">;
}) {
const client = options.client ?? operationalTaskClient;
app.post("/internal/operational-task/access-requests", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge || bridge.context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
}
const parsed = expressAccessRequestSchema.safeParse(req.body?.request);
if (!parsed.success) {
res.status(400).json({ message: "invalid access request" });
return;
}
try {
res.json(await client.requestDataAccess(bridge.runtimeSessionId, bridge.taskId, {
...parsed.data, sourceSessionId: bridge.context.clientSessionId,
}));
} catch (error) {
replyTaskBridgeError(res, error, "access request failed");
}
});
app.post("/internal/operational-task/actions", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge) {
res.status(403).json({ message: "operational task context unavailable" });
return;
}
const parsed = proposedTaskActionSchema.safeParse(req.body?.action);
if (!parsed.success) {
res.status(400).json({ message: "invalid proposed action", detail: parsed.error.flatten() });
return;
}
try {
res.json(await client.proposeAction(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
replyTaskBridgeError(res, error, "task action proposal failed");
}
});
app.post("/internal/operational-task/human-evidence", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge || bridge.context.purpose !== "task_collaboration") {
res.status(403).json({ message: "human evidence requires a task collaboration session" });
return;
}
const parsed = humanTaskEvidenceCommandSchema.safeParse(req.body?.command);
if (!parsed.success) {
res.status(400).json({ message: "invalid human evidence", detail: parsed.error.flatten() });
return;
}
try {
res.json(await client.addHumanEvidence(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
replyTaskBridgeError(res, error, "human evidence publish failed");
}
});
}
+7 -8
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js"; import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js"; import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js";
import { taskPresentationBridgeSchema } from "../product/task-presentation.js"; import { taskPresentationBridgeSchema } from "../product/task-presentation.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js"; import { readTaskBridgeContext, replyTaskBridgeError } from "./task-bridge-context.js";
const artifactsInputSchema = taskArtifactBridgeSchema.and(z.object({ session_id: z.string().trim().min(1).max(128) })); const artifactsInputSchema = taskArtifactBridgeSchema.and(z.object({ session_id: z.string().trim().min(1).max(128) }));
@@ -19,12 +19,12 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
return; return;
} }
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; const bridge = readTaskBridgeContext(req, options.internalToken);
const context = sessionId ? getRuntimeSessionContext(sessionId) : null; if (!bridge) {
if (!context?.taskId) {
res.status(403).json({ message: "operational task context unavailable" }); res.status(403).json({ message: "operational task context unavailable" });
return; return;
} }
const { context, runtimeSessionId: sessionId, taskId } = bridge;
if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") { if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" }); res.status(403).json({ message: "task execution context required" });
return; return;
@@ -36,7 +36,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() }); res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() });
return; return;
} }
res.json(await client.publishPresentation(sessionId, context.taskId, { res.json(await client.publishPresentation(sessionId, taskId, {
requestId: parsed.data.request_id, requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId, sourceSessionId: context.clientSessionId,
command: parsed.data.command, command: parsed.data.command,
@@ -47,7 +47,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() }); res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() });
return; return;
} }
res.json(await client.publishArtifacts(sessionId, context.taskId, { res.json(await client.publishArtifacts(sessionId, taskId, {
requestId: parsed.data.request_id, requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId, sourceSessionId: context.clientSessionId,
evidence: parsed.data.evidence, evidence: parsed.data.evidence,
@@ -57,8 +57,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
})); }));
} }
} catch (error) { } catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502; replyTaskBridgeError(res, error, `task ${kind} publish failed`);
res.status(status).json({ message: error instanceof Error ? error.message : `task ${kind} publish failed` });
} }
}); });
} }
+6 -14
View File
@@ -2,7 +2,7 @@ import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS,
import { assertWebSearchReady } from "./web-search-check.js"; import { assertWebSearchReady } from "./web-search-check.js";
import { allowedQueryTools } from "./query-mcp-policy.js"; import { allowedQueryTools } from "./query-mcp-policy.js";
import { setTimeout as delayAbortable } from "node:timers/promises"; import { setTimeout as delayAbortable } from "node:timers/promises";
import { isMessageNotFoundError, isSessionNotFoundError, type OpenCodeClient as OpencodeClient } from "@opencode/client"; import { isMessageNotFoundError, isSessionNotFoundError, type FormDetail, type OpenCodeClient as OpencodeClient } from "@opencode/client";
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js"; import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js"; import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
@@ -381,13 +381,6 @@ export class OpencodeRuntimeAdapter {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
return this.messages(sessionId);
}
async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) { async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const selected = model ?? parseModelReference(config.OPENCODE_MODEL); const selected = model ?? parseModelReference(config.OPENCODE_MODEL);
@@ -733,19 +726,18 @@ export class OpencodeRuntimeAdapter {
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message }); return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
} }
async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) { async replyQuestion(options: { requestId: string; sessionId: string; answers: QuestionAnswers; form: FormDetail }) {
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId"); if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
const client = await this.ensureClient(); const client = await this.ensureClient();
const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId }); return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(options.form, options.answers) });
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
} }
async getQuestionStatus(options: { requestId: string; sessionId: string }): Promise<"pending" | "answered" | "cancelled" | "missing"> { async getQuestion(options: { requestId: string; sessionId: string }): Promise<FormDetail | null> {
const client = await this.ensureClient(); const client = await this.ensureClient();
try { return (await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId })).state.status; } try { return await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId }); }
catch (error) { catch (error) {
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined; const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
if (tag === "FormNotFoundError") return "missing"; if (tag === "FormNotFoundError") return null;
throw error; throw error;
} }
} }
+4 -84
View File
@@ -37,12 +37,7 @@ import { createAgentMessageQuery, registerAgentMessageBridge } from "./routes/ag
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js"; import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js"; import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import { import { registerTaskCommandBridge } from "./routes/task-command-bridge.js";
operationalTaskClient,
expressAccessRequestSchema,
humanTaskEvidenceCommandSchema,
proposedTaskActionSchema,
} from "./product/operational-task-client.js";
import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js"; import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js";
const app = express(); const app = express();
@@ -53,9 +48,9 @@ const sessionWorkspaces = new SessionWorkspaceStore(config.CONVERSATION_WORKSPAC
const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces); const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces);
const answerViewStateStore = new AnswerViewStateStore(sessionWorkspaces); const answerViewStateStore = new AnswerViewStateStore(sessionWorkspaces);
const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces); const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces);
const sessionUiStateStore = new SessionUiStateStore(undefined, sessionWorkspaces); const sessionUiStateStore = new SessionUiStateStore(sessionWorkspaces);
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const sessionTranscriptStore = new SessionTranscriptStore(undefined, sessionWorkspaces); const sessionTranscriptStore = new SessionTranscriptStore(sessionWorkspaces);
const learningOrchestrator = new LearningOrchestrator( const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime, opencodeRuntime,
memoryStore, memoryStore,
@@ -112,80 +107,7 @@ app.post("/internal/frontend-actions/request", async (req, res) => {
} }
}); });
const readOperationalTaskBridgeContext = (req: express.Request) => { registerTaskCommandBridge(app, { internalToken });
if (!isTrustedInternalBridgeRequest(req, internalToken)) return null;
const runtimeSessionId = typeof req.body?.session_id === "string"
? req.body.session_id.trim()
: typeof req.query.session_id === "string"
? req.query.session_id.trim()
: "";
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
return context?.taskId ? { context, runtimeSessionId, taskId: context.taskId } : null;
};
app.post("/internal/operational-task/access-requests", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
if (!bridge || bridge.context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
}
const parsed = expressAccessRequestSchema.safeParse(req.body?.request);
if (!parsed.success) {
res.status(400).json({ message: "invalid access request" });
return;
}
try {
res.json(await operationalTaskClient.requestDataAccess(bridge.runtimeSessionId, bridge.taskId, {
...parsed.data, sourceSessionId: bridge.context.clientSessionId,
}));
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number"
? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : "access request failed" });
}
});
app.post("/internal/operational-task/actions", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
if (!bridge) {
res.status(403).json({ message: "operational task context unavailable" });
return;
}
const parsed = proposedTaskActionSchema.safeParse(req.body?.action);
if (!parsed.success) {
res.status(400).json({ message: "invalid proposed action", detail: parsed.error.flatten() });
return;
}
try {
res.json(await operationalTaskClient.proposeAction(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number"
? error.status
: 502;
res.status(status).json({ message: error instanceof Error ? error.message : "task action proposal failed" });
}
});
app.post("/internal/operational-task/human-evidence", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
if (!bridge || bridge.context.purpose !== "task_collaboration") {
res.status(403).json({ message: "human evidence requires a task collaboration session" });
return;
}
const parsed = humanTaskEvidenceCommandSchema.safeParse(req.body?.command);
if (!parsed.success) {
res.status(400).json({ message: "invalid human evidence", detail: parsed.error.flatten() });
return;
}
try {
res.json(await operationalTaskClient.addHumanEvidence(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number"
? error.status
: 502;
res.status(status).json({ message: error instanceof Error ? error.message : "human evidence publish failed" });
}
});
app.get("/health", async (_req, res) => { app.get("/health", async (_req, res) => {
try { try {
@@ -247,10 +169,8 @@ const bootstrap = async () => {
logger.info(migrated, "session records migrated to owned workspaces; legacy sources archived"); logger.info(migrated, "session records migrated to owned workspaces; legacy sources archived");
} }
await Promise.all([ await Promise.all([
sessionUiStateStore.initialize(),
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
sessionTranscriptStore.initialize(),
]); ]);
}; };
+8
View File
@@ -0,0 +1,8 @@
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
/** Always derive persistence scope from a host-validated session record. */
export const toSessionScope = (record: SessionWorkspaceContext): SessionWorkspaceContext => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
+6 -22
View File
@@ -1,10 +1,5 @@
import { join } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
import { import {
atomicWriteJson,
ensureDirectory,
listJsonFiles,
readJsonFile, readJsonFile,
toStableId, toStableId,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
@@ -50,11 +45,7 @@ const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
export class SessionTranscriptStore { export class SessionTranscriptStore {
private readonly writeQueues = new Map<string, Promise<void>>(); private readonly writeQueues = new Map<string, Promise<void>>();
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {} constructor(private readonly workspaces: SessionWorkspaceStore) {}
async initialize() {
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
async appendTurn( async appendTurn(
context: SessionTranscriptContext, context: SessionTranscriptContext,
@@ -66,7 +57,7 @@ export class SessionTranscriptStore {
) { ) {
const key = this.filePath(context); const key = this.filePath(context);
return this.serializeWrite(key, async () => { return this.serializeWrite(key, async () => {
if (this.workspaces) await this.workspaces.ensure(this.workspaceContext(context)); await this.workspaces.ensure(this.workspaceContext(context));
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。 // 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
const transcript = (await this.readTranscript(context)) ?? { const transcript = (await this.readTranscript(context)) ?? {
actorKey: context.actorKey, actorKey: context.actorKey,
@@ -148,10 +139,8 @@ export class SessionTranscriptStore {
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount), turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
updatedAt: timestamp, updatedAt: timestamp,
}; };
if (this.workspaces) {
await this.workspaces.ensure(this.workspaceContext(targetContext)); await this.workspaces.ensure(this.workspaceContext(targetContext));
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json"); await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
}
await this.persist(targetContext, nextTranscript); await this.persist(targetContext, nextTranscript);
return nextTranscript; return nextTranscript;
} }
@@ -168,7 +157,7 @@ export class SessionTranscriptStore {
} }
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean); const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
const hits: SessionSearchHit[] = []; const hits: SessionSearchHit[] = [];
const files = this.workspaces ? await this.workspaces.list(context, "transcript.json") : await listJsonFiles(this.baseDir); const files = await this.workspaces.list(context, "transcript.json");
for (const file of files) { for (const file of files) {
const transcript = await readJsonFile<SessionTranscriptRecord>(file); const transcript = await readJsonFile<SessionTranscriptRecord>(file);
if (!transcript) { if (!transcript) {
@@ -205,7 +194,7 @@ export class SessionTranscriptStore {
} }
private async readTranscript(context: SessionTranscriptContext) { private async readTranscript(context: SessionTranscriptContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null; if (!await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context)); const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey || if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey ||
(record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch"); (record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch");
@@ -213,16 +202,11 @@ export class SessionTranscriptStore {
} }
private filePath(context: SessionTranscriptContext) { private filePath(context: SessionTranscriptContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "transcript.json"); return this.workspaces.path(this.workspaceContext(context), "transcript.json");
return join(
this.baseDir,
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
);
} }
private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) { private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) {
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript); await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
else await atomicWriteJson(this.filePath(context), transcript);
} }
private workspaceContext(context: SessionTranscriptContext) { private workspaceContext(context: SessionTranscriptContext) {
+8 -29
View File
@@ -1,13 +1,9 @@
import { join } from "node:path"; import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
import { config } from "../config.js";
import { type SessionWorkspaceStore } from "./workspace-store.js"; import { type SessionWorkspaceStore } from "./workspace-store.js";
import { import {
atomicWriteJson,
ensureDirectory,
readJsonFile, readJsonFile,
removeFileIfExists, removeFileIfExists,
slugify,
} from "../utils/fileStore.js"; } from "../utils/fileStore.js";
export type SessionUiStateRecord = { export type SessionUiStateRecord = {
@@ -17,56 +13,39 @@ export type SessionUiStateRecord = {
executionPlans?: Record<string, unknown>[]; executionPlans?: Record<string, unknown>[];
}; };
type SessionUiStateContext = { type SessionUiStateContext = SessionWorkspaceContext;
sessionId: string;
actorKey?: string;
projectKey?: string;
};
export class SessionUiStateStore { export class SessionUiStateStore {
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {} constructor(private readonly workspaces: SessionWorkspaceStore) {}
async initialize() {
if (!this.workspaces) await ensureDirectory(this.baseDir);
}
async read(context: SessionUiStateContext) { async read(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null; if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
return await readJsonFile<SessionUiStateRecord>(this.filePath(context)); return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
} }
async write(context: SessionUiStateContext, state: SessionUiStateRecord) { async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch"); if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
if (this.workspaces) { await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
await this.workspaces.ensure(this.workspaceContext(context));
await this.workspaces.validate(this.workspaceContext(context), "messages.json");
}
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
else await atomicWriteJson(this.filePath(context), state);
return state; return state;
} }
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) { async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
if (this.workspaces) return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => { return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
const state = current as SessionUiStateRecord | null; const state = current as SessionUiStateRecord | null;
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable"); if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
const next = change(state); const next = change(state);
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch"); if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
return { result: next, value: next }; return { result: next, value: next };
}); });
const state = await this.read(context);
if (!state) throw new Error("chat state unavailable");
return this.write(context, change(state));
} }
async remove(context: SessionUiStateContext) { async remove(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return; if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
await removeFileIfExists(this.filePath(context)); await removeFileIfExists(this.filePath(context));
} }
private filePath(context: SessionUiStateContext) { private filePath(context: SessionUiStateContext) {
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "messages.json"); return this.workspaces.path(this.workspaceContext(context), "messages.json");
return join(this.baseDir, `${slugify(context.sessionId)}.json`);
} }
private workspaceContext(context: SessionUiStateContext) { private workspaceContext(context: SessionUiStateContext) {
+2 -2
View File
@@ -99,11 +99,11 @@ describe("locally rendered answers", () => {
try { try {
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" }; const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const store = new SessionUiStateStore(join(root, "legacy"), workspaces); const store = new SessionUiStateStore(workspaces);
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }]; const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
const messages = [{ id: "a", role: "assistant", parts }]; const messages = [{ id: "a", role: "assistant", parts }];
await store.write(owner, { sessionId: owner.sessionId, messages }); await store.write(owner, { sessionId: owner.sessionId, messages });
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root)); const restored = new SessionUiStateStore(new SessionWorkspaceStore(workspaces.root));
expect((await restored.read(owner))?.messages).toEqual(messages); expect((await restored.read(owner))?.messages).toEqual(messages);
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull(); expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull(); expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
+3 -2
View File
@@ -35,9 +35,10 @@ describe("unified view commands", () => {
}); });
it("passes aligned historical scatter points through native and publication contracts", () => { it("passes aligned historical scatter points through native and publication contracts", () => {
const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: { const command = { action: "open_chart", chart_id: "pressure-comparison", evidence_refs: ["E1"], chart: {
title: "压力七天对比", chart_type: "line", y_axis_name: "压力(m)", title: "压力七天对比", chart_type: "time_comparison", y_axis_name: "压力(m)",
x_data: ["19:30", "19:45", "20:00"], series: [ x_data: ["19:30", "19:45", "20:00"], series: [
{ name: "当前", type: "line", data: [32, null, 31] }, { name: "当前", type: "line", data: [32, null, 31],
sample_times: ["2026-06-14T19:30:00+08:00", null, "2026-06-14T20:00:00+08:00"] },
{ name: "前一天", type: "scatter", data: [null, 32, 33], { name: "前一天", type: "scatter", data: [null, 32, 33],
sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] }, sample_times: [null, "2026-06-13T19:45:00+08:00", "2026-06-13T20:00:00+08:00"] },
], ],
+1
View File
@@ -68,6 +68,7 @@ try {
if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`); if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`);
} }
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests"); if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
if (requests.filter((method) => method === "tools/list").length !== 1) throw new Error("Business queries fetched the catalog again after startup");
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated"); if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`); console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`);
} finally { } finally {
+18
View File
@@ -26,6 +26,24 @@ const resourceToken = jwt("resource");
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 }); const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); } function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
it("uses cached tool definitions without fetching the catalog on each query", async () => {
const id = "cached-tool-definition"; credential(id);
const client = fixture((async (url, init) => {
if (String(url).includes("/token")) return token();
return init?.method === "POST"
? Response.json({ context_handle: "x".repeat(43) })
: new Response(null, { status: 204 });
}) as McpHttpFetch);
const definition = { ...catalog[0], outputSchema: { type: "object", properties: { ok: { type: "boolean" } } } };
Object.assign(client, { tools: [definition], withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>) => run({
callTool: async (_params: unknown, options: { toolDefinition: unknown }) => {
expect(options.toolDefinition).toBe(definition);
return { content: [], structuredContent: { ok: true } };
},
}) });
expect((await client.query(id, owner(id), definition.name, {}, "call")).structuredContent).toEqual({ ok: true });
});
it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => { it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => {
const id = "mcp-calculation-budget"; credential(id); const id = "mcp-calculation-budget"; credential(id);
let requests = 0, transportTimeout = 0, sdkTimeout = 0; let requests = 0, transportTimeout = 0, sdkTimeout = 0;
+1 -1
View File
@@ -31,7 +31,7 @@ it("authenticates state reads and writes against the persisted session owner and
const context = getLocalAgentContext(ownerRequest); const context = getLocalAgentContext(ownerRequest);
const sessionId = "state-session"; const sessionId = "state-session";
await sessions.ensure({ ...context, sessionId }); await sessions.ensure({ ...context, sessionId });
await new SessionUiStateStore(join(root, "unused"), workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] }); await new SessionUiStateStore(workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] });
const app = express(); const app = express();
app.use(express.json({ limit: "2mb" })); app.use(express.json({ limit: "2mb" }));
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
+47
View File
@@ -0,0 +1,47 @@
import { expect, it } from "bun:test";
import type { UIMessageChunk } from "ai";
import { createChatRunAccumulator, publishRunEvent } from "../../src/routes/chat-run-accumulator.js";
import { createInitialStreamingMessages, type ActiveRun } from "../../src/routes/chatUiState.js";
it.each([false, true])("projects one event stream into reconnect and persisted messages (collaboration=%s)", (collaboration) => {
const run: ActiveRun = {
clientSessionId: "s", controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages([], "查询"), pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const chunks: UIMessageChunk[] = [];
const stream = createChatRunAccumulator({ run, messages: [{ id: "user", role: "user", parts: [{ type: "text", text: "查询" }] }],
messageId: "answer", sessionId: "s", collaboration, write: (chunk) => chunks.push(chunk), onStatus() {} });
stream.apply("progress", { id: "query", status: "running", title: "查询资料" });
stream.apply("token", { content: "已有数据" });
stream.apply("permission_request", { request_id: "permission", session_id: "s", permission: "shell", patterns: [], always: [], created_at: 1 });
stream.apply("permission_response", { request_id: "permission", reply: "once" });
stream.appendInput({ id: "extra", role: "user", parts: [{ type: "text", text: "补充" }] }, "补充");
stream.appendInput({ id: "extra", role: "user", parts: [] }, "重复");
stream.apply("final_answer", { content: "结论" });
stream.apply("done", {});
expect(run.status).toBe("completed");
expect(run.pendingPermissions.size).toBe(0);
expect(run.messages.at(-1)).toMatchObject({ id: "answer", content: "结论", permissions: [{ status: "approved_once" }], progress: [{ status: "completed" }, { status: "completed" }] });
const saved = stream.uiMessages();
expect(saved.map((message) => message.id)).toEqual(["user", "extra", "answer"]);
expect(saved.at(-1)?.parts).toEqual((run.messages.at(-1) as { parts: typeof saved[number]["parts"] }).parts);
expect(saved.at(-1)?.parts.some((part) => part.type === "data-progress")).toBe(!collaboration);
expect(chunks.filter((chunk) => chunk.type === "finish")).toHaveLength(1);
});
it.each(["done", "error", "aborted"])("publishes the same %s terminal state to reconnect subscribers and storage", (event) => {
const run: ActiveRun = {
clientSessionId: "s", controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages([], "查询"), pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const received: Array<{ event: string; data: Record<string, unknown> }> = [];
run.subscribers.add({ write: (event, data) => received.push({ event, data }), close() {} });
const stream = createChatRunAccumulator({ run, messages: [], messageId: "answer", sessionId: "s",
collaboration: false, write() {}, onStatus() {} });
publishRunEvent(run, "progress", { id: "tool", title: "正在处理", status: "running", phase: "tool" });
publishRunEvent(run, "token", { content: "部分结果" });
publishRunEvent(run, event, { session_id: "s", message: "执行已停止" });
const canonical = stream.uiMessages().at(-1)?.parts.find((part) => part.type === "data-run_status");
expect(received.at(-2)).toMatchObject({ event: "run_status", data: { status: event === "done" ? "completed" : event } });
expect(canonical).toMatchObject({ data: { status: received.at(-2)?.data.status } });
});
+1 -1
View File
@@ -22,7 +22,7 @@ it("persists owned topics, searches old text across themes and preserves chats w
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch; globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces); const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "ui"), workspaces); const states = new SessionUiStateStore(workspaces);
for (const id of ["a", "b", "unclassified"]) { for (const id of ["a", "b", "unclassified"]) {
await sessions.ensure({ ...context, sessionId: id }); await sessions.ensure({ ...context, sessionId: id });
await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [ await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [
+2 -1
View File
@@ -7,6 +7,7 @@ import { attachTrustedAgentContext } from "../../src/context/localContext.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js"; import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js"; import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js"; import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js"; import type { ActiveRun } from "../../src/routes/chatUiState.js";
@@ -28,7 +29,7 @@ describe("credential refresh HTTP route", () => {
const router = express.Router(); const router = express.Router();
router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); }); router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); });
const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]); const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]);
registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(join(directory, "ui")) }); registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(new SessionWorkspaceStore(join(directory, "workspaces"))) });
app.use(router); app.use(router);
const server = app.listen(0, "127.0.0.1"); const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve)); await new Promise<void>((resolve) => server.once("listening", resolve));
+1 -1
View File
@@ -115,7 +115,7 @@ it("uses authenticated /responses for saved answers and rejects bypass and forei
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("Authorization") === "Bearer owner" ? "owner" : "other", project_id: project, network: "fixture" })) as typeof fetch; globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("Authorization") === "Bearer owner" ? "owner" : "other", project_id: project, network: "fixture" })) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces); const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "unused"), workspaces); const states = new SessionUiStateStore(workspaces);
const interactions = new InteractionRequestStore(workspaces); const interactions = new InteractionRequestStore(workspaces);
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" }; const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
await sessions.ensure(context); await sessions.ensure(context);
+30 -3
View File
@@ -5,16 +5,43 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getLocalAgentContext } from "../../src/context/localContext.js"; import { getLocalAgentContext } from "../../src/context/localContext.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js"; import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it("reads a native form once when checking and answering a persisted question", async () => {
const root = await mkdtemp(join(tmpdir(), "question-single-read-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(workspaces);
const context = { ...getLocalAgentContext(), sessionId: "session" };
await sessions.ensure(context);
await states.write(context, { sessionId: context.sessionId, messages: [{ role: "assistant", questions: [{ requestId: "form", status: "pending" }] }] });
const calls: string[] = [];
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => ({ session: { form: {
get: async () => { calls.push("get"); return { fields: [{ key: "answer", type: "text" }], state: { status: "pending" } }; },
reply: async (input: { answer: unknown }) => { calls.push("reply"); expect(input.answer).toEqual({ answer: "A" }); },
} } }),
});
const routes = new Map<string, (req: unknown, res: unknown) => Promise<void>>();
const router = { get() {}, post(path: string, handler: (req: unknown, res: unknown) => Promise<void>) { routes.set(path, handler); } };
registerChatInteractionRoutes(router as unknown as express.Router, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
const req = { params: { request_id: "form" }, body: { session_id: "session", answers: [["A"]] }, header() { return undefined; } };
const response = { code: 200, setHeader() {}, status(code: number) { this.code = code; return this; }, json() { return this; } };
try {
await routes.get("/question/:request_id/reply")!(req, response);
expect(response.code).toBe(202);
expect(calls).toEqual(["get", "reply"]);
} finally { await rm(root, { recursive: true, force: true }); }
});
it("reconciles persisted legacy forms and replies after the active run was lost", async () => { it("reconciles persisted legacy forms and replies after the active run was lost", async () => {
const root = await mkdtemp(join(tmpdir(), "legacy-question-routes-")); const root = await mkdtemp(join(tmpdir(), "legacy-question-routes-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces); const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "unused"), workspaces); const states = new SessionUiStateStore(workspaces);
const context = { ...getLocalAgentContext(), sessionId: "session" }; const context = { ...getLocalAgentContext(), sessionId: "session" };
await sessions.ensure(context); await sessions.ensure(context);
await states.write(context, { sessionId: "session", messages: [{ id: "old", role: "assistant", questions: [{ requestId: "form", status: "pending", tool: { callID: "call" } }] }, { id: "new", role: "assistant", text: "later message", parts: [{ type: "data-question_request", data: { request_id: "form2", session_id: "session", questions: [], tool: { callID: "call2" } } }, { type: "data-question_request", data: { request_id: "form3", session_id: "session", questions: [] } }, { type: "data-question_request", data: { request_id: "form4", session_id: "session", questions: [] } }] }] }); await states.write(context, { sessionId: "session", messages: [{ id: "old", role: "assistant", questions: [{ requestId: "form", status: "pending", tool: { callID: "call" } }] }, { id: "new", role: "assistant", text: "later message", parts: [{ type: "data-question_request", data: { request_id: "form2", session_id: "session", questions: [], tool: { callID: "call2" } } }, { type: "data-question_request", data: { request_id: "form3", session_id: "session", questions: [] } }, { type: "data-question_request", data: { request_id: "form4", session_id: "session", questions: [] } }] }] });
@@ -22,7 +49,7 @@ it("reconciles persisted legacy forms and replies after the active run was lost"
let unavailable = false; let unavailable = false;
let reads = 0; let reads = 0;
let replies = 0; let replies = 0;
const runtime = { getQuestionStatus: async () => { reads++; if (unavailable) throw new Error("private-native-path"); return status; }, replyQuestion: async () => { replies++; status = "answered"; }, rejectQuestion: async () => { status = "cancelled"; } } as unknown as OpencodeRuntimeAdapter; const runtime = { getQuestion: async () => { reads++; if (unavailable) throw new Error("private-native-path"); return status === "missing" ? null : { state: { status } }; }, replyQuestion: async () => { replies++; status = "answered"; }, rejectQuestion: async () => { status = "cancelled"; } } as unknown as OpencodeRuntimeAdapter;
const app = express(); app.use(express.json()); const app = express(); app.use(express.json());
registerChatInteractionRoutes(app, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states }); registerChatInteractionRoutes(app, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
const server = app.listen(0, "127.0.0.1"); const server = app.listen(0, "127.0.0.1");
@@ -0,0 +1,58 @@
import { createChatRunAccumulator } from "../../src/routes/chat-run-accumulator.js";
import { expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLocalAgentContext } from "../../src/context/localContext.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it.each([false, true])("persists permission replies in the owner's workspace (native failure=%s)", async (nativeFailure) => {
const root = await mkdtemp(join(tmpdir(), "permission-persistence-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(workspaces);
const context = { ...getLocalAgentContext(), sessionId: "permission-session" };
await sessions.ensure(context);
const messages = [{ id: "answer", role: "assistant", permissions: [] }];
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages });
let calls = 0;
const runtime = { replyPermission: async () => { calls++; if (nativeFailure) throw new Error("native unavailable"); } } as unknown as OpencodeRuntimeAdapter;
const run: ActiveRun = {
clientSessionId: context.sessionId, controller: new AbortController(), messages,
pendingPermissions: new Map([["permission", { request_id: "permission", session_id: context.sessionId, permission: "shell", patterns: [], always: [], created_at: Date.now() }]]),
pendingQuestions: new Map(), subscribers: new Set(), status: "running",
};
const accumulator = createChatRunAccumulator({ run, messages: [], messageId: "answer", sessionId: context.sessionId,
collaboration: false, write() {}, onStatus() {} });
accumulator.apply("permission_request", run.pendingPermissions.get("permission")! as unknown as Record<string, unknown>);
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: run.messages });
const app = express(); app.use(express.json());
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/permission/permission/reply`;
const reply = (headers = {}) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ session_id: context.sessionId, reply: "once" }) });
try {
expect((await reply({ "X-Agent-Local-User": "other" })).status).toBe(404);
expect(calls).toBe(0);
expect((await reply()).status).toBe(nativeFailure ? 502 : 202);
expect(calls).toBe(1);
expect(run.pendingPermissions.has("permission")).toBe(nativeFailure);
const saved = await states.read(context);
expect(saved?.isTitleManuallyEdited).toBe(true);
const parts = (saved?.messages[0] as { parts: Array<{ type: string; data: Record<string, unknown> }> }).parts;
expect(parts.find((part) => part.type === "data-permission_response")?.data).toMatchObject(nativeFailure
? { request_id: "permission", status: "error", error: "native unavailable" }
: { request_id: "permission", reply: "once" });
expect(run.messages[0]).toMatchObject({ permissions: [{ requestId: "permission", status: nativeFailure ? "error" : "approved_once" }] });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UIMessage } from "ai";
import { getLocalAgentContext } from "../../src/context/localContext.js";
import { createChatRunAccumulator } from "../../src/routes/chat-run-accumulator.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import { createInitialStreamingMessages, type ActiveRun } from "../../src/routes/chatUiState.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it("keeps historical question invalidation across the next live snapshot", async () => {
const root = await mkdtemp(join(tmpdir(), "question-persistence-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(workspaces);
const context = { ...getLocalAgentContext(), sessionId: "history-session" };
await sessions.ensure(context);
const history: UIMessage[] = [{ id: "old-answer", role: "assistant", parts: [
{ type: "data-question_request", data: { request_id: "old-question", session_id: context.sessionId,
questions: [{ question: "继续吗?", options: [] }] } },
{ type: "data-final_answer", data: { content: "此前的结果" } },
] }, { id: "user", role: "user", parts: [{ type: "text", text: "新一轮" }] }];
const run: ActiveRun = {
clientSessionId: context.sessionId, controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages(history, "新一轮"),
pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const stream = createChatRunAccumulator({ run, messages: history, messageId: "current-answer",
sessionId: context.sessionId, collaboration: false, write() {}, onStatus() {} });
stream.apply("token", { content: "正在核查" });
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: stream.uiMessages() });
let nativeReads = 0;
const runtime = { getQuestion: async () => { nativeReads++; return null; } } as unknown as OpencodeRuntimeAdapter;
const app = express();
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime,
sessionMetadataStore: sessions, sessionUiStateStore: states });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/${context.sessionId}/questions/old-question`;
const resolved = (messages: unknown[]) => {
const message = messages[0] as UIMessage;
expect(message.id).toBe("old-answer");
const part = message.parts.find((part) => part.type === "data-question_response");
expect(part).toMatchObject({ type: "data-question_response", data: { request_id: "old-question", status: "invalidated" } });
};
try {
expect((await fetch(url, { headers: { "X-Agent-Local-User": "other" } })).status).toBe(404);
expect(nativeReads).toBe(0);
const response = await fetch(url);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ status: "invalidated" });
resolved((await states.read(context))!.messages);
resolved(run.messages);
resolved(stream.uiMessages());
expect(stream.uiMessages().at(-1)?.parts.some((part) => part.type === "data-question_response")).toBe(false);
stream.apply("token", { content: ",继续查询" });
await states.write(context, { ...(await states.read(context))!, messages: stream.uiMessages() });
resolved((await states.read(context))!.messages);
expect((await states.read(context))?.isTitleManuallyEdited).toBe(true);
await fetch(url);
expect(nativeReads).toBe(1);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+31 -1
View File
@@ -44,7 +44,7 @@ beforeEach(async () => {
})) as typeof fetch; })) as typeof fetch;
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces); const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
states = new SessionUiStateStore(join(root, "ui"), workspaces); states = new SessionUiStateStore(workspaces);
interactions = new InteractionRequestStore(workspaces); interactions = new InteractionRequestStore(workspaces);
await sessions.initialize(); await sessions.initialize();
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" }; const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
@@ -296,6 +296,11 @@ it("marks an explicitly aborted response incomplete and preserves scoped history
const text = await response.text(); const text = await response.text();
expect(text).toContain("event: response.incomplete"); expect(text).toContain("event: response.incomplete");
expect(text).not.toContain("event: response.completed"); expect(text).not.toContain("event: response.completed");
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const saved = (await states.read(context))!.messages as Array<{ parts: Array<{ type: string; data?: { status?: string } }> }>;
expect(saved.at(-1)?.parts.find((part) => part.type === "data-run_status")?.data?.status).toBe("aborted");
const loaded = await http(`${base}/api/v1/session/owned`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
expect((await loaded.json()).messages).toEqual(saved);
}); });
it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => { it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => {
@@ -316,3 +321,28 @@ it("continues a paged conversation from the complete saved history and preserves
expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409); expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409);
expect(prompted).toHaveLength(1); expect(prompted).toHaveLength(1);
}); });
it("uses the same canonical live messages for session reads and older pages", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const history = Array.from({ length: 5 }, (_, index) => ({ id: `old-${index}`, role: index % 2 ? "assistant" : "user",
parts: [{ type: "text", text: `历史 ${index}` }] as unknown[] }));
history[1].parts.push({ type: "data-question_request", data: { request_id: "old-question", session_id: "owned", questions: [] } });
await states.write(context, { sessionId: "owned", messages: history });
const response = await call({ input: "新一轮", conversation: "owned", stream: true, tjwater: { client_message_id: "live-user" } });
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
const live = await http(`${base}/api/v1/session/owned`, { headers });
const snapshot = await live.json();
expect(snapshot.is_streaming).toBe(true);
expect(snapshot.messages).toHaveLength(7);
expect(snapshot.messages.slice(0, 5)).toEqual(history);
expect(snapshot.messages[5].id).toBe("live-user");
const recent = await http(`${base}/api/v1/session/owned?message_limit=2`, { headers });
const page = await recent.json();
expect(page.messages).toEqual(snapshot.messages.slice(-2));
const older = await http(`${base}/api/v1/session/owned/messages?limit=40&before=${encodeURIComponent(page.message_page.before_cursor)}`, { headers });
expect(older.status).toBe(200);
expect((await older.json()).messages).toEqual(history);
release();
await response.text();
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { astToString, COMMENT_HEADER, default as openapiTS } from "openapi-typescript";
it.each(["valid", "invalid-schema", "invalid-digest"])("synchronizes the complete artifact group or preserves it (%s)", async (scenario) => {
const root = await mkdtemp(join(tmpdir(), "server-contract-sync-"));
try {
for (const directory of ["scripts", "openapi", "src/generated", "upstream"]) await mkdir(join(root, directory), { recursive: true });
await copyFile(new URL("../../scripts/server-contract.ts", import.meta.url), join(root, "scripts/server-contract.ts"));
await symlink(new URL("../../node_modules", import.meta.url).pathname, join(root, "node_modules"), "dir");
const targets = ["openapi/openapi.json", "openapi/server-manifest.json", "src/generated/server-api.d.ts"];
for (const target of targets) await writeFile(join(root, target), `original:${target}`);
const schema = { openapi: scenario === "invalid-schema" ? "9.9.9" : "3.1.0", info: { title: "Fixture", version: "1" }, paths: {} };
const raw = JSON.stringify(schema);
const manifest = { contracts: { server: { sha256: scenario === "invalid-digest" ? "incorrect" : createHash("sha256").update(raw).digest("hex") } } };
await writeFile(join(root, "upstream/schema.json"), raw);
await writeFile(join(root, "upstream/manifest.json"), JSON.stringify(manifest));
const child = Bun.spawn([process.execPath, "scripts/server-contract.ts", "--sync", "--upstream", "upstream/schema.json"], {
cwd: root, stdout: "pipe", stderr: "pipe",
});
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
if (scenario === "valid") {
expect(code).toBe(0);
expect(await readFile(join(root, targets[0]), "utf8")).toBe(raw);
expect(JSON.parse(await readFile(join(root, targets[1]), "utf8"))).toEqual(manifest);
expect(await readFile(join(root, targets[2]), "utf8")).toBe(COMMENT_HEADER + astToString(await openapiTS(schema)));
} else {
expect(code).not.toBe(0);
expect(stderr).toContain(scenario === "invalid-schema" ? "Unsupported OpenAPI version" : "contract digest differs");
for (const target of targets) expect(await readFile(join(root, target), "utf8")).toBe(`original:${target}`);
}
expect((await readdir(join(root, "openapi"))).sort()).toEqual(["openapi.json", "server-manifest.json"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+1 -1
View File
@@ -47,7 +47,7 @@ describe("chat session HTTP isolation", () => {
const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces")); const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces"));
const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces); const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces);
const sessionUiStateStore = new SessionUiStateStore(join(metadataDir, "old-ui"), workspaces); const sessionUiStateStore = new SessionUiStateStore(workspaces);
await sessionMetadataStore.initialize(); await sessionMetadataStore.initialize();
const ownerContext = { const ownerContext = {
actorKey: toActorKey("product:user-a"), actorKey: toActorKey("product:user-a"),
@@ -33,7 +33,7 @@ async function fixture() {
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" }; const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" };
const workspaces = new SessionWorkspaceStore(join(root, "workspaces")); const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces); const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "states"), workspaces); const states = new SessionUiStateStore(workspaces);
const taskNames = new Map([[firstTask, "南桥管道爆管调查"], [secondTask, "城南供水压力复核"]]); const taskNames = new Map([[firstTask, "南桥管道爆管调查"], [secondTask, "城南供水压力复核"]]);
const taskReads: string[] = []; const taskReads: string[] = [];
let available = true; let available = true;
+45
View File
@@ -0,0 +1,45 @@
import { expect, it } from "bun:test";
import express from "express";
import type { OperationalTaskClient } from "../../src/product/operational-task-client.js";
import { registerTaskCommandBridge } from "../../src/routes/task-command-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
it("preserves command-specific session purposes and trusted bridge authentication", async () => {
const calls: unknown[][] = [];
const client = {
requestDataAccess: async (...args: unknown[]) => { calls.push(args); return {}; },
proposeAction: async (...args: unknown[]) => { calls.push(args); throw Object.assign(new Error("conflict"), { status: 409 }); },
addHumanEvidence: async (...args: unknown[]) => { calls.push(args); return {}; },
} as unknown as Pick<OperationalTaskClient, "requestDataAccess" | "proposeAction" | "addHumanEvidence">;
for (const purpose of ["task_execution", "task_collaboration"] as const) setRuntimeSessionContext({
sessionId: purpose, clientSessionId: `client-${purpose}`, taskId: "task", purpose,
actorKey: "actor", projectKey: "project", traceId: "test",
});
const app = express(); app.use(express.json());
registerTaskCommandBridge(app, { internalToken: "test-token", client });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const post = (kind: string, session: string, data: object, headers = {}) => fetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/internal/operational-task/${kind}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "test-token", ...headers }, body: JSON.stringify({ session_id: session, ...data }),
});
const requestId = "60000000-0000-4000-8000-000000000001";
const access = { request: { requestId, nodeId: "read", scopes: ["scada_devices"], reason: "查询" } };
const evidence = { command: { request_id: requestId, evidence: { evidence_id: "e1", kind: "human_input", summary: "已复核", source_label: "用户", observed_at: "2026-11-09T00:00:00Z" } } };
try {
expect((await post("access-requests", "task_execution", access, { Origin: "http://browser.test" })).status).toBe(403);
expect((await post("access-requests", "task_execution", access, { "x-agent-internal-token": "wrong" })).status).toBe(403);
expect((await post("access-requests", "task_collaboration", access)).status).toBe(403);
expect((await post("human-evidence", "task_execution", evidence)).status).toBe(403);
expect(calls).toHaveLength(0);
expect((await post("access-requests", "task_execution", access)).status).toBe(200);
expect(calls[0]).toEqual(["task_execution", "task", { ...access.request, sourceSessionId: "client-task_execution" }]);
expect((await post("human-evidence", "task_collaboration", evidence)).status).toBe(200);
const action = { action: { request_id: requestId, reason: "核验", kind: "create_work_order", payload: { title: "采样", description: "核验", work_order_type: "general" } } };
expect((await post("actions", "task_execution", action)).status).toBe(409);
expect((await post("actions", "task_collaboration", action)).status).toBe(409);
expect((await post("actions", "unknown", action)).status).toBe(403);
} finally {
removeRuntimeSessionContext("task_execution"); removeRuntimeSessionContext("task_collaboration");
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+3 -3
View File
@@ -33,11 +33,11 @@ test("native question lookup distinguishes missing form from unavailable service
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }), ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }),
}) as OpencodeRuntimeAdapter; }) as OpencodeRuntimeAdapter;
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("missing"); expect(await adapter.getQuestion({ sessionId: "s", requestId: "form" })).toBeNull();
failure = new Error("network unavailable"); failure = new Error("network unavailable");
await expect(adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable"); await expect(adapter.getQuestion({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable");
failure = undefined; failure = undefined;
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("answered"); expect(await adapter.getQuestion({ sessionId: "s", requestId: "form" })).toMatchObject({ state: { status: "answered" } });
}); });
test("message admission lookup fails closed on inbox and message transport errors", async () => { test("message admission lookup fails closed on inbox and message transport errors", async () => {
let inboxFailure = true; let inboxFailure = true;
+1 -1
View File
@@ -26,7 +26,7 @@ describe("answer view state", () => {
beforeEach(async () => { beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), "answer-state-")); base = await mkdtemp(join(tmpdir(), "answer-state-"));
workspaces = new SessionWorkspaceStore(join(base, "workspaces")); workspaces = new SessionWorkspaceStore(join(base, "workspaces"));
messages = new SessionUiStateStore(join(base, "unused"), workspaces); messages = new SessionUiStateStore(workspaces);
states = new AnswerViewStateStore(workspaces); states = new AnswerViewStateStore(workspaces);
currentSpec = spec(); currentSpec = spec();
await saveMessages(); await saveMessages();
+1 -1
View File
@@ -44,7 +44,7 @@ describe("learning session lifecycle", () => {
}); });
const orchestrator = (runtime: object) => new LearningOrchestrator( const orchestrator = (runtime: object) => new LearningOrchestrator(
runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")), runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")),
new SessionTranscriptStore(join(base, "legacy-transcripts"), records), state, new SessionTranscriptStore(records), state,
); );
it("releases both native helper sessions after a completed review and persists progress in chat", async () => { it("releases both native helper sessions after a completed review and persists progress in chat", async () => {
+2 -2
View File
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js"; import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
describe("SessionTranscriptStore", () => { describe("SessionTranscriptStore", () => {
@@ -11,8 +12,7 @@ describe("SessionTranscriptStore", () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-transcript-")); tempDir = await mkdtemp(join(tmpdir(), "tjwater-transcript-"));
store = new SessionTranscriptStore(tempDir); store = new SessionTranscriptStore(new SessionWorkspaceStore(tempDir));
await store.initialize();
}); });
afterEach(async () => { afterEach(async () => {
+5 -5
View File
@@ -2,18 +2,18 @@ import { expect, it } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises"; import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
it("restores plan history independently of chat messages after reconnecting", async () => { it("restores plan history independently of chat messages after reconnecting", async () => {
const directory = await mkdtemp(join(tmpdir(), "tjwater-plan-state-")); const directory = await mkdtemp(join(tmpdir(), "tjwater-plan-state-"));
try { try {
const store = new SessionUiStateStore(directory); const store = new SessionUiStateStore(new SessionWorkspaceStore(directory));
await store.initialize();
const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }]; const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }];
await store.write({ sessionId: "session-1" }, { sessionId: "session-1", messages: [], executionPlans }); await store.write({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" }, { sessionId: "session-1", messages: [], executionPlans });
const reloaded = await new SessionUiStateStore(directory).read({ sessionId: "session-1" }); const reloaded = await new SessionUiStateStore(new SessionWorkspaceStore(directory)).read({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" });
expect(reloaded?.executionPlans).toEqual(executionPlans); expect(reloaded?.executionPlans).toEqual(executionPlans);
expect(await store.read({ sessionId: "other-session" })).toBeNull(); expect(await store.read({ sessionId: "other-session", actorKey: "actor-test", projectKey: "project-test" })).toBeNull();
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
+11 -11
View File
@@ -20,11 +20,11 @@ describe("owned session workspaces", () => {
afterEach(async () => { await rm(base, { recursive: true, force: true }); }); afterEach(async () => { await rm(base, { recursive: true, force: true }); });
it("keeps messages and searchable transcripts private to their user and scope", async () => { it("keeps messages and searchable transcripts private to their user and scope", async () => {
const ui = new SessionUiStateStore(join(base, "old-ui"), records); const ui = new SessionUiStateStore(records);
const transcript = new SessionTranscriptStore(join(base, "old-transcripts"), records); const transcript = new SessionTranscriptStore(records);
await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] }); await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] });
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 }); await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
expect((await new SessionUiStateStore(join(base, "old-ui"), records).read(owner))?.messages).toHaveLength(1); expect((await new SessionUiStateStore(records).read(owner))?.messages).toHaveLength(1);
expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull(); expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull();
expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull(); expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull();
expect(await transcript.search(owner, "private")).toHaveLength(2); expect(await transcript.search(owner, "private")).toHaveLength(2);
@@ -36,7 +36,7 @@ describe("owned session workspaces", () => {
}); });
it("has a separate personal namespace for each user and separates it from projects", async () => { it("has a separate personal namespace for each user and separates it from projects", async () => {
const ui = new SessionUiStateStore(join(base, "old-ui"), records); const ui = new SessionUiStateStore(records);
const personal = { ...owner, projectKey: "personal-actor-a" }; const personal = { ...owner, projectKey: "personal-actor-a" };
await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] }); await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] });
expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/"); expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/");
@@ -60,13 +60,13 @@ describe("owned session workspaces", () => {
expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 }); expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 });
expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages); expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages);
expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns); expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns);
await new SessionUiStateStore(uiStateDir, records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] }); await new SessionUiStateStore(records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] });
expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0); expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0);
const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat"); const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat");
const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!; const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!;
expect(await readFile(join(archive, backup), "utf8")).toBe(messages); expect(await readFile(join(archive, backup), "utf8")).toBe(messages);
expect(await readdir(uiStateDir)).toEqual(["orphan.json"]); expect(await readdir(uiStateDir)).toEqual(["orphan.json"]);
expect((await new SessionUiStateStore(uiStateDir, records).read(owner))?.messages).toEqual([{ text: "new answer" }]); expect((await new SessionUiStateStore(records).read(owner))?.messages).toEqual([{ text: "new answer" }]);
}); });
it("rejects traversal, linked records and mismatched ownership", async () => { it("rejects traversal, linked records and mismatched ownership", async () => {
@@ -75,7 +75,7 @@ describe("owned session workspaces", () => {
const foreign = join(base, "foreign.json"); const foreign = join(base, "foreign.json");
await writeFile(foreign, "private"); await writeFile(foreign, "private");
await symlink(foreign, records.path(owner, "messages.json")); await symlink(foreign, records.path(owner, "messages.json"));
await expect(new SessionUiStateStore(join(base, "old-ui"), records).read(owner)).rejects.toThrow("symbolic link"); await expect(new SessionUiStateStore(records).read(owner)).rejects.toThrow("symbolic link");
await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" })); await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" }));
await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch"); await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch");
}); });
@@ -114,8 +114,8 @@ describe("owned session workspaces", () => {
for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path); for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path);
const metadata = new SessionMetadataStore(metadataDir, records); const metadata = new SessionMetadataStore(metadataDir, records);
const { record } = await metadata.ensure(owner); const { record } = await metadata.ensure(owner);
const ui = new SessionUiStateStore(uiStateDir, records); const ui = new SessionUiStateStore(records);
const transcript = new SessionTranscriptStore(transcriptDir, records); const transcript = new SessionTranscriptStore(records);
const learning = new SessionLearningStateStore(learningStateDir, records); const learning = new SessionLearningStateStore(learningStateDir, records);
const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] }); const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] });
await writeFile(join(uiStateDir, "chat.json"), legacyUi); await writeFile(join(uiStateDir, "chat.json"), legacyUi);
@@ -153,7 +153,7 @@ describe("owned session workspaces", () => {
const metadataDir = join(base, "metadata"); const metadataDir = join(base, "metadata");
const metadata = new SessionMetadataStore(metadataDir, records); const metadata = new SessionMetadataStore(metadataDir, records);
const { record } = await metadata.ensure(owner); const { record } = await metadata.ensure(owner);
const transcript = new SessionTranscriptStore(join(base, "legacy"), records); const transcript = new SessionTranscriptStore(records);
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 }); await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
const markDeleted = records.markDeleted.bind(records); const markDeleted = records.markDeleted.bind(records);
records.markDeleted = async () => { throw new Error("disk write interrupted"); }; records.markDeleted = async () => { throw new Error("disk write interrupted"); };
@@ -163,7 +163,7 @@ describe("owned session workspaces", () => {
await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found"); await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
records = new SessionWorkspaceStore(records.root, records.backupRoot); records = new SessionWorkspaceStore(records.root, records.backupRoot);
const restarted = new SessionMetadataStore(metadataDir, records); const restarted = new SessionMetadataStore(metadataDir, records);
expect(await new SessionTranscriptStore(join(base, "legacy"), records).search(owner, "private")).toEqual([]); expect(await new SessionTranscriptStore(records).search(owner, "private")).toEqual([]);
await expect(restarted.ensure(owner)).rejects.toThrow("session not found"); await expect(restarted.ensure(owner)).rejects.toThrow("session not found");
await markDeleted(owner); await markDeleted(owner);
await restarted.remove(record); await restarted.remove(record);