2 Commits
Author SHA1 Message Date
jiang 4744f1f20a fix(agent): derive activity stages from tool actions
Generic Container CI/CD / test-build-publish (push) Successful in 2m24s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m24s
2026-09-25 16:23:00 +08:00
jiang 54d8feca14 fix(agent): bundle ripgrep for skill and search tools
Generic Container CI/CD / test-build-publish (push) Successful in 4m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 4m4s
2026-09-25 16:02:10 +08:00
3 changed files with 154 additions and 7 deletions
+2
View File
@@ -19,6 +19,7 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \
curl \ curl \
jq \ jq \
libseccomp2 \ libseccomp2 \
ripgrep \
unzip \ unzip \
python3 \ python3 \
python3-venv && \ python3-venv && \
@@ -96,6 +97,7 @@ RUN bun run check
FROM build AS test FROM build AS test
COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode COPY --from=opencode-builder /out/opencode /usr/local/bin/opencode
RUN command -v rg && rg --version
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
+88 -7
View File
@@ -2,6 +2,7 @@ import type { Part } from "@opencode-ai/sdk/v2";
import { import {
getToolLabel, getToolLabel,
initialActivity,
normalizeToolStatus, normalizeToolStatus,
type ActivityActionPayload, type ActivityActionPayload,
type ActivityPayload, type ActivityPayload,
@@ -12,6 +13,51 @@ import {
type ToolPart = Extract<Part, { type: "tool" }>; type ToolPart = Extract<Part, { type: "tool" }>;
type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">; type ActivityContext = Pick<ActivityPayload, "id" | "title" | "reason">;
type AutomaticStageKind = "reference" | "data" | "analysis" | "visualization" | "other";
const automaticStages: Record<AutomaticStageKind, Pick<ActivityPayload, "title" | "reason">> = {
reference: {
title: "查找所需资料",
reason: "正在检索完成本次请求所需的工作流和参考信息。",
},
data: {
title: "获取业务数据",
reason: "正在查询本次分析所需的数据和业务能力。",
},
analysis: {
title: "处理分析数据",
reason: "正在对已获取的信息进行计算和核查。",
},
visualization: {
title: "更新地图与图表",
reason: "正在将分析内容呈现在地图或图表中。",
},
other: {
title: "执行分析操作",
reason: "正在处理完成本次请求所需的操作。",
},
};
const referenceTools = new Set(["skill", "read", "glob", "grep", "session_search"]);
const dataTools = new Set(["tjwater_cli", "web_search", "geocode"]);
const visualizationTools = new Set([
"store_render_ref",
"locate_features",
"zoom_to_map",
"view_scada",
"show_chart",
"render_junctions",
"view_history",
"apply_layer_style",
]);
const getAutomaticStageKind = (tool: string): AutomaticStageKind => {
if (referenceTools.has(tool)) return "reference";
if (dataTools.has(tool)) return "data";
if (tool === "bash") return "analysis";
if (visualizationTools.has(tool)) return "visualization";
return "other";
};
const getActionTarget = (params: Record<string, unknown>) => { const getActionTarget = (params: Record<string, unknown>) => {
for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) { for (const key of ["command", "file_path", "filePath", "path", "query", "keyword"]) {
@@ -33,6 +79,9 @@ export const createActivityTracker = ({
const activities = new Map<string, ActivityPayload>(); const activities = new Map<string, ActivityPayload>();
const actionActivityIds = new Map<string, string>(); const actionActivityIds = new Map<string, string>();
let currentActivityId: string | null = null; let currentActivityId: string | null = null;
let currentStageOrigin: "startup" | "explicit" | "automatic" = "startup";
let currentAutomaticKind: AutomaticStageKind | null = null;
let automaticStageCount = 0;
const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => { const emit = (activity: ActivityPayload, todos?: TodoItemPayload[]) => {
const now = Date.now(); const now = Date.now();
@@ -88,11 +137,13 @@ export const createActivityTracker = ({
emit(nextActivity); emit(nextActivity);
}; };
const start = ( const startStage = (
id: string, id: string,
title: string, title: string,
reason: string, reason: string,
todos?: TodoItemPayload[], todos: TodoItemPayload[] | undefined,
origin: typeof currentStageOrigin,
automaticKind: AutomaticStageKind | null = null,
) => { ) => {
finalize("completed"); finalize("completed");
const activity: ActivityPayload = { const activity: ActivityPayload = {
@@ -106,17 +157,47 @@ export const createActivityTracker = ({
}; };
activities.set(id, activity); activities.set(id, activity);
currentActivityId = id; currentActivityId = id;
currentStageOrigin = origin;
currentAutomaticKind = automaticKind;
emit(activity, todos); emit(activity, todos);
return activity; return activity;
}; };
const start = (
id: string,
title: string,
reason: string,
todos?: TodoItemPayload[],
) => startStage(
id,
title,
reason,
todos,
id === initialActivity.id ? "startup" : "explicit",
);
const ensure = (tool: string) => { const ensure = (tool: string) => {
const activity = current(); const activity = current();
if (activity?.status === "running") return activity; if (activity?.status === "running" && currentStageOrigin === "explicit") {
return start( return activity;
`activity-fallback-${Date.now().toString(36)}`, }
"执行分析操作", const kind = getAutomaticStageKind(tool);
`为完成当前请求,需要使用${getToolLabel(tool)}处理相关信息。`, if (
activity?.status === "running" &&
currentStageOrigin === "automatic" &&
(currentAutomaticKind === kind ||
activity.actions.some((action) => action.status === "running"))
) {
return activity;
}
const stage = automaticStages[kind];
return startStage(
`activity-auto-${++automaticStageCount}`,
stage.title,
stage.reason,
undefined,
"automatic",
kind,
); );
}; };
+64
View File
@@ -1,8 +1,72 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js"; import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
import { initialActivity } from "../../src/routes/chatStreamEvents.js";
describe("createActivityTracker", () => { describe("createActivityTracker", () => {
it("moves real tool work out of preparation when the model omits activity updates", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
const action = (id: string, tool: string, status: string) =>
tracker.upsertAction(
{ id, tool, state: { status, input: {} } } as never,
{},
);
tracker.start(initialActivity.id, initialActivity.title, initialActivity.reason);
action("skill-1", "skill", "completed");
action("read-1", "read", "completed");
action("data-1", "tjwater_cli", "completed");
action("data-1", "tjwater_cli", "completed");
action("compute-1", "bash", "completed");
action("map-1", "show_chart", "completed");
tracker.finalize("completed");
const started = events
.filter(({ data }) =>
(data.activity as { actions: unknown[] }).actions.length === 0 &&
(data.activity as { status: string }).status === "running",
)
.map(({ data }) => (data.activity as { title: string }).title);
expect(started).toEqual([
initialActivity.title,
"查找所需资料",
"获取业务数据",
"处理分析数据",
"更新地图与图表",
]);
expect(tracker.getActionContext("read-1")?.title).toBe("查找所需资料");
expect(tracker.getActionContext("data-1")?.title).toBe("获取业务数据");
expect(tracker.getActionContext("map-1")?.title).toBe("更新地图与图表");
});
it("keeps a model-provided stage across tool categories", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
tracker.start(initialActivity.id, initialActivity.title, initialActivity.reason);
tracker.start("model-stage", "验证分析结果", "核对本次计算与数据。");
for (const [id, tool] of [["tool-1", "tjwater_cli"], ["tool-2", "show_chart"]]) {
tracker.upsertAction(
{ id, tool, state: { status: "completed", input: {} } } as never,
{},
);
}
expect(tracker.getActionContext("tool-1")?.title).toBe("验证分析结果");
expect(tracker.getActionContext("tool-2")?.title).toBe("验证分析结果");
expect(
events.filter(({ data }) =>
(data.activity as { actions: unknown[] }).actions.length === 0 &&
(data.activity as { status: string }).status === "running",
),
).toHaveLength(2);
});
it("groups actions and closes running children with their activity", () => { it("groups actions and closes running children with their activity", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = []; const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({ const tracker = createActivityTracker({