Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4744f1f20a |
@@ -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,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user