fix(agent): stabilize sandboxed streaming workflows

This commit is contained in:
2026-08-26 12:26:29 +08:00
parent 80cfc1f2ab
commit 4ec010455b
12 changed files with 916 additions and 34 deletions
+2
View File
@@ -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:00target-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
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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",
+18
View File
@@ -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
View File
@@ -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);
+290 -3
View File
@@ -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("已从结构化结果恢复最终回答。");
});
}); });
+136 -1
View File
@@ -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: [["继续"]],
},
]); ]);
}); });
}); });