fix(agent): stabilize sandboxed streaming workflows
This commit is contained in:
@@ -10,6 +10,8 @@ model: deepseek/deepseek-v4-flash
|
|||||||
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
|
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
|
||||||
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
|
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
|
||||||
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划
|
- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划
|
||||||
|
- `todowrite` 是面向用户的业务任务摘要:每项只描述目标或可验证结果,不出现函数名、脚本/文件名、命令、工具名、参数、内部目录或具体修复实现;这些技术细节仅保留在工具过程信息中
|
||||||
|
- 任务标题使用简洁的业务语言,例如“准备供水分区所需数据”“计算供水服务范围”“生成并展示分析结果”“整理可复用分析经验”
|
||||||
|
|
||||||
## 工作流生命周期
|
## 工作流生命周期
|
||||||
|
|
||||||
|
|||||||
@@ -148,3 +148,6 @@ if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模
|
|||||||
- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。
|
- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。
|
||||||
- **setting 字段不可用**:`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。
|
- **setting 字段不可用**:`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。
|
||||||
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
|
- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。
|
||||||
|
|
||||||
|
## Learned Patterns
|
||||||
|
- [5ba58cd24c9cea84b6ab5861] **数据 schema 实测适配(2026-04 验证)**:`data timeseries realtime links` 返回记录的管道主键为 `link_id`(不是 `id`),`data timeseries realtime nodes` 返回记录的节点主键为 `node_id`(不是 `id`),且 `time` 字段为 UTC 格式(如 `2026-04-01T00:00:00+00:00`)。运行 `bottleneck_analysis.py` 前需:① 脚本内将 `r['id']` 改为 `r['link_id']`、`n['id']` 改为 `n['node_id']`;② `--target-tim...
|
||||||
|
|||||||
@@ -144,3 +144,6 @@ show_chart(title="各水源分区节点数/压力对比", chart_type="bar", ...)
|
|||||||
- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果
|
- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果
|
||||||
- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化
|
- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化
|
||||||
- **零流量阈值**:`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
|
- **零流量阈值**:`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
|
||||||
|
|
||||||
|
## Learned Patterns
|
||||||
|
- [6614a914a8f7dcc2fc34c1ba] 实时数据时间匹配必须用 norm_time 归一化为 UTC 再比较(数据 time 为 UTC 格式如 2026-06-03T00:00:00+00:00,target-time 传 +08:00 会因字符串不等而筛出 0 条);links/nodes 记录主键字段为 link_id/node_id(不是 id)。执行分区前先确认目标时刻存在实时数据,可用 `data timeseries realtime simulation-by-id-time` 探测;数据可能只覆盖某几天(本模型覆盖 2026-06-03 附近,2026-04 与 2026-08 均无数据)。
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ COLORS = [
|
|||||||
"rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)"
|
"rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def norm_time(t):
|
||||||
|
"""把 ISO8601 字符串归一化为 UTC 的 ISO 字符串,用于跨时区比较"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.fromisoformat(t.replace("Z", "+00:00")).astimezone(
|
||||||
|
__import__("datetime").timezone.utc).isoformat()
|
||||||
|
|
||||||
def load_json(path, label):
|
def load_json(path, label):
|
||||||
print(f"Loading {label}...", file=sys.stderr)
|
print(f"Loading {label}...", file=sys.stderr)
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
@@ -63,11 +70,12 @@ def main():
|
|||||||
|
|
||||||
# --- Step 3: Load link flow at target time ---
|
# --- Step 3: Load link flow at target time ---
|
||||||
ldata = load_json(args.links, "link flows")["data"]
|
ldata = load_json(args.links, "link flows")["data"]
|
||||||
target_links = [l for l in ldata if l["time"] == args.target_time]
|
target_ts = norm_time(args.target_time)
|
||||||
|
target_links = [l for l in ldata if norm_time(l["time"]) == target_ts]
|
||||||
flow_direction = {}
|
flow_direction = {}
|
||||||
pipe_flow = {}
|
pipe_flow = {}
|
||||||
for l in target_links:
|
for l in target_links:
|
||||||
lid = l["id"]
|
lid = l.get("link_id") or l.get("id")
|
||||||
flow_val = l["flow"]
|
flow_val = l["flow"]
|
||||||
pipe_flow[lid] = abs(flow_val)
|
pipe_flow[lid] = abs(flow_val)
|
||||||
if lid in pipe_topology:
|
if lid in pipe_topology:
|
||||||
@@ -81,11 +89,11 @@ def main():
|
|||||||
|
|
||||||
# --- Step 4: Load node data at target time ---
|
# --- Step 4: Load node data at target time ---
|
||||||
ndata = load_json(args.nodes, "node data")["data"]
|
ndata = load_json(args.nodes, "node data")["data"]
|
||||||
target_nodes = [n for n in ndata if n["time"] == args.target_time]
|
target_nodes = [n for n in ndata if norm_time(n["time"]) == target_ts]
|
||||||
node_pressure = {}
|
node_pressure = {}
|
||||||
node_demand = {}
|
node_demand = {}
|
||||||
for n in target_nodes:
|
for n in target_nodes:
|
||||||
nid = n["id"]
|
nid = n.get("node_id") or n.get("id")
|
||||||
node_pressure[nid] = n.get("pressure", 0)
|
node_pressure[nid] = n.get("pressure", 0)
|
||||||
node_demand[nid] = n.get("actual_demand", 0)
|
node_demand[nid] = n.get("actual_demand", 0)
|
||||||
print(f" {len(target_nodes)} nodes", file=sys.stderr)
|
print(f" {len(target_nodes)} nodes", file=sys.stderr)
|
||||||
|
|||||||
+29
@@ -48,6 +48,31 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
|
|||||||
(getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \
|
(getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
FROM base AS opencode-builder
|
||||||
|
|
||||||
|
ARG OPENCODE_SOURCE_COMMIT=a105350812f05f914c768e468559dbd6bd508d8e
|
||||||
|
ARG OPENCODE_PATCH_VERSION=1.18.13-tjwater.1
|
||||||
|
WORKDIR /tmp/opencode-src
|
||||||
|
|
||||||
|
RUN git init . && \
|
||||||
|
git remote add origin https://github.com/anomalyco/opencode.git && \
|
||||||
|
git fetch --depth 1 origin "$OPENCODE_SOURCE_COMMIT" && \
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
COPY patches/opencode-1.18.13-message-phase.patch /tmp/opencode-message-phase.patch
|
||||||
|
|
||||||
|
RUN git apply --check /tmp/opencode-message-phase.patch && \
|
||||||
|
git apply /tmp/opencode-message-phase.patch && \
|
||||||
|
bun install --frozen-lockfile --ignore-scripts && \
|
||||||
|
bun test --cwd packages/llm test/provider/openai-responses.test.ts && \
|
||||||
|
OPENCODE_VERSION="$OPENCODE_PATCH_VERSION" bun run --cwd packages/opencode build --single --skip-install --skip-embed-web-ui && \
|
||||||
|
case "$(uname -m)" in \
|
||||||
|
x86_64) binary=packages/opencode/dist/opencode-linux-x64/bin/opencode ;; \
|
||||||
|
aarch64|arm64) binary=packages/opencode/dist/opencode-linux-arm64/bin/opencode ;; \
|
||||||
|
*) echo "unsupported OpenCode build architecture: $(uname -m)" >&2; exit 1 ;; \
|
||||||
|
esac && \
|
||||||
|
install -D -m 0755 "$binary" /out/opencode
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -70,6 +95,7 @@ COPY .opencode ./.opencode
|
|||||||
RUN bun run check
|
RUN bun run check
|
||||||
|
|
||||||
FROM build AS test
|
FROM build AS test
|
||||||
|
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
COPY contracts ./contracts
|
COPY contracts ./contracts
|
||||||
@@ -81,6 +107,9 @@ RUN bun run test:ci
|
|||||||
FROM build AS runner
|
FROM build AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
|
||||||
|
RUN opencode --version
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=8787
|
ENV PORT=8787
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000
|
|||||||
|
|
||||||
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
|
当前仅支持 Embedded 模式,不支持连接外部 OpenCode server。
|
||||||
|
|
||||||
|
生产镜像会从固定的 OpenCode `v1.18.13` 源码提交构建 CLI,并应用仓库内的
|
||||||
|
`patches/opencode-1.18.13-message-phase.patch`。该补丁只透传 OpenAI Responses
|
||||||
|
输出项已有的 `commentary` / `final_answer` phase,不改变模型行为:过程文本继续写入
|
||||||
|
可折叠的 Agent 过程卡,`final_answer` 到达后立即按增量写入正式回答。未提供 phase 的
|
||||||
|
DeepSeek 模型启用 OpenCode 1.18.13 内置的 JSON Schema 最终回答工具
|
||||||
|
`StructuredOutput`:模型必须先完成全部分析和工具调用,再把完整回答写入 `answer`;
|
||||||
|
该工具成功后 OpenCode 会直接结束运行循环,不再进入下一轮模型或工具调用。Agent 将
|
||||||
|
`answer` 映射为正式文本推送;若模型未按协议调用该工具,仍保留会话 idle 后提取最终
|
||||||
|
文本的兼容兜底。
|
||||||
|
本地直接运行 `bun --watch src/server.ts` 时,`PATH` 中也需要放置应用了同一补丁的
|
||||||
|
`opencode` CLI,才能启用 phase 驱动的正式文本流式输出。
|
||||||
|
|
||||||
## 认证续期与学习工具
|
## 认证续期与学习工具
|
||||||
|
|
||||||
后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
|
后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
diff --git a/package.json b/package.json
|
||||||
|
index 15725c8..4378766 100644
|
||||||
|
--- a/package.json
|
||||||
|
+++ b/package.json
|
||||||
|
@@ -4,7 +4,7 @@
|
||||||
|
"description": "AI-powered development tool",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
- "packageManager": "bun@1.3.14",
|
||||||
|
+ "packageManager": "bun@1.3.13",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
|
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||||
|
diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts
|
||||||
|
index 4936d31..53138af 100644
|
||||||
|
--- a/packages/llm/src/protocols/openai-responses.ts
|
||||||
|
+++ b/packages/llm/src/protocols/openai-responses.ts
|
||||||
|
@@ -177,6 +177,7 @@ type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
|
||||||
|
const OpenAIResponsesStreamItem = Schema.Struct({
|
||||||
|
type: Schema.String,
|
||||||
|
id: Schema.optional(Schema.String),
|
||||||
|
+ phase: optionalNull(Schema.String),
|
||||||
|
call_id: Schema.optional(Schema.String),
|
||||||
|
name: Schema.optional(Schema.String),
|
||||||
|
arguments: Schema.optional(Schema.String),
|
||||||
|
@@ -238,6 +239,7 @@ interface ParserState {
|
||||||
|
readonly hasFunctionCall: boolean
|
||||||
|
readonly lifecycle: Lifecycle.State
|
||||||
|
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||||
|
+ readonly textMetadata: Readonly<Record<string, ProviderMetadata>>
|
||||||
|
readonly store: boolean | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
@@ -614,9 +616,19 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
||||||
|
|
||||||
|
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
|
if (!event.delta) return [state, NO_EVENTS]
|
||||||
|
+ const itemID = event.item_id ?? "text-0"
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
return [
|
||||||
|
- { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||||
|
+ {
|
||||||
|
+ ...state,
|
||||||
|
+ lifecycle: Lifecycle.textDelta(
|
||||||
|
+ state.lifecycle,
|
||||||
|
+ events,
|
||||||
|
+ itemID,
|
||||||
|
+ event.delta,
|
||||||
|
+ state.textMetadata[itemID],
|
||||||
|
+ ),
|
||||||
|
+ },
|
||||||
|
events,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
@@ -655,6 +667,22 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||||
|
// best-effort, not guaranteed.
|
||||||
|
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
|
const item = event.item
|
||||||
|
+ if (item?.type === "message" && item.id) {
|
||||||
|
+ const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined
|
||||||
|
+ return [
|
||||||
|
+ {
|
||||||
|
+ ...state,
|
||||||
|
+ textMetadata: {
|
||||||
|
+ ...state.textMetadata,
|
||||||
|
+ [item.id]: openaiMetadata({
|
||||||
|
+ itemId: item.id,
|
||||||
|
+ ...(phase ? { phase } : {}),
|
||||||
|
+ }),
|
||||||
|
+ },
|
||||||
|
+ },
|
||||||
|
+ NO_EVENTS,
|
||||||
|
+ ]
|
||||||
|
+ }
|
||||||
|
if (item && isReasoningItem(item)) {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
return [
|
||||||
|
@@ -812,6 +840,20 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
|
const item = event.item
|
||||||
|
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
|
||||||
|
+ if (item.type === "message" && item.id) {
|
||||||
|
+ const events: LLMEvent[] = []
|
||||||
|
+ const lifecycle = Lifecycle.textEnd(state.lifecycle, events, item.id, state.textMetadata[item.id])
|
||||||
|
+ const { [item.id]: _removed, ...textMetadata } = state.textMetadata
|
||||||
|
+ return [
|
||||||
|
+ {
|
||||||
|
+ ...state,
|
||||||
|
+ lifecycle,
|
||||||
|
+ textMetadata,
|
||||||
|
+ },
|
||||||
|
+ events,
|
||||||
|
+ ] satisfies StepResult
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
if (item.type === "function_call") {
|
||||||
|
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||||
|
const tools = state.tools[item.id]
|
||||||
|
@@ -969,6 +1011,7 @@ export const protocol = Protocol.make({
|
||||||
|
tools: ToolStream.empty<string>(),
|
||||||
|
lifecycle: Lifecycle.initial(),
|
||||||
|
reasoningItems: {},
|
||||||
|
+ textMetadata: {},
|
||||||
|
store: OpenAIOptions.store(request),
|
||||||
|
}),
|
||||||
|
step,
|
||||||
|
diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts
|
||||||
|
index eb6c95d..64248be 100644
|
||||||
|
--- a/packages/llm/src/protocols/utils/lifecycle.ts
|
||||||
|
+++ b/packages/llm/src/protocols/utils/lifecycle.ts
|
||||||
|
@@ -14,13 +14,22 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||||
|
return { ...state, stepStarted: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
-export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||||
|
+export const textDelta = (
|
||||||
|
+ state: State,
|
||||||
|
+ events: LLMEvent[],
|
||||||
|
+ id: string,
|
||||||
|
+ text: string,
|
||||||
|
+ providerMetadata?: ProviderMetadata,
|
||||||
|
+): State => {
|
||||||
|
const stepped = stepStart(state, events)
|
||||||
|
if (stepped.text.has(id)) {
|
||||||
|
- events.push(LLMEvent.textDelta({ id, text }))
|
||||||
|
+ events.push(LLMEvent.textDelta({ id, text, providerMetadata }))
|
||||||
|
return stepped
|
||||||
|
}
|
||||||
|
- events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
||||||
|
+ events.push(
|
||||||
|
+ LLMEvent.textStart({ id, providerMetadata }),
|
||||||
|
+ LLMEvent.textDelta({ id, text, providerMetadata }),
|
||||||
|
+ )
|
||||||
|
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts
|
||||||
|
index cd8bad5..89ae576 100644
|
||||||
|
--- a/packages/llm/test/provider/openai-responses.test.ts
|
||||||
|
+++ b/packages/llm/test/provider/openai-responses.test.ts
|
||||||
|
@@ -754,6 +754,45 @@ describe("OpenAI Responses route", () => {
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
+ it.effect("preserves assistant message phase in text provider metadata", () =>
|
||||||
|
+ Effect.gen(function* () {
|
||||||
|
+ const message = {
|
||||||
|
+ type: "message",
|
||||||
|
+ id: "msg_final",
|
||||||
|
+ phase: "final_answer",
|
||||||
|
+ }
|
||||||
|
+ const body = sseEvents(
|
||||||
|
+ { type: "response.output_item.added", item: message },
|
||||||
|
+ { type: "response.output_text.delta", item_id: "msg_final", delta: "Final" },
|
||||||
|
+ { type: "response.output_item.done", item: message },
|
||||||
|
+ { type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
+ )
|
||||||
|
+
|
||||||
|
+ const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||||
|
+ const metadata = {
|
||||||
|
+ openai: {
|
||||||
|
+ itemId: "msg_final",
|
||||||
|
+ phase: "final_answer",
|
||||||
|
+ },
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ expect(response.text).toBe("Final")
|
||||||
|
+ expect(response.events).toMatchObject([
|
||||||
|
+ { type: "step-start", index: 0 },
|
||||||
|
+ { type: "text-start", id: "msg_final", providerMetadata: metadata },
|
||||||
|
+ {
|
||||||
|
+ type: "text-delta",
|
||||||
|
+ id: "msg_final",
|
||||||
|
+ text: "Final",
|
||||||
|
+ providerMetadata: metadata,
|
||||||
|
+ },
|
||||||
|
+ { type: "text-end", id: "msg_final", providerMetadata: metadata },
|
||||||
|
+ { type: "step-finish", index: 0, reason: "stop" },
|
||||||
|
+ { type: "finish", reason: "stop" },
|
||||||
|
+ ])
|
||||||
|
+ }),
|
||||||
|
+ )
|
||||||
|
+
|
||||||
|
it.effect("parses reasoning summary stream fixtures", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const body = sseEvents(
|
||||||
+166
-9
@@ -2,6 +2,7 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
|||||||
|
|
||||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||||
import { type SupportedModel } from "../chat/models.js";
|
import { type SupportedModel } from "../chat/models.js";
|
||||||
|
import { config } from "../config.js";
|
||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import {
|
import {
|
||||||
type PermissionReply,
|
type PermissionReply,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
extractRequestReason,
|
extractRequestReason,
|
||||||
extractSkillAuditInfo,
|
extractSkillAuditInfo,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
getAssistantMessagePhase,
|
||||||
getToolProgressTitle,
|
getToolProgressTitle,
|
||||||
getUnknownErrorMessage,
|
getUnknownErrorMessage,
|
||||||
hasToolParams,
|
hasToolParams,
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
normalizeToolStatus,
|
normalizeToolStatus,
|
||||||
type PermissionRequestPayload,
|
type PermissionRequestPayload,
|
||||||
type QuestionRequestPayload,
|
type QuestionRequestPayload,
|
||||||
|
type AssistantMessagePhase,
|
||||||
type TodoItemPayload,
|
type TodoItemPayload,
|
||||||
type TodoUpdatePayload,
|
type TodoUpdatePayload,
|
||||||
} from "./chatStreamEvents.js";
|
} from "./chatStreamEvents.js";
|
||||||
@@ -112,17 +115,59 @@ const toRuntimeModel = (model?: SupportedModel) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STRUCTURED_OUTPUT_TOOL_NAME = "StructuredOutput";
|
||||||
|
const STRUCTURED_FINAL_ANSWER_FORMAT = {
|
||||||
|
type: "json_schema" as const,
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
answer: {
|
||||||
|
type: "string",
|
||||||
|
description: "直接展示给用户的完整最终回答,使用简体中文和 Markdown。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["answer"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiresStructuredFinalAnswer = (model?: SupportedModel) =>
|
||||||
|
(model ?? config.OPENCODE_MODEL).split("/", 1)[0] === "deepseek";
|
||||||
|
|
||||||
|
const extractStructuredAnswer = (value: unknown) =>
|
||||||
|
isObjectRecord(value) && typeof value.answer === "string"
|
||||||
|
? value.answer.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
const emitFinalMessage = async (
|
const emitFinalMessage = async (
|
||||||
runtime: OpencodeRuntimeAdapter,
|
runtime: OpencodeRuntimeAdapter,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
clientSessionId: string,
|
clientSessionId: string,
|
||||||
currentAssistantMessageIds: Set<string>,
|
currentAssistantMessageIds: Set<string>,
|
||||||
assistantTextParts: Map<string, Map<string, string>>,
|
assistantTextParts: Map<string, Map<string, string>>,
|
||||||
|
assistantTextPartPhases: Map<string, AssistantMessagePhase>,
|
||||||
write: (event: string, data: Record<string, unknown>) => void,
|
write: (event: string, data: Record<string, unknown>) => void,
|
||||||
) => {
|
) => {
|
||||||
let text = [...currentAssistantMessageIds]
|
let text = [...currentAssistantMessageIds]
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
|
.map((messageId) => {
|
||||||
|
const parts = assistantTextParts.get(messageId);
|
||||||
|
if (!parts) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const candidates = [...parts.entries()];
|
||||||
|
const finalText = candidates
|
||||||
|
.filter(([partId]) => assistantTextPartPhases.get(partId) === "final_answer")
|
||||||
|
.map(([, content]) => content)
|
||||||
|
.join("");
|
||||||
|
if (finalText) {
|
||||||
|
return finalText;
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
.filter(([partId]) => assistantTextPartPhases.get(partId) !== "commentary")
|
||||||
|
.map(([, content]) => content)
|
||||||
|
.join("");
|
||||||
|
})
|
||||||
.find((content) => content.length > 0) ?? "";
|
.find((content) => content.length > 0) ?? "";
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -135,7 +180,20 @@ const emitFinalMessage = async (
|
|||||||
(currentAssistantMessageIds.size === 0 ||
|
(currentAssistantMessageIds.size === 0 ||
|
||||||
currentAssistantMessageIds.has(message.info.id)),
|
currentAssistantMessageIds.has(message.info.id)),
|
||||||
);
|
);
|
||||||
text = collectTextContent(assistantMessage?.parts ?? []);
|
const structured =
|
||||||
|
assistantMessage && "structured" in assistantMessage.info
|
||||||
|
? assistantMessage.info.structured
|
||||||
|
: undefined;
|
||||||
|
const structuredAnswer = extractStructuredAnswer(structured);
|
||||||
|
text =
|
||||||
|
structuredAnswer ||
|
||||||
|
collectTextContent(
|
||||||
|
(assistantMessage?.parts ?? []).filter(
|
||||||
|
(part) =>
|
||||||
|
part.type !== "text" ||
|
||||||
|
getAssistantMessagePhase(part.metadata) !== "commentary",
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -165,7 +223,7 @@ export const streamPromptResponse = async ({
|
|||||||
failed: boolean;
|
failed: boolean;
|
||||||
toolCallCount: number;
|
toolCallCount: number;
|
||||||
}> => {
|
}> => {
|
||||||
const eventStream = await runtime.subscribeEvents();
|
const eventStream = await runtime.subscribeEvents(workspaceRoot);
|
||||||
const iterator = eventStream[Symbol.asyncIterator]();
|
const iterator = eventStream[Symbol.asyncIterator]();
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
const promptStartedAt = Date.now();
|
const promptStartedAt = Date.now();
|
||||||
@@ -176,6 +234,8 @@ export const streamPromptResponse = async ({
|
|||||||
const emittedQuestionRequestIds = new Set<string>();
|
const emittedQuestionRequestIds = new Set<string>();
|
||||||
const currentAssistantMessageIds = new Set<string>();
|
const currentAssistantMessageIds = new Set<string>();
|
||||||
const assistantTextParts = new Map<string, Map<string, string>>();
|
const assistantTextParts = new Map<string, Map<string, string>>();
|
||||||
|
const assistantTextPartPhases = new Map<string, AssistantMessagePhase>();
|
||||||
|
const emittedFinalTextLengths = new Map<string, number>();
|
||||||
const partTypes = new Map<string, Part["type"]>();
|
const partTypes = new Map<string, Part["type"]>();
|
||||||
const pendingTextDeltas = new Map<string, string[]>();
|
const pendingTextDeltas = new Map<string, string[]>();
|
||||||
const reasoningStatuses = new Map<string, "running" | "completed">();
|
const reasoningStatuses = new Map<string, "running" | "completed">();
|
||||||
@@ -192,6 +252,7 @@ export const streamPromptResponse = async ({
|
|||||||
let promptSettled = false;
|
let promptSettled = false;
|
||||||
let aborted = signal?.aborted ?? false;
|
let aborted = signal?.aborted ?? false;
|
||||||
let failed = false;
|
let failed = false;
|
||||||
|
let structuredFinalAnswerEmitted = false;
|
||||||
const debugContext = {
|
const debugContext = {
|
||||||
sessionId,
|
sessionId,
|
||||||
clientSessionId,
|
clientSessionId,
|
||||||
@@ -258,6 +319,37 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emitFinalText = (partId: string, text: string) => {
|
||||||
|
const emittedLength = emittedFinalTextLengths.get(partId) ?? 0;
|
||||||
|
if (text.length <= emittedLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const content = text.slice(emittedLength);
|
||||||
|
emittedFinalTextLengths.set(partId, text.length);
|
||||||
|
emittedText = true;
|
||||||
|
write("token", {
|
||||||
|
session_id: clientSessionId,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitCommentaryProgress = (
|
||||||
|
partId: string,
|
||||||
|
text: string,
|
||||||
|
completed: boolean,
|
||||||
|
) => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitProgress({
|
||||||
|
id: `commentary-${partId}`,
|
||||||
|
phase: "commentary",
|
||||||
|
status: completed ? "completed" : "running",
|
||||||
|
title: completed ? "Agent 过程已更新" : "Agent 正在处理",
|
||||||
|
detail: text,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
emitProgress({
|
emitProgress({
|
||||||
id: "request-received",
|
id: "request-received",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
@@ -267,7 +359,14 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const promptPromise = runtime
|
const promptPromise = runtime
|
||||||
.prompt(sessionId, message, toRuntimeModel(model))
|
.prompt(
|
||||||
|
sessionId,
|
||||||
|
message,
|
||||||
|
toRuntimeModel(model),
|
||||||
|
requiresStructuredFinalAnswer(model)
|
||||||
|
? { format: STRUCTURED_FINAL_ANSWER_FORMAT }
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
promptSettled = true;
|
promptSettled = true;
|
||||||
logDevelopmentDebug("runtime.prompt resolved", {
|
logDevelopmentDebug("runtime.prompt resolved", {
|
||||||
@@ -425,6 +524,7 @@ export const streamPromptResponse = async ({
|
|||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
directory: workspaceRoot,
|
||||||
reply,
|
reply,
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
@@ -481,6 +581,7 @@ export const streamPromptResponse = async ({
|
|||||||
await runtime.replyPermission({
|
await runtime.replyPermission({
|
||||||
requestId: event.properties.id,
|
requestId: event.properties.id,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
directory: workspaceRoot,
|
||||||
reply,
|
reply,
|
||||||
});
|
});
|
||||||
write("permission_response", {
|
write("permission_response", {
|
||||||
@@ -674,11 +775,15 @@ export const streamPromptResponse = async ({
|
|||||||
const partType = partTypes.get(event.properties.partID);
|
const partType = partTypes.get(event.properties.partID);
|
||||||
if (partType === "text") {
|
if (partType === "text") {
|
||||||
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
|
const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
|
||||||
messageParts.set(
|
const text = `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`;
|
||||||
event.properties.partID,
|
messageParts.set(event.properties.partID, text);
|
||||||
`${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
|
|
||||||
);
|
|
||||||
assistantTextParts.set(event.properties.messageID, messageParts);
|
assistantTextParts.set(event.properties.messageID, messageParts);
|
||||||
|
const phase = assistantTextPartPhases.get(event.properties.partID) ?? "unknown";
|
||||||
|
if (phase === "final_answer") {
|
||||||
|
emitFinalText(event.properties.partID, text);
|
||||||
|
} else if (phase === "commentary") {
|
||||||
|
emitCommentaryProgress(event.properties.partID, text, false);
|
||||||
|
}
|
||||||
} else if (!partType) {
|
} else if (!partType) {
|
||||||
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
|
const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
|
||||||
pending.push(event.properties.delta);
|
pending.push(event.properties.delta);
|
||||||
@@ -695,11 +800,19 @@ export const streamPromptResponse = async ({
|
|||||||
currentAssistantMessageIds.add(part.messageID);
|
currentAssistantMessageIds.add(part.messageID);
|
||||||
}
|
}
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
|
const phase = getAssistantMessagePhase(part.metadata);
|
||||||
|
assistantTextPartPhases.set(part.id, phase);
|
||||||
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
|
const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
|
||||||
pendingTextDeltas.delete(part.id);
|
pendingTextDeltas.delete(part.id);
|
||||||
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
|
const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
|
||||||
messageParts.set(part.id, part.text || pendingText);
|
const text = part.text || pendingText;
|
||||||
|
messageParts.set(part.id, text);
|
||||||
assistantTextParts.set(part.messageID, messageParts);
|
assistantTextParts.set(part.messageID, messageParts);
|
||||||
|
if (phase === "final_answer") {
|
||||||
|
emitFinalText(part.id, text);
|
||||||
|
} else if (phase === "commentary") {
|
||||||
|
emitCommentaryProgress(part.id, text, Boolean(part.time?.end));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
pendingTextDeltas.delete(part.id);
|
pendingTextDeltas.delete(part.id);
|
||||||
}
|
}
|
||||||
@@ -756,6 +869,47 @@ export const streamPromptResponse = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (part.tool === STRUCTURED_OUTPUT_TOOL_NAME) {
|
||||||
|
if (part.state.status === "error") {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
sessionId,
|
||||||
|
clientSessionId,
|
||||||
|
partId: part.id,
|
||||||
|
error: part.state.error,
|
||||||
|
},
|
||||||
|
"structured final answer tool failed",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(part.state.status !== "running" &&
|
||||||
|
part.state.status !== "completed") ||
|
||||||
|
structuredFinalAnswerEmitted
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = extractStructuredAnswer(toolParams);
|
||||||
|
if (!answer) {
|
||||||
|
logger.warn(
|
||||||
|
{ sessionId, clientSessionId, partId: part.id },
|
||||||
|
"structured final answer tool received without an answer",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredFinalAnswerEmitted = true;
|
||||||
|
emitFinalText(part.id, answer);
|
||||||
|
logDevelopmentDebug("final answer submitted through StructuredOutput", {
|
||||||
|
...debugContext,
|
||||||
|
partId: part.id,
|
||||||
|
answerChars: answer.length,
|
||||||
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const questionToolPayload = normalizeQuestionToolPayload(
|
const questionToolPayload = normalizeQuestionToolPayload(
|
||||||
part,
|
part,
|
||||||
toolParams,
|
toolParams,
|
||||||
@@ -942,14 +1096,17 @@ export const streamPromptResponse = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await promptPromise;
|
await promptPromise;
|
||||||
|
if (!emittedText) {
|
||||||
emittedText = await emitFinalMessage(
|
emittedText = await emitFinalMessage(
|
||||||
runtime,
|
runtime,
|
||||||
sessionId,
|
sessionId,
|
||||||
clientSessionId,
|
clientSessionId,
|
||||||
currentAssistantMessageIds,
|
currentAssistantMessageIds,
|
||||||
assistantTextParts,
|
assistantTextParts,
|
||||||
|
assistantTextPartPhases,
|
||||||
write,
|
write,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
emitProgress({
|
emitProgress({
|
||||||
id: "request-received",
|
id: "request-received",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export type TodoUpdatePayload = {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssistantMessagePhase =
|
||||||
|
| "commentary"
|
||||||
|
| "final_answer"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const toolLabels: Record<string, string> = {
|
const toolLabels: Record<string, string> = {
|
||||||
@@ -110,6 +115,19 @@ export const getUnknownErrorMessage = (error: unknown) => {
|
|||||||
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
export const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const getAssistantMessagePhase = (
|
||||||
|
metadata: unknown,
|
||||||
|
): AssistantMessagePhase => {
|
||||||
|
if (!isObjectRecord(metadata)) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
const openai = isObjectRecord(metadata.openai) ? metadata.openai : undefined;
|
||||||
|
const phase = openai?.phase ?? metadata.phase;
|
||||||
|
return phase === "commentary" || phase === "final_answer"
|
||||||
|
? phase
|
||||||
|
: "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
export const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||||
if (isObjectRecord(value)) {
|
if (isObjectRecord(value)) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
+51
-9
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createOpencode,
|
createOpencode,
|
||||||
type OpencodeClient,
|
type OpencodeClient,
|
||||||
|
type OutputFormat,
|
||||||
} from "@opencode-ai/sdk/v2";
|
} from "@opencode-ai/sdk/v2";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -38,6 +39,10 @@ type RuntimeModelOverride = {
|
|||||||
modelID: string;
|
modelID: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuntimePromptOptions = {
|
||||||
|
format?: OutputFormat;
|
||||||
|
};
|
||||||
|
|
||||||
export type PermissionReply = "once" | "always" | "reject";
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
export type QuestionAnswers = string[][];
|
export type QuestionAnswers = string[][];
|
||||||
|
|
||||||
@@ -180,7 +185,12 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return this.messages(sessionId);
|
return this.messages(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
|
async prompt(
|
||||||
|
sessionId: string,
|
||||||
|
text: string,
|
||||||
|
model?: RuntimeModelOverride,
|
||||||
|
options?: RuntimePromptOptions,
|
||||||
|
) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
logDevelopmentDebug(
|
logDevelopmentDebug(
|
||||||
@@ -194,6 +204,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
await client.session.prompt({
|
await client.session.prompt({
|
||||||
sessionID: sessionId,
|
sessionID: sessionId,
|
||||||
model,
|
model,
|
||||||
|
...(options?.format ? { format: options.format } : {}),
|
||||||
parts: [{ type: "text", text }],
|
parts: [{ type: "text", text }],
|
||||||
});
|
});
|
||||||
logDevelopmentDebug(
|
logDevelopmentDebug(
|
||||||
@@ -299,35 +310,41 @@ export class OpencodeRuntimeAdapter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async subscribeEvents() {
|
async subscribeEvents(directory?: string) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const response = await client.event.subscribe();
|
const response = await client.event.subscribe(
|
||||||
|
directory ? { directory } : undefined,
|
||||||
|
);
|
||||||
return response.stream;
|
return response.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
async replyPermission(options: {
|
async replyPermission(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
reply: PermissionReply;
|
reply: PermissionReply;
|
||||||
message?: string;
|
message?: string;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("permission" in client && client.permission?.reply) {
|
if ("permission" in client && client.permission?.reply) {
|
||||||
const response = await client.permission.reply({
|
const response = await client.permission.reply({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
reply: options.reply,
|
reply: options.reply,
|
||||||
message: options.message,
|
message: options.message,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "permission.reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
if ("permission" in client && client.permission?.respond && options.sessionId) {
|
||||||
const response = await client.permission.respond({
|
const response = await client.permission.respond({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
permissionID: options.requestId,
|
permissionID: options.requestId,
|
||||||
|
directory,
|
||||||
response: options.reply,
|
response: options.reply,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "permission.respond");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode permission reply API is unavailable");
|
throw new Error("opencode permission reply API is unavailable");
|
||||||
@@ -336,16 +353,19 @@ export class OpencodeRuntimeAdapter {
|
|||||||
async replyQuestion(options: {
|
async replyQuestion(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
answers: QuestionAnswers;
|
answers: QuestionAnswers;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("question" in client && client.question?.reply) {
|
if ("question" in client && client.question?.reply) {
|
||||||
try {
|
try {
|
||||||
const response = await client.question.reply({
|
const response = await client.question.reply({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
answers: options.answers,
|
answers: options.answers,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.reply");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.sessionId) {
|
if (!options.sessionId) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -360,6 +380,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
reply?: (parameters: {
|
reply?: (parameters: {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
requestID: string;
|
requestID: string;
|
||||||
|
directory?: string;
|
||||||
questionV2Reply: { answers: QuestionAnswers };
|
questionV2Reply: { answers: QuestionAnswers };
|
||||||
}) => Promise<{ data: unknown }>;
|
}) => Promise<{ data: unknown }>;
|
||||||
};
|
};
|
||||||
@@ -371,11 +392,12 @@ export class OpencodeRuntimeAdapter {
|
|||||||
const response = await v2Question.reply({
|
const response = await v2Question.reply({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
questionV2Reply: {
|
questionV2Reply: {
|
||||||
answers: options.answers,
|
answers: options.answers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.v2.reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode question reply API is unavailable");
|
throw new Error("opencode question reply API is unavailable");
|
||||||
@@ -384,14 +406,17 @@ export class OpencodeRuntimeAdapter {
|
|||||||
async rejectQuestion(options: {
|
async rejectQuestion(options: {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
}) {
|
}) {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
|
const directory = await this.resolveInteractionDirectory(options);
|
||||||
if ("question" in client && client.question?.reject) {
|
if ("question" in client && client.question?.reject) {
|
||||||
try {
|
try {
|
||||||
const response = await client.question.reject({
|
const response = await client.question.reject({
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.reject");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!options.sessionId) {
|
if (!options.sessionId) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -406,6 +431,7 @@ export class OpencodeRuntimeAdapter {
|
|||||||
reject?: (parameters: {
|
reject?: (parameters: {
|
||||||
sessionID: string;
|
sessionID: string;
|
||||||
requestID: string;
|
requestID: string;
|
||||||
|
directory?: string;
|
||||||
}) => Promise<{ data: unknown }>;
|
}) => Promise<{ data: unknown }>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -416,13 +442,29 @@ export class OpencodeRuntimeAdapter {
|
|||||||
const response = await v2Question.reject({
|
const response = await v2Question.reject({
|
||||||
sessionID: options.sessionId,
|
sessionID: options.sessionId,
|
||||||
requestID: options.requestId,
|
requestID: options.requestId,
|
||||||
|
directory,
|
||||||
});
|
});
|
||||||
return response.data;
|
return requireData(response.data, "question.v2.reject");
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("opencode question reject API is unavailable");
|
throw new Error("opencode question reject API is unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveInteractionDirectory(options: {
|
||||||
|
sessionId?: string;
|
||||||
|
directory?: string;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const directory = options.directory?.trim();
|
||||||
|
if (directory) {
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
if (!options.sessionId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const session = await this.getSession(options.sessionId);
|
||||||
|
return session.directory;
|
||||||
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
if (this.toolOutputCleanupTimer) {
|
if (this.toolOutputCleanupTimer) {
|
||||||
clearInterval(this.toolOutputCleanupTimer);
|
clearInterval(this.toolOutputCleanupTimer);
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ const createEventStream = (events: unknown[]) => ({
|
|||||||
|
|
||||||
describe("streamPromptResponse", () => {
|
describe("streamPromptResponse", () => {
|
||||||
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
|
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
|
||||||
|
let subscribedDirectory: string | undefined;
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async (directory?: string) => {
|
||||||
createEventStream([
|
subscribedDirectory = directory;
|
||||||
|
return createEventStream([
|
||||||
{
|
{
|
||||||
type: "message.part.delta",
|
type: "message.part.delta",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -73,7 +75,8 @@ describe("streamPromptResponse", () => {
|
|||||||
type: "session.idle",
|
type: "session.idle",
|
||||||
properties: { sessionID: "runtime-session-1" },
|
properties: { sessionID: "runtime-session-1" },
|
||||||
},
|
},
|
||||||
]),
|
]);
|
||||||
|
},
|
||||||
prompt: async () => undefined,
|
prompt: async () => undefined,
|
||||||
messages: async () => [
|
messages: async () => [
|
||||||
{
|
{
|
||||||
@@ -109,9 +112,11 @@ describe("streamPromptResponse", () => {
|
|||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "分析管网瓶颈",
|
message: "分析管网瓶颈",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
|
||||||
expect(
|
expect(
|
||||||
events
|
events
|
||||||
.filter((item) => item.event === "token")
|
.filter((item) => item.event === "token")
|
||||||
@@ -120,6 +125,143 @@ describe("streamPromptResponse", () => {
|
|||||||
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
|
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams final_answer deltas while routing commentary to progress", async () => {
|
||||||
|
let messagesCalls = 0;
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "commentary-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-commentary",
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
metadata: { openai: { phase: "commentary" } },
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-commentary",
|
||||||
|
partID: "commentary-part",
|
||||||
|
field: "text",
|
||||||
|
delta: "我先检查相关数据。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "commentary-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-commentary",
|
||||||
|
type: "text",
|
||||||
|
text: "我先检查相关数据。",
|
||||||
|
metadata: { openai: { phase: "commentary" } },
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "final-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
metadata: { openai: { phase: "final_answer" } },
|
||||||
|
time: { start: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
partID: "final-part",
|
||||||
|
field: "text",
|
||||||
|
delta: "分析完成,",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
partID: "final-part",
|
||||||
|
field: "text",
|
||||||
|
delta: "结果正常。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "final-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "text",
|
||||||
|
text: "分析完成,结果正常。",
|
||||||
|
metadata: { openai: { phase: "final_answer" } },
|
||||||
|
time: { start: 3, end: 4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => {
|
||||||
|
messagesCalls += 1;
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
message: "分析管网",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
events.filter((item) => item.event === "token").map((item) => item.data.content),
|
||||||
|
).toEqual(["分析完成,", "结果正常。"]);
|
||||||
|
expect(messagesCalls).toBe(0);
|
||||||
|
expect(
|
||||||
|
events.find(
|
||||||
|
(item) =>
|
||||||
|
item.event === "progress" && item.data.id === "commentary-commentary-part",
|
||||||
|
)?.data,
|
||||||
|
).toMatchObject({
|
||||||
|
phase: "commentary",
|
||||||
|
status: "running",
|
||||||
|
detail: "我先检查相关数据。",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
events.some(
|
||||||
|
(item) => item.event === "token" && item.data.content === "我先检查相关数据。",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the final text event cache when the messages lookup fails", async () => {
|
it("uses the final text event cache when the messages lookup fails", async () => {
|
||||||
const runtime = {
|
const runtime = {
|
||||||
subscribeEvents: async () =>
|
subscribeEvents: async () =>
|
||||||
@@ -334,6 +476,7 @@ describe("streamPromptResponse", () => {
|
|||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "run tests",
|
message: "run tests",
|
||||||
approvalMode: "auto",
|
approvalMode: "auto",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,6 +484,7 @@ describe("streamPromptResponse", () => {
|
|||||||
{
|
{
|
||||||
requestId: "perm-1",
|
requestId: "perm-1",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
reply: "once",
|
reply: "once",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -422,6 +566,7 @@ describe("streamPromptResponse", () => {
|
|||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "run tests",
|
message: "run tests",
|
||||||
approvalMode: "always",
|
approvalMode: "always",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,6 +574,7 @@ describe("streamPromptResponse", () => {
|
|||||||
{
|
{
|
||||||
requestId: "perm-always-bash",
|
requestId: "perm-always-bash",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
reply: "once",
|
reply: "once",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -465,6 +611,7 @@ describe("streamPromptResponse", () => {
|
|||||||
clientSessionId: "client-session-1",
|
clientSessionId: "client-session-1",
|
||||||
message: "delete recursively",
|
message: "delete recursively",
|
||||||
approvalMode: "always",
|
approvalMode: "always",
|
||||||
|
workspaceRoot: "/tmp/conversation-workspace-1",
|
||||||
write: (event, data) => events.push({ event, data }),
|
write: (event, data) => events.push({ event, data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,6 +619,7 @@ describe("streamPromptResponse", () => {
|
|||||||
{
|
{
|
||||||
requestId: "perm-always-rm-rf",
|
requestId: "perm-always-rm-rf",
|
||||||
sessionId: "runtime-session-1",
|
sessionId: "runtime-session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
reply: "reject",
|
reply: "reject",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -741,4 +889,143 @@ describe("streamPromptResponse", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses StructuredOutput as the terminal action for models without reliable phases", async () => {
|
||||||
|
const promptCalls: unknown[][] = [];
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "final-answer-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "tool",
|
||||||
|
callID: "final-answer-call",
|
||||||
|
tool: "StructuredOutput",
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: { answer: "供水服务分区分析已完成。" },
|
||||||
|
time: { start: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
part: {
|
||||||
|
id: "final-answer-part",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
messageID: "assistant-final",
|
||||||
|
type: "tool",
|
||||||
|
callID: "final-answer-call",
|
||||||
|
tool: "StructuredOutput",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { answer: "供水服务分区分析已完成。" },
|
||||||
|
output: "最终回答已提交。",
|
||||||
|
title: "StructuredOutput",
|
||||||
|
metadata: {},
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
prompt: async (...args: unknown[]) => {
|
||||||
|
promptCalls.push(args);
|
||||||
|
},
|
||||||
|
messages: async () => {
|
||||||
|
throw new Error("StructuredOutput should avoid the message fallback");
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
const result = await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
message: "分析供水服务分区",
|
||||||
|
model: "deepseek/deepseek-v4-flash",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(promptCalls[0]?.[3]).toMatchObject({
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
required: ["answer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((item) => item.event === "token")
|
||||||
|
.map((item) => item.data.content)
|
||||||
|
.join(""),
|
||||||
|
).toBe("供水服务分区分析已完成。");
|
||||||
|
expect(events.some((item) => item.event === "tool_call")).toBe(false);
|
||||||
|
expect(events.some((item) => item.event === "done")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers the final answer from structured message data when tool events are missed", async () => {
|
||||||
|
const runtime = {
|
||||||
|
subscribeEvents: async () =>
|
||||||
|
createEventStream([
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "assistant-final",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "runtime-session-1" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
prompt: async () => undefined,
|
||||||
|
messages: async () => [
|
||||||
|
{
|
||||||
|
info: {
|
||||||
|
id: "assistant-final",
|
||||||
|
sessionID: "runtime-session-1",
|
||||||
|
role: "assistant",
|
||||||
|
structured: { answer: "已从结构化结果恢复最终回答。" },
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as OpencodeRuntimeAdapter;
|
||||||
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
|
await streamPromptResponse({
|
||||||
|
runtime,
|
||||||
|
sessionId: "runtime-session-1",
|
||||||
|
clientSessionId: "client-session-1",
|
||||||
|
message: "恢复最终回答",
|
||||||
|
model: "deepseek/deepseek-v4-flash",
|
||||||
|
write: (event, data) => events.push({ event, data }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
events
|
||||||
|
.filter((item) => item.event === "token")
|
||||||
|
.map((item) => item.data.content)
|
||||||
|
.join(""),
|
||||||
|
).toBe("已从结构化结果恢复最终回答。");
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,6 +90,136 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
|
||||||
|
it("subscribes to the conversation workspace directory", async () => {
|
||||||
|
const calls: Array<Record<string, unknown> | undefined> = [];
|
||||||
|
const stream = (async function* () {
|
||||||
|
return;
|
||||||
|
})();
|
||||||
|
const client = {
|
||||||
|
event: {
|
||||||
|
subscribe: async (input?: Record<string, unknown>) => {
|
||||||
|
calls.push(input);
|
||||||
|
return { stream };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runtime.subscribeEvents("/tmp/conversation-workspace-1"),
|
||||||
|
).resolves.toBe(stream);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ directory: "/tmp/conversation-workspace-1" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpencodeRuntimeAdapter.prompt", () => {
|
||||||
|
it("forwards the final answer schema", async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const client = {
|
||||||
|
session: {
|
||||||
|
prompt: async (input: unknown) => {
|
||||||
|
calls.push(input);
|
||||||
|
return { data: {} };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await runtime.prompt(
|
||||||
|
"session-1",
|
||||||
|
"分析供水分区",
|
||||||
|
{ providerID: "deepseek", modelID: "deepseek-v4-flash" },
|
||||||
|
{
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { answer: { type: "string" } },
|
||||||
|
required: ["answer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
sessionID: "session-1",
|
||||||
|
model: {
|
||||||
|
providerID: "deepseek",
|
||||||
|
modelID: "deepseek-v4-flash",
|
||||||
|
},
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { answer: { type: "string" } },
|
||||||
|
required: ["answer"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [{ type: "text", text: "分析供水分区" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpencodeRuntimeAdapter interaction replies", () => {
|
||||||
|
it("replies to permissions in the conversation workspace directory", async () => {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
const client = {
|
||||||
|
permission: {
|
||||||
|
reply: async (input: unknown) => {
|
||||||
|
calls.push(input);
|
||||||
|
return { data: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await runtime.replyPermission({
|
||||||
|
requestId: "permission-1",
|
||||||
|
sessionId: "session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
reply: "once",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{
|
||||||
|
requestID: "permission-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
reply: "once",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when OpenCode returns no permission reply data", async () => {
|
||||||
|
const client = {
|
||||||
|
permission: {
|
||||||
|
reply: async () => ({ data: undefined }),
|
||||||
|
},
|
||||||
|
} as unknown as OpencodeClient;
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
ensureClient: async () => client,
|
||||||
|
}) as OpencodeRuntimeAdapter;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runtime.replyPermission({
|
||||||
|
requestId: "permission-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
reply: "once",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("permission.reply returned no data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("OpencodeRuntimeAdapter.createSession", () => {
|
describe("OpencodeRuntimeAdapter.createSession", () => {
|
||||||
it("creates a real chat session inside a dedicated conversation workspace", async () => {
|
it("creates a real chat session inside a dedicated conversation workspace", async () => {
|
||||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
|
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
|
||||||
@@ -225,11 +355,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
|||||||
await runtime.replyQuestion({
|
await runtime.replyQuestion({
|
||||||
requestId: "question-1",
|
requestId: "question-1",
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
answers: [["继续"]],
|
answers: [["继续"]],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
{ requestID: "question-1", answers: [["继续"]] },
|
{
|
||||||
|
requestID: "question-1",
|
||||||
|
directory: "/tmp/conversation-workspace-1",
|
||||||
|
answers: [["继续"]],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user