发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
10 changed files with 539 additions and 47 deletions
Showing only changes of commit b08eb009bb - Show all commits

View File

@@ -1,7 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useUserStore } from '@easyflow/stores'; import { useUserStore } from '@easyflow/stores';
@@ -23,6 +23,10 @@ describe('agentChatRuntimeManager', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => {
vi.useRealTimers();
});
it('replaces draft image URLs and isolates snapshots by account', async () => { it('replaces draft image URLs and isolates snapshots by account', async () => {
let callbacks: any; let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => { vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
@@ -95,4 +99,76 @@ describe('agentChatRuntimeManager', () => {
userStore.setUserInfo(firstAccount); userStore.setUserInfo(firstAccount);
expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined(); expect(agentChatRuntimeManager.getSnapshot('101')).toBeUndefined();
}); });
it('coalesces streaming notifications and persists terminal state immediately', async () => {
vi.useFakeTimers();
let callbacks: any;
vi.mocked(sendAgentChat).mockImplementation((_data, options) => {
callbacks = options;
return Promise.resolve() as any;
});
const account = {
avatar: '',
id: 'stream-user',
loginName: 'stream-user',
nickname: '流式用户',
tenantId: 'tenant-1',
};
useUserStore().setUserInfo(account);
await agentChatRuntimeManager.start({
agentId: 'agent-1',
prompt: '流式测试',
sessionId: 'stream-session',
});
const listener = vi.fn();
const unsubscribe = agentChatRuntimeManager.subscribe(listener);
const storageSpy = vi.spyOn(sessionStorage, 'setItem');
for (const delta of ['A', 'B', 'C']) {
callbacks.onMessage({
data: JSON.stringify({
domain: 'LLM',
payload: { delta },
type: 'MESSAGE',
}),
});
}
expect(listener).not.toHaveBeenCalled();
expect(storageSpy).not.toHaveBeenCalled();
expect(
JSON.stringify(
agentChatRuntimeManager.getSnapshot('stream-session')?.items,
),
).toContain('ABC');
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledTimes(1);
expect(storageSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(250);
expect(storageSpy).toHaveBeenCalledTimes(2);
callbacks.onMessage({
data: JSON.stringify({
domain: 'LLM',
payload: { delta: 'D' },
type: 'MESSAGE',
}),
});
listener.mockClear();
storageSpy.mockClear();
callbacks.onFinished();
expect(listener).toHaveBeenCalledTimes(1);
expect(storageSpy).toHaveBeenCalledTimes(2);
expect(agentChatRuntimeManager.getSnapshot('stream-session')).toEqual(
expect.objectContaining({ completed: true, sending: false }),
);
unsubscribe();
storageSpy.mockRestore();
clearAgentChatBrowserCache(account);
});
}); });

View File

@@ -64,10 +64,15 @@ interface StartOptions {
} }
const STORAGE_VERSION = 2; const STORAGE_VERSION = 2;
const STREAM_NOTIFY_INTERVAL_MS = 50;
const STREAM_PERSIST_INTERVAL_MS = 300;
const MAX_RUNTIME_SESSIONS_PER_IDENTITY = 10;
const sessions = new Map<string, RuntimeSessionState>(); const sessions = new Map<string, RuntimeSessionState>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const latestSessionIds = new Map<string, string>(); const latestSessionIds = new Map<string, string>();
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
let notifyTimer: ReturnType<typeof setTimeout> | undefined;
function clone<T>(value: T): T { function clone<T>(value: T): T {
const serialized = JSON.stringify(value); const serialized = JSON.stringify(value);
@@ -108,6 +113,24 @@ function notify() {
} }
} }
function notifyNow() {
if (notifyTimer) {
clearTimeout(notifyTimer);
notifyTimer = undefined;
}
notify();
}
function scheduleNotify() {
if (notifyTimer || listeners.size === 0) {
return;
}
notifyTimer = setTimeout(() => {
notifyTimer = undefined;
notify();
}, STREAM_NOTIFY_INTERVAL_MS);
}
function persistSession(state: RuntimeSessionState) { function persistSession(state: RuntimeSessionState) {
const storage = safeSessionStorage(); const storage = safeSessionStorage();
if (!storage) { if (!storage) {
@@ -118,7 +141,7 @@ function persistSession(state: RuntimeSessionState) {
agentName: state.agentName, agentName: state.agentName,
completed: state.completed, completed: state.completed,
error: state.error, error: state.error,
items: clone(state.items), items: state.items,
prompt: state.prompt, prompt: state.prompt,
roundId: state.roundId, roundId: state.roundId,
sessionId: state.sessionId, sessionId: state.sessionId,
@@ -136,6 +159,82 @@ function persistSession(state: RuntimeSessionState) {
} }
} }
function cancelPersistTimer(scopedSessionKey: string) {
const timer = persistTimers.get(scopedSessionKey);
if (!timer) {
return;
}
clearTimeout(timer);
persistTimers.delete(scopedSessionKey);
}
function removeSessionCache(
scopedSessionKey: string,
state: RuntimeSessionState,
) {
cancelPersistTimer(scopedSessionKey);
sessions.delete(scopedSessionKey);
try {
safeSessionStorage()?.removeItem(
storageKey(state.identity, state.sessionId),
);
} catch {
// 本地缓存清理失败不影响服务端会话。
}
}
function pruneRuntimeSessions(identity: string, currentSessionId: string) {
const scopedSessions = [...sessions.entries()]
.filter(([, session]) => session.identity === identity)
.sort((first, second) => second[1].updatedAt - first[1].updatedAt);
if (scopedSessions.length <= MAX_RUNTIME_SESSIONS_PER_IDENTITY) {
return;
}
const protectedKeys = new Set(
scopedSessions
.filter(
([, session]) =>
session.sending || session.sessionId === currentSessionId,
)
.map(([key]) => key),
);
let retainedCount = protectedKeys.size;
for (const [key, session] of scopedSessions) {
if (protectedKeys.has(key)) {
continue;
}
if (retainedCount < MAX_RUNTIME_SESSIONS_PER_IDENTITY) {
retainedCount += 1;
continue;
}
removeSessionCache(key, session);
}
}
function touchState(state: RuntimeSessionState) {
state.updatedAt = Date.now();
latestSessionIds.set(state.identity, state.sessionId);
sessions.set(sessionKey(state.identity, state.sessionId), state);
}
function scheduleStateUpdate(state: RuntimeSessionState) {
touchState(state);
const scopedSessionKey = sessionKey(state.identity, state.sessionId);
if (!persistTimers.has(scopedSessionKey)) {
persistTimers.set(
scopedSessionKey,
setTimeout(() => {
persistTimers.delete(scopedSessionKey);
const current = sessions.get(scopedSessionKey);
if (current) {
persistSession(current);
}
}, STREAM_PERSIST_INTERVAL_MS),
);
}
scheduleNotify();
}
function restoreSession(identity: string, sessionId: string) { function restoreSession(identity: string, sessionId: string) {
if (!identity) { if (!identity) {
return undefined; return undefined;
@@ -179,11 +278,11 @@ function restoreSession(identity: string, sessionId: string) {
} }
function upsertState(state: RuntimeSessionState) { function upsertState(state: RuntimeSessionState) {
state.updatedAt = Date.now(); touchState(state);
latestSessionIds.set(state.identity, state.sessionId); cancelPersistTimer(sessionKey(state.identity, state.sessionId));
sessions.set(sessionKey(state.identity, state.sessionId), state);
persistSession(state); persistSession(state);
notify(); pruneRuntimeSessions(state.identity, state.sessionId);
notifyNow();
} }
function runningSession(identity = identityScope()) { function runningSession(identity = identityScope()) {
@@ -272,11 +371,12 @@ function replaceAcceptedImages(
onAgentChatCacheClear((identity) => { onAgentChatCacheClear((identity) => {
for (const [key, session] of sessions) { for (const [key, session] of sessions) {
if (session.identity === identity) { if (session.identity === identity) {
cancelPersistTimer(key);
sessions.delete(key); sessions.delete(key);
} }
} }
latestSessionIds.delete(identity); latestSessionIds.delete(identity);
notify(); notifyNow();
}); });
export const agentChatRuntimeManager = { export const agentChatRuntimeManager = {
@@ -383,7 +483,7 @@ export const agentChatRuntimeManager = {
void options.onInputAccepted?.(); void options.onInputAccepted?.();
} }
applyAgentSseEnvelope(current.items, envelope, { roundId }); applyAgentSseEnvelope(current.items, envelope, { roundId });
upsertState(current); scheduleStateUpdate(current);
}, },
}, },
); );

View File

@@ -10,7 +10,7 @@ import type {
AgentToolBinding, AgentToolBinding,
} from '../types'; } from '../types';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ChatTimeline } from '@easyflow/common-ui'; import { ChatTimeline } from '@easyflow/common-ui';
import { BrushCleaning } from '@easyflow/icons'; import { BrushCleaning } from '@easyflow/icons';
@@ -39,6 +39,7 @@ const emit = defineEmits<{ close: [] }>();
const { const {
loading, loading,
clearDraftSession, clearDraftSession,
dispose,
markToolApproving, markToolApproving,
markToolRejected, markToolRejected,
copyMessageText, copyMessageText,
@@ -80,6 +81,9 @@ async function activateComposer() {
} }
onMounted(() => void activateComposer()); onMounted(() => void activateComposer());
onBeforeUnmount(() => {
dispose();
});
watch( watch(
() => [props.agent.id, props.agent.localId], () => [props.agent.id, props.agent.localId],

View File

@@ -241,4 +241,158 @@ describe('useAgentTryoutRawRounds', () => {
toolCallId: 'call-approval', toolCallId: 'call-approval',
}); });
}); });
it('异步工作流轮询事件始终归并到首张审批卡', () => {
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId: 'session-raw-async-tool',
});
const roundId = store.createRound('生成文档');
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
input: { user_input: '写一篇小作文' },
requestId: 'req-async',
resumeToken: 'resume-async',
toolCallId: 'submit-call-1',
toolName: '文档生成',
},
type: 'FORM_REQUEST',
});
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
asyncTool: true,
phase: 'submit',
sourceToolCallId: 'submit-call-1',
status: 'RUNNING',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
},
type: 'TOOL_RESULT',
});
for (const sourceToolCallId of ['observe-call-1', 'observe-call-2']) {
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
asyncTool: true,
input: { taskId: 'task-1' },
phase: 'observe',
sourceToolCallId,
status: 'RUNNING',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
},
type: 'TOOL_CALL',
});
}
store.recordEvent(roundId, {
domain: 'TOOL',
payload: {
asyncTool: true,
phase: 'result',
sourceToolCallId: 'result-call-1',
status: 'SUCCEEDED',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
},
type: 'TOOL_RESULT',
});
const tools = store
.buildTimelineItems()
.filter((item) => item.type === 'tool');
expect(tools).toHaveLength(1);
expect(tools[0]).toMatchObject({
mode: 'approval',
status: 'success',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
});
it('连续文本增量压缩后刷新内容保持一致', () => {
const sessionId = 'stream-compaction';
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
const roundId = store.createRound('你好');
const liveItems = store.buildTimelineItems();
const assistantText = (
items: ReturnType<typeof store.buildTimelineItems>,
) =>
items
.flatMap((item) =>
item.type === 'message' && item.role === 'assistant'
? item.parts
.filter((part) => part.type === 'text')
.map((part) => part.content)
: [],
)
.join('');
for (const delta of ['你', '好', '', '世界']) {
const event = store.recordEvent(roundId, {
domain: 'LLM',
payload: { delta },
type: 'MESSAGE',
});
if (!event) {
throw new Error('流式事件记录失败');
}
store.projectEvent(liveItems, roundId, event);
}
expect(store.currentVariant(roundId)?.runtimeEvents).toHaveLength(1);
expect(store.currentVariant(roundId)?.runtimeEvents[0]?.payload.delta).toBe(
'你好,世界',
);
expect(assistantText(liveItems)).toBe('你好,世界');
store.flush();
const restored = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
expect(assistantText(restored.buildTimelineItems())).toBe('你好,世界');
});
it('存在待持久化增量时结束事件仍立即落盘', () => {
const sessionId = 'terminal-persist';
const store = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
const roundId = store.createRound('结束测试');
store.recordEvent(roundId, {
domain: 'LLM',
payload: { reasoning: '思考中' },
type: 'THINKING',
});
store.recordEvent(roundId, {
domain: 'SYSTEM',
payload: {},
type: 'DONE',
});
const restored = useAgentTryoutRawRounds({
mode: 'draft',
sessionId,
});
expect(restored.currentVariant(roundId)?.status).toBe('completed');
expect(
restored
.currentVariant(roundId)
?.runtimeEvents.some(
(event) => event.domain === 'SYSTEM' && event.type === 'DONE',
),
).toBe(true);
});
}); });

View File

@@ -45,7 +45,7 @@ const STORAGE_VERSION = 1;
const MAX_ROUNDS = 50; const MAX_ROUNDS = 50;
const MAX_VARIANTS = 10; const MAX_VARIANTS = 10;
const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds'; const STORAGE_PREFIX = 'easyflow:agent-tryout-raw-rounds';
const PERSIST_DEBOUNCE_MS = 300; const PERSIST_DEBOUNCE_MS = 500;
const memorySessions = new Map<string, AgentTryoutRawRound[]>(); const memorySessions = new Map<string, AgentTryoutRawRound[]>();
function createRoundId() { function createRoundId() {
@@ -275,6 +275,45 @@ function selectedVariant(round: AgentTryoutRawRound) {
); );
} }
function streamingPayloadKey(event: AgentTryoutRuntimeEvent) {
if (event.domain !== 'LLM') {
return undefined;
}
let candidates: string[] = [];
if (event.type === 'MESSAGE') {
candidates = ['delta'];
} else if (event.type === 'THINKING') {
candidates = ['reasoning', 'delta', 'text'];
}
return candidates.find((key) => typeof event.payload[key] === 'string');
}
function appendRuntimeEvent(
variant: AgentTryoutRawVariant,
event: AgentTryoutRuntimeEvent,
) {
const previous = variant.runtimeEvents[variant.runtimeEvents.length - 1];
const currentKey = streamingPayloadKey(event);
const previousKey = previous ? streamingPayloadKey(previous) : undefined;
if (
previous &&
previous.domain === event.domain &&
previous.type === event.type &&
currentKey &&
currentKey === previousKey
) {
previous.payload = {
...previous.payload,
...event.payload,
[currentKey]:
asText(previous.payload[currentKey]) +
asText(event.payload[currentKey]),
};
return;
}
variant.runtimeEvents.push(event);
}
function visibleText(item: ChatTimelineMessageItem) { function visibleText(item: ChatTimelineMessageItem) {
return item.parts return item.parts
.filter((part) => part.type === 'text') .filter((part) => part.type === 'text')
@@ -529,6 +568,7 @@ function projectEventToTimeline(
payload.toolDisplayName ?? rawToolName ?? '工具', payload.toolDisplayName ?? rawToolName ?? '工具',
); );
const asyncTool = payload.asyncTool === true; const asyncTool = payload.asyncTool === true;
const taskInput = asRecord(payload.input ?? payload.toolInput);
ChatTimelineBuilder.upsertToolCall(items, { ChatTimelineBuilder.upsertToolCall(items, {
input: payload.input ?? payload.toolInput, input: payload.input ?? payload.toolInput,
output: asyncTool output: asyncTool
@@ -549,6 +589,12 @@ function projectEventToTimeline(
variantIndex, variantIndex,
'knowledge-retrieval', 'knowledge-retrieval',
), ),
sourceToolCallId: asyncTool
? asText(payload.sourceToolCallId ?? payload.source_tool_call_id)
: undefined,
taskId: asyncTool
? asText(payload.taskId ?? taskInput.taskId ?? taskInput.task_id)
: undefined,
toolCallId: asText( toolCallId: asText(
payload.toolCallId ?? payload.toolCallId ??
payload.taskId ?? payload.taskId ??
@@ -650,11 +696,6 @@ export function useAgentTryoutRawRounds(options: {
} }
function schedulePersist() { function schedulePersist() {
const key = storageKey(options.mode, options.sessionId);
memorySessions.set(
key,
[...rounds.values()].map((item) => clone(item)),
);
if (persistTimer) { if (persistTimer) {
return; return;
} }
@@ -737,7 +778,7 @@ export function useAgentTryoutRawRounds(options: {
payload: event.payload || {}, payload: event.payload || {},
type: event.type.toUpperCase(), type: event.type.toUpperCase(),
}; };
variant.runtimeEvents.push(runtimeEvent); appendRuntimeEvent(variant, runtimeEvent);
if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') { if (runtimeEvent.domain === 'SYSTEM' && runtimeEvent.type === 'DONE') {
variant.status = 'completed'; variant.status = 'completed';
round.status = 'completed'; round.status = 'completed';
@@ -754,9 +795,23 @@ export function useAgentTryoutRawRounds(options: {
runtimeEvent.domain === 'ERROR' runtimeEvent.domain === 'ERROR'
) { ) {
persistNow(); persistNow();
return; return runtimeEvent;
} }
schedulePersist(); schedulePersist();
return runtimeEvent;
}
function projectEvent(
items: ChatTimelineItem[],
roundId: string,
event: AgentTryoutRuntimeEvent,
) {
const round = rounds.get(roundId);
const variant = round && selectedVariant(round);
if (!round || !variant) {
return;
}
projectEventToTimeline(items, event, round.roundId, variant.variantIndex);
} }
function completeRound(roundId: string) { function completeRound(roundId: string) {
@@ -849,6 +904,7 @@ export function useAgentTryoutRawRounds(options: {
createRound, createRound,
currentVariant, currentVariant,
getPrompt, getPrompt,
projectEvent,
recordEvent, recordEvent,
regenerateRound, regenerateRound,
selectVariant, selectVariant,

View File

@@ -114,6 +114,7 @@ export function useAgentTryoutStream() {
const sessionChanged = activeSessionId !== sessionId; const sessionChanged = activeSessionId !== sessionId;
activeSessionId = sessionId; activeSessionId = sessionId;
if (!rawRounds || sessionChanged) { if (!rawRounds || sessionChanged) {
rawRounds?.flush();
rawRounds = useAgentTryoutRawRounds({ rawRounds = useAgentTryoutRawRounds({
mode: 'draft', mode: 'draft',
sessionId, sessionId,
@@ -174,12 +175,18 @@ export function useAgentTryoutStream() {
const payload = envelope.payload || {}; const payload = envelope.payload || {};
if (activeRoundId) { if (activeRoundId) {
rawRounds?.recordEvent(activeRoundId, { const runtimeEvent = rawRounds?.recordEvent(activeRoundId, {
domain, domain,
payload, payload,
type, type,
}); });
rebuildTimeline(); if (runtimeEvent) {
rawRounds?.projectEvent(
timelineItems.value,
activeRoundId,
runtimeEvent,
);
}
} }
if (domain === 'LLM' && type === 'MESSAGE') { if (domain === 'LLM' && type === 'MESSAGE') {
@@ -340,12 +347,23 @@ export function useAgentTryoutStream() {
finishStoppedRun(); finishStoppedRun();
} }
function dispose() {
if (loading.value) {
userStopped = true;
sseClient.abort();
finishStoppedRun();
return;
}
rawRounds?.flush();
}
return { return {
loading, loading,
clearDraftSession, clearDraftSession,
markToolApproving, markToolApproving,
markToolRejected, markToolRejected,
copyMessageText, copyMessageText,
dispose,
regenerateDraft, regenerateDraft,
selectVariant, selectVariant,
syncDraftContext, syncDraftContext,

View File

@@ -6,7 +6,7 @@ import type {
ChatTimelineToolApprovalPayload, ChatTimelineToolApprovalPayload,
} from './types'; } from './types';
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import ChatTimelineItem from './ChatTimelineItem.vue'; import ChatTimelineItem from './ChatTimelineItem.vue';
@@ -38,6 +38,15 @@ let preservedScrollTop: number | undefined;
const bottomThreshold = 24; const bottomThreshold = 24;
let scrollFrame = 0; let scrollFrame = 0;
const assistantActionAnchorIds = computed(() => {
const latestAssistantByRound = new Map<string, string>();
for (const item of props.items) {
if (item.type === 'message' && item.role === 'assistant' && item.roundId) {
latestAssistantByRound.set(item.roundId, item.id);
}
}
return new Set(latestAssistantByRound.values());
});
function isNearBottom(container: HTMLElement) { function isNearBottom(container: HTMLElement) {
return ( return (
@@ -85,26 +94,13 @@ function canRegenerateMessage(item: ChatTimelineItemType) {
return item.type === 'message' && (props.regenerable?.(item) ?? false); return item.type === 'message' && (props.regenerable?.(item) ?? false);
} }
function isAssistantActionAnchor( function isAssistantActionAnchor(item: ChatTimelineItemType) {
item: ChatTimelineItemType, return (
index: number, item.type === 'message' &&
items: ChatTimelineItemType[], item.role === 'assistant' &&
) { Boolean(item.roundId) &&
if (item.type !== 'message' || item.role !== 'assistant' || !item.roundId) { assistantActionAnchorIds.value.has(item.id)
return false; );
}
for (let cursor = items.length - 1; cursor >= 0; cursor -= 1) {
const current = items[cursor];
if (
current &&
current.type === 'message' &&
current.role === 'assistant' &&
current.roundId === item.roundId
) {
return cursor === index;
}
}
return false;
} }
function isVariantLoading(item: ChatTimelineItemType) { function isVariantLoading(item: ChatTimelineItemType) {
@@ -152,9 +148,9 @@ watch(
</div> </div>
<template v-else> <template v-else>
<ChatTimelineItem <ChatTimelineItem
v-for="(item, index) in items" v-for="item in items"
:key="item.id" :key="item.id"
:assistant-actions-visible="isAssistantActionAnchor(item, index, items)" :assistant-actions-visible="isAssistantActionAnchor(item)"
:item="item" :item="item"
:image-loader="imageLoader" :image-loader="imageLoader"
:approval-loading="approvalLoading" :approval-loading="approvalLoading"

View File

@@ -385,6 +385,69 @@ describe('chat timeline builder', () => {
} }
}); });
it('keeps async submit and polling lifecycle in the approval card', () => {
const items: ChatTimelineItem[] = [];
ChatTimelineBuilder.appendToolApproval(items, {
requestId: 'request-1',
resumeToken: 'resume-1',
toolCallId: 'submit-call-1',
toolName: '文档生成',
input: { user_input: '写一篇小作文' },
});
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: 'submit-call-1',
status: 'running',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: 'observe-call-1',
status: 'running',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
ChatTimelineBuilder.upsertToolCall(items, {
output: '文档生成已完成',
sourceToolCallId: 'result-call-1',
status: 'success',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
mode: 'approval',
status: 'success',
taskId: 'task-1',
toolCallId: 'task-1',
toolName: '文档生成',
type: 'tool',
});
});
it('keeps different async tasks as separate tool cards', () => {
const items: ChatTimelineItem[] = [];
for (const taskId of ['task-1', 'task-2']) {
ChatTimelineBuilder.upsertToolCall(items, {
sourceToolCallId: `${taskId}-observe`,
status: 'running',
taskId,
toolCallId: taskId,
toolName: '文档生成',
});
}
expect(items).toHaveLength(2);
expect(
items.map((item) => (item.type === 'tool' ? item.taskId : undefined)),
).toEqual(['task-1', 'task-2']);
});
it('marks approval tool rejected in the same card', () => { it('marks approval tool rejected in the same card', () => {
const items: ChatTimelineItem[] = []; const items: ChatTimelineItem[] = [];

View File

@@ -162,14 +162,26 @@ function finishAssistantMessage(
} }
} }
function findToolItem(items: ChatTimelineItem[], toolCallId?: string) { function findToolItem(
const identity = normalizeText(toolCallId).trim(); items: ChatTimelineItem[],
if (!identity) { toolCallId?: string,
taskId?: string,
sourceToolCallId?: string,
) {
const identities = new Set(
[toolCallId, sourceToolCallId]
.map((value) => normalizeText(value).trim())
.filter(Boolean),
);
const normalizedTaskId = normalizeText(taskId).trim();
if (identities.size === 0 && !normalizedTaskId) {
return undefined; return undefined;
} }
return items.find( return items.find(
(item): item is ChatTimelineToolItem => (item): item is ChatTimelineToolItem =>
item.type === 'tool' && item.toolCallId === identity, item.type === 'tool' &&
((Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) ||
(item.toolCallId ? identities.has(item.toolCallId) : false)),
); );
} }
@@ -251,7 +263,9 @@ function upsertTool(
rejectReason?: string; rejectReason?: string;
requestId?: string; requestId?: string;
resumeToken?: string; resumeToken?: string;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus; status?: ChatTimelineToolStatus;
taskId?: string;
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
}, },
@@ -259,7 +273,13 @@ function upsertTool(
const toolCallId = normalizeText( const toolCallId = normalizeText(
payload.toolCallId ?? payload.approval?.toolCallId, payload.toolCallId ?? payload.approval?.toolCallId,
).trim(); ).trim();
const found = findToolItem(items, toolCallId); const taskId = normalizeText(payload.taskId).trim();
const found = findToolItem(
items,
toolCallId,
taskId,
payload.sourceToolCallId,
);
const approval = payload.approval ?? found?.approval; const approval = payload.approval ?? found?.approval;
const mode = const mode =
payload.mode === 'approval' payload.mode === 'approval'
@@ -287,6 +307,7 @@ function upsertTool(
found.output = normalizePayloadValue(payload.output) ?? found.output; found.output = normalizePayloadValue(payload.output) ?? found.output;
found.rejectReason = payload.rejectReason ?? found.rejectReason; found.rejectReason = payload.rejectReason ?? found.rejectReason;
found.status = payload.status || found.status; found.status = payload.status || found.status;
found.taskId = taskId || found.taskId;
found.toolCallId = toolCallId || found.toolCallId; found.toolCallId = toolCallId || found.toolCallId;
found.toolName = toolName || found.toolName; found.toolName = toolName || found.toolName;
return found; return found;
@@ -304,6 +325,7 @@ function upsertTool(
rejectReason: payload.rejectReason, rejectReason: payload.rejectReason,
status: status:
payload.status || (mode === 'approval' ? 'pending_approval' : 'running'), payload.status || (mode === 'approval' ? 'pending_approval' : 'running'),
taskId: taskId || undefined,
toolCallId, toolCallId,
toolName: toolName || '工具调用', toolName: toolName || '工具调用',
type: 'tool', type: 'tool',
@@ -417,8 +439,10 @@ export const ChatTimelineBuilder = {
payload: { payload: {
input?: unknown; input?: unknown;
output?: unknown; output?: unknown;
sourceToolCallId?: string;
status?: ChatTimelineToolStatus; status?: ChatTimelineToolStatus;
statusKey?: string; statusKey?: string;
taskId?: string;
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
}, },

View File

@@ -89,6 +89,7 @@ export interface ChatTimelineToolItem extends ChatTimelineItemBase {
output?: unknown; output?: unknown;
rejectReason?: string; rejectReason?: string;
status: ChatTimelineToolStatus; status: ChatTimelineToolStatus;
taskId?: string;
toolCallId?: string; toolCallId?: string;
toolName: string; toolName: string;
type: 'tool'; type: 'tool';