@steerable/agent-shell-web 0.6.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +20 -0
- package/package.json +69 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/src/AppShell.tsx +24 -0
- package/src/assets/logo-generic.png +0 -0
- package/src/brand.test.ts +22 -0
- package/src/brand.ts +48 -0
- package/src/components/AgentSidebar.test.tsx +127 -0
- package/src/components/AgentSidebar.tsx +1010 -0
- package/src/components/ChatHeader.test.tsx +230 -0
- package/src/components/ChatHeader.tsx +436 -0
- package/src/components/DockPanelHeader.tsx +69 -0
- package/src/components/LocalLlmSettingsModal.test.tsx +372 -0
- package/src/components/LocalLlmSettingsModal.tsx +72 -0
- package/src/components/TerminalPanel.tsx +68 -0
- package/src/components/TerminalView.tsx +228 -0
- package/src/components/chat/ApprovalModal.test.tsx +83 -0
- package/src/components/chat/ApprovalModal.tsx +229 -0
- package/src/components/chat/AskUserModalHost.test.tsx +176 -0
- package/src/components/chat/AskUserModalHost.tsx +61 -0
- package/src/components/chat/AskUserQuestionMenu.tsx +621 -0
- package/src/components/chat/AssistantMessage.test.tsx +68 -0
- package/src/components/chat/AssistantMessage.tsx +469 -0
- package/src/components/chat/ChatInput.test.tsx +609 -0
- package/src/components/chat/ChatInput.tsx +2068 -0
- package/src/components/chat/ChatProjectBadge.tsx +362 -0
- package/src/components/chat/EmptyChat.tsx +18 -0
- package/src/components/chat/ExecPolicyPicker.test.tsx +26 -0
- package/src/components/chat/ExecPolicyPicker.tsx +120 -0
- package/src/components/chat/ExecutedActionsCard.tsx +216 -0
- package/src/components/chat/InterruptedTurnCard.tsx +53 -0
- package/src/components/chat/LocalChatPanel.tsx +408 -0
- package/src/components/chat/Markdown.test.tsx +170 -0
- package/src/components/chat/Markdown.tsx +432 -0
- package/src/components/chat/MessageList.tsx +332 -0
- package/src/components/chat/ModelPicker.test.tsx +149 -0
- package/src/components/chat/ModelPicker.tsx +289 -0
- package/src/components/chat/OrchestrationChildrenCard.tsx +40 -0
- package/src/components/chat/PlanTodoList.tsx +121 -0
- package/src/components/chat/ProjectTrustBanner.tsx +91 -0
- package/src/components/chat/SessionTreeModal.test.tsx +161 -0
- package/src/components/chat/SessionTreeModal.tsx +221 -0
- package/src/components/chat/StreamingStatus.tsx +95 -0
- package/src/components/chat/TaskOutcomeCards.test.tsx +97 -0
- package/src/components/chat/TaskOutcomeCards.tsx +128 -0
- package/src/components/chat/TaskPanelModal.test.tsx +198 -0
- package/src/components/chat/TaskPanelModal.tsx +310 -0
- package/src/components/chat/TaskProcessPanel.test.tsx +111 -0
- package/src/components/chat/TaskProcessPanel.tsx +208 -0
- package/src/components/chat/TurnProcessGroup.test.tsx +161 -0
- package/src/components/chat/TurnProcessGroup.tsx +193 -0
- package/src/components/chat/UnifiedDiff.tsx +31 -0
- package/src/components/chat/UserMessage.tsx +155 -0
- package/src/components/chat/elapsed.test.ts +42 -0
- package/src/components/chat/elapsed.ts +45 -0
- package/src/components/chat/executed-actions-model.test.ts +134 -0
- package/src/components/chat/executed-actions-model.ts +143 -0
- package/src/components/chat/orchestration-children-model.test.ts +115 -0
- package/src/components/chat/orchestration-children-model.ts +104 -0
- package/src/components/chat/timestamp.ts +24 -0
- package/src/components/chat/turn-timeline.test.ts +97 -0
- package/src/components/chat/turn-timeline.ts +132 -0
- package/src/components/chat/useChatTasks.test.ts +136 -0
- package/src/components/chat/useChatTasks.ts +134 -0
- package/src/components/chat/useCopy.ts +62 -0
- package/src/components/settings/AgentCapabilityFields.tsx +343 -0
- package/src/components/settings/AgentsSettingsPanel.test.tsx +229 -0
- package/src/components/settings/AgentsSettingsPanel.tsx +412 -0
- package/src/components/settings/DiagnoseSettingsPanel.tsx +119 -0
- package/src/components/settings/InsightsSettingsPanel.test.tsx +132 -0
- package/src/components/settings/InsightsSettingsPanel.tsx +309 -0
- package/src/components/settings/LlmSettingsPanel.tsx +875 -0
- package/src/components/settings/McpSettingsPanel.tsx +466 -0
- package/src/components/settings/ModelCapabilityChips.tsx +46 -0
- package/src/components/settings/ModelIdCombobox.test.tsx +88 -0
- package/src/components/settings/ModelIdCombobox.tsx +185 -0
- package/src/components/settings/SecuritySettingsPanel.test.tsx +122 -0
- package/src/components/settings/SecuritySettingsPanel.tsx +169 -0
- package/src/components/settings/SettingsSaveButton.tsx +35 -0
- package/src/components/settings/SkillsSettingsPanel.test.tsx +62 -0
- package/src/components/settings/SkillsSettingsPanel.tsx +334 -0
- package/src/components/settings/TelemetrySettingsPanel.tsx +173 -0
- package/src/components/settings/UsagePanel.tsx +196 -0
- package/src/components/settings/WebSearchSettingsPanel.test.tsx +90 -0
- package/src/components/settings/WebSearchSettingsPanel.tsx +183 -0
- package/src/components/settings/compat-flags-model.test.ts +71 -0
- package/src/components/settings/compat-flags-model.ts +57 -0
- package/src/components/settings/llm-vendors.test.ts +171 -0
- package/src/components/settings/llm-vendors.ts +275 -0
- package/src/components/settings/model-capabilities.test.ts +88 -0
- package/src/components/settings/model-capabilities.ts +123 -0
- package/src/components/settings/preset-choice-model.test.ts +96 -0
- package/src/components/settings/preset-choice-model.ts +75 -0
- package/src/fixtures/browser-dev-data.json +315 -0
- package/src/hooks/useChatsAndAgents.ts +182 -0
- package/src/layouts/AgentLayout.tsx +574 -0
- package/src/lib/attachments.ts +78 -0
- package/src/lib/browser-dev-electron-mock.ts +640 -0
- package/src/lib/chat-title.ts +33 -0
- package/src/lib/chat-transport.ts +346 -0
- package/src/lib/date-groups.ts +34 -0
- package/src/lib/demo-flag.ts +15 -0
- package/src/lib/electron-bridge.ts +270 -0
- package/src/lib/exec-policy.ts +25 -0
- package/src/lib/http-bridge.ts +293 -0
- package/src/lib/insights.ts +17 -0
- package/src/lib/local-api.ts +714 -0
- package/src/lib/pending-first-message.test.ts +17 -0
- package/src/lib/pending-first-message.ts +36 -0
- package/src/lib/slash-sources.ts +191 -0
- package/src/main.tsx +103 -0
- package/src/packs/registry.ts +64 -0
- package/src/pages/AgentPage.tsx +1226 -0
- package/src/pages/ChatPanelPreviewPage.tsx +264 -0
- package/src/pages/SettingsPage.test.tsx +102 -0
- package/src/pages/SettingsPage.tsx +197 -0
- package/src/styles/index.css +156 -0
- package/src/vite-env.d.ts +1 -0
- package/vite.base.ts +94 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UnifiedDiff — render a unified diff string (from local_edit_file's
|
|
3
|
+
* `result.diff`) with per-line colouring. Host-layer renderer plugged into
|
|
4
|
+
* ToolExecutionCard's `renderOutput` slot; the framework card stays generic.
|
|
5
|
+
*/
|
|
6
|
+
export function UnifiedDiff({ diff }: { diff: string }) {
|
|
7
|
+
const lines = diff.split('\n');
|
|
8
|
+
return (
|
|
9
|
+
<pre className="overflow-x-auto rounded bg-agent-muted/40 px-2 py-1.5 font-mono text-[11px] leading-relaxed">
|
|
10
|
+
{lines.map((line, i) => {
|
|
11
|
+
let cls = 'text-agent-foreground';
|
|
12
|
+
if (line.startsWith('@@')) {
|
|
13
|
+
cls = 'text-agent-muted-foreground';
|
|
14
|
+
} else if (line.startsWith('+++') || line.startsWith('---')) {
|
|
15
|
+
cls = 'font-semibold text-agent-muted-foreground';
|
|
16
|
+
} else if (line.startsWith('+')) {
|
|
17
|
+
cls = 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400';
|
|
18
|
+
} else if (line.startsWith('-')) {
|
|
19
|
+
cls = 'bg-rose-500/10 text-rose-700 dark:text-rose-400';
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<div key={i} className={cls}>
|
|
23
|
+
{line === '' ? ' ' : line}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
})}
|
|
27
|
+
</pre>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default UnifiedDiff;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { motion } from 'framer-motion';
|
|
3
|
+
import { LuChevronDown, LuChevronUp, LuCheck, LuCopy } from 'react-icons/lu';
|
|
4
|
+
import type { ChatMessage } from '@steerable/agent-protocol';
|
|
5
|
+
import type { LocalChat, LocalChatAgent } from '@/lib/local-api';
|
|
6
|
+
import { useSlashSources } from '@/lib/slash-sources';
|
|
7
|
+
import { Markdown } from './Markdown';
|
|
8
|
+
import { getFriendlyDate } from './timestamp';
|
|
9
|
+
import { useCopy } from './useCopy';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* UserMessage — port of `deeppath`'s user-bubble component, trimmed to the
|
|
13
|
+
* agent-only surface.
|
|
14
|
+
*
|
|
15
|
+
* Differences vs the original:
|
|
16
|
+
* - **No RefCards.** Local-backend messages don't carry the `messageMetadata.refs`
|
|
17
|
+
* array used by the cloud product to attach goal/task/event/document refs;
|
|
18
|
+
* when we eventually want this for agent (e.g. uploaded files), it'll go
|
|
19
|
+
* here.
|
|
20
|
+
* - **No edit button.** The cloud product's `editChatMessage` rewrites the
|
|
21
|
+
* SSE turn in place — we don't have that machinery yet. Tracking under
|
|
22
|
+
* phase 2c.
|
|
23
|
+
* - **No mentioned-agent chip / inline `ref://` / `tool://` / `agent://`
|
|
24
|
+
* parsing.** Agent-only mode doesn't surface multi-agent mentions or
|
|
25
|
+
* pinned-ref shortcuts, so the markdown content is rendered directly.
|
|
26
|
+
*
|
|
27
|
+
* What we KEEP from the old version (and why it matters for parity):
|
|
28
|
+
* - Left-aligned bubble (not right-aligned). The product chat layout stacks
|
|
29
|
+
* both user + assistant bubbles flush-left in a vertical column — this is
|
|
30
|
+
* the most visible "feel" difference vs a typical chat UI.
|
|
31
|
+
* - Bubble background uses the secondary muted token so it sits a notch
|
|
32
|
+
* above the canvas without competing with assistant bubbles.
|
|
33
|
+
* - 4.5em fold + gradient mask + 「展开全部 / 收起」 toggle so a wall-of-text
|
|
34
|
+
* user prompt doesn't steal the whole viewport before the assistant
|
|
35
|
+
* replies.
|
|
36
|
+
* - Friendly timestamp at bottom-right.
|
|
37
|
+
* - `framer-motion` fade/slide-in on mount.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface UserMessageProps {
|
|
41
|
+
message: ChatMessage;
|
|
42
|
+
agents?: LocalChatAgent[];
|
|
43
|
+
chats?: LocalChat[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function UserMessage({ message, agents = [], chats = [] }: UserMessageProps) {
|
|
47
|
+
const content = message.content || '';
|
|
48
|
+
// 发出去的消息里 `/技能` `/mcp__srv__tool` 要和输入框里同样渲染成工具卡片。
|
|
49
|
+
// 目录来自共享缓存,整屏消息只拉一次。
|
|
50
|
+
const { skills, mcpTools } = useSlashSources();
|
|
51
|
+
|
|
52
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
53
|
+
const [isOverflowing, setIsOverflowing] = useState(false);
|
|
54
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const { copied, copy } = useCopy(content);
|
|
56
|
+
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
const el = contentRef.current;
|
|
59
|
+
if (!el || isExpanded) return;
|
|
60
|
+
setIsOverflowing(el.scrollHeight > el.clientHeight + 1);
|
|
61
|
+
}, [content, isExpanded]);
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<motion.div
|
|
65
|
+
className="group/message mb-2"
|
|
66
|
+
data-message-role="user"
|
|
67
|
+
data-message-id={message.id}
|
|
68
|
+
initial={{ opacity: 0, y: 20 }}
|
|
69
|
+
animate={{ opacity: 1, y: 0 }}
|
|
70
|
+
transition={{ duration: 0.3 }}
|
|
71
|
+
>
|
|
72
|
+
<div className="flex justify-start">
|
|
73
|
+
<div className="mx-auto w-full max-w-[var(--chat-input-box-width)] rounded-lg border border-agent-border bg-agent-muted p-3 text-sm text-agent-foreground">
|
|
74
|
+
<div className="relative">
|
|
75
|
+
<div
|
|
76
|
+
ref={contentRef}
|
|
77
|
+
className={`markdown-content pl-1 ${
|
|
78
|
+
!isExpanded ? 'overflow-hidden' : ''
|
|
79
|
+
}`}
|
|
80
|
+
style={!isExpanded ? { maxHeight: '4.5em' } : undefined}
|
|
81
|
+
>
|
|
82
|
+
{content ? (
|
|
83
|
+
<Markdown
|
|
84
|
+
inlineParagraph
|
|
85
|
+
agents={agents}
|
|
86
|
+
chats={chats}
|
|
87
|
+
skills={skills}
|
|
88
|
+
mcpTools={mcpTools}
|
|
89
|
+
>
|
|
90
|
+
{content}
|
|
91
|
+
</Markdown>
|
|
92
|
+
) : (
|
|
93
|
+
<div className="h-4" />
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
{!isExpanded && isOverflowing && (
|
|
97
|
+
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-5 bg-gradient-to-t from-agent-muted to-transparent" />
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{isOverflowing && (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={(event) => {
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
setIsExpanded((prev) => !prev);
|
|
107
|
+
}}
|
|
108
|
+
className="mt-1 inline-flex items-center gap-0.5 text-xs text-agent-muted-foreground transition-colors hover:text-agent-foreground"
|
|
109
|
+
>
|
|
110
|
+
{isExpanded ? (
|
|
111
|
+
<>
|
|
112
|
+
收起
|
|
113
|
+
<LuChevronUp className="h-3 w-3" />
|
|
114
|
+
</>
|
|
115
|
+
) : (
|
|
116
|
+
<>
|
|
117
|
+
展开全部
|
|
118
|
+
<LuChevronDown className="h-3 w-3" />
|
|
119
|
+
</>
|
|
120
|
+
)}
|
|
121
|
+
</button>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<div className="mt-2 flex items-center justify-between">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
onClick={() => void copy()}
|
|
128
|
+
className="inline-flex items-center gap-0.5 rounded text-xs text-agent-muted-foreground opacity-0 transition-all duration-200 hover:text-agent-foreground focus:opacity-100 group-hover/message:opacity-100"
|
|
129
|
+
title={copied ? '已复制' : '复制消息'}
|
|
130
|
+
aria-label={copied ? '已复制' : '复制消息'}
|
|
131
|
+
>
|
|
132
|
+
{copied ? (
|
|
133
|
+
<>
|
|
134
|
+
<LuCheck className="h-3 w-3 text-emerald-600 dark:text-emerald-400" />
|
|
135
|
+
<span className="text-emerald-600 dark:text-emerald-400">
|
|
136
|
+
已复制
|
|
137
|
+
</span>
|
|
138
|
+
</>
|
|
139
|
+
) : (
|
|
140
|
+
<LuCopy className="h-3 w-3" />
|
|
141
|
+
)}
|
|
142
|
+
</button>
|
|
143
|
+
<div className="text-[13px] text-agent-muted-foreground">
|
|
144
|
+
{message.createdAt
|
|
145
|
+
? getFriendlyDate(new Date(message.createdAt))
|
|
146
|
+
: getFriendlyDate(new Date())}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</motion.div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default UserMessage;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
formatElapsedCompact,
|
|
4
|
+
inferDurationMs,
|
|
5
|
+
readPersistedDurationMs,
|
|
6
|
+
} from './elapsed';
|
|
7
|
+
|
|
8
|
+
describe('formatElapsedCompact', () => {
|
|
9
|
+
it('matches Codex compact elapsed', () => {
|
|
10
|
+
expect(formatElapsedCompact(0)).toBe('0s');
|
|
11
|
+
expect(formatElapsedCompact(999)).toBe('0s');
|
|
12
|
+
expect(formatElapsedCompact(1_000)).toBe('1s');
|
|
13
|
+
expect(formatElapsedCompact(12_000)).toBe('12s');
|
|
14
|
+
expect(formatElapsedCompact(59_000)).toBe('59s');
|
|
15
|
+
expect(formatElapsedCompact(60_000)).toBe('1m 00s');
|
|
16
|
+
expect(formatElapsedCompact(61_000)).toBe('1m 01s');
|
|
17
|
+
expect(formatElapsedCompact(83_000)).toBe('1m 23s');
|
|
18
|
+
expect(formatElapsedCompact(3_605_000)).toBe('1h 00m 05s');
|
|
19
|
+
expect(formatElapsedCompact(90_122_000)).toBe('25h 02m 02s');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('readPersistedDurationMs', () => {
|
|
24
|
+
it('reads a finite non-negative duration from assistant metadata', () => {
|
|
25
|
+
expect(readPersistedDurationMs(JSON.stringify({ durationMs: 83000 }))).toBe(83000);
|
|
26
|
+
expect(readPersistedDurationMs(JSON.stringify({ durationMs: 0 }))).toBe(0);
|
|
27
|
+
expect(readPersistedDurationMs(JSON.stringify({}))).toBeUndefined();
|
|
28
|
+
expect(readPersistedDurationMs(JSON.stringify({ durationMs: -1 }))).toBeUndefined();
|
|
29
|
+
expect(readPersistedDurationMs('{')).toBeUndefined();
|
|
30
|
+
expect(readPersistedDurationMs(null)).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('inferDurationMs', () => {
|
|
35
|
+
it('subtracts the user timestamp from the assistant timestamp', () => {
|
|
36
|
+
expect(
|
|
37
|
+
inferDurationMs('2026-09-08T12:00:00.000Z', '2026-09-08T12:01:23.000Z'),
|
|
38
|
+
).toBe(83_000);
|
|
39
|
+
expect(inferDurationMs('2026-09-08T12:00:00.000Z', '2026-09-08T11:59:00.000Z')).toBeUndefined();
|
|
40
|
+
expect(inferDurationMs(undefined, '2026-09-08T12:00:00.000Z')).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex-style compact elapsed time for the turn-process disclosure.
|
|
3
|
+
* Examples: 0s, 12s, 1m 00s, 1m 23s, 1h 02m 03s.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function formatElapsedCompact(elapsedMs: number): string {
|
|
7
|
+
const elapsedSecs = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
8
|
+
if (elapsedSecs < 60) return `${elapsedSecs}s`;
|
|
9
|
+
if (elapsedSecs < 3600) {
|
|
10
|
+
const minutes = Math.floor(elapsedSecs / 60);
|
|
11
|
+
const seconds = elapsedSecs % 60;
|
|
12
|
+
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
|
13
|
+
}
|
|
14
|
+
const hours = Math.floor(elapsedSecs / 3600);
|
|
15
|
+
const minutes = Math.floor((elapsedSecs % 3600) / 60);
|
|
16
|
+
const seconds = elapsedSecs % 60;
|
|
17
|
+
return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function readPersistedDurationMs(
|
|
21
|
+
metadataJson: string | null | undefined,
|
|
22
|
+
): number | undefined {
|
|
23
|
+
if (!metadataJson) return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(metadataJson) as { durationMs?: unknown };
|
|
26
|
+
if (typeof parsed.durationMs !== 'number' || !Number.isFinite(parsed.durationMs)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
return parsed.durationMs >= 0 ? parsed.durationMs : undefined;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wall-clock from a user prompt timestamp to the assistant reply timestamp. */
|
|
36
|
+
export function inferDurationMs(
|
|
37
|
+
startIso: string | undefined,
|
|
38
|
+
endIso: string | undefined,
|
|
39
|
+
): number | undefined {
|
|
40
|
+
if (!startIso || !endIso) return undefined;
|
|
41
|
+
const start = Date.parse(startIso);
|
|
42
|
+
const end = Date.parse(endIso);
|
|
43
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return undefined;
|
|
44
|
+
return end - start;
|
|
45
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
expandRunCodeActions,
|
|
4
|
+
summarizeRunCodeAction,
|
|
5
|
+
summarizeWebAction,
|
|
6
|
+
} from './executed-actions-model';
|
|
7
|
+
|
|
8
|
+
// W5-2 工具卡中文摘要的纯映射层测试:web_search 出查询词与结果计数,
|
|
9
|
+
// web_fetch 出短 URL、HTTP 状态、体量与截断标记;失败时只出意图头,
|
|
10
|
+
// 不编造结果字段。
|
|
11
|
+
|
|
12
|
+
describe('summarizeWebAction / web_search', () => {
|
|
13
|
+
it('成功时出查询词与结果计数', () => {
|
|
14
|
+
const summary = summarizeWebAction(
|
|
15
|
+
'web_search',
|
|
16
|
+
{ query: 'KV cache 量化' },
|
|
17
|
+
{ success: true, data: { result_count: 8, query: 'KV cache 量化', results: [] } },
|
|
18
|
+
);
|
|
19
|
+
expect(summary).toBe('搜索“KV cache 量化” → 8 条结果');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('长查询词截断到 40 字符', () => {
|
|
23
|
+
const summary = summarizeWebAction(
|
|
24
|
+
'web_search',
|
|
25
|
+
{ query: 'a'.repeat(60) },
|
|
26
|
+
{ success: true, data: { result_count: 1 } },
|
|
27
|
+
);
|
|
28
|
+
expect(summary).toBe(`搜索“${'a'.repeat(37)}…” → 1 条结果`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('失败时只出意图头,不编造计数', () => {
|
|
32
|
+
const summary = summarizeWebAction(
|
|
33
|
+
'web_search',
|
|
34
|
+
{ query: 'x' },
|
|
35
|
+
{ success: false, error: 'web search timed out: ReadTimeout' },
|
|
36
|
+
);
|
|
37
|
+
expect(summary).toBe('搜索“x”');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('data 缺失(被截断吃掉)时退化为意图头', () => {
|
|
41
|
+
expect(summarizeWebAction('web_search', { query: 'x' }, { success: true })).toBe(
|
|
42
|
+
'搜索“x”',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('summarizeWebAction / web_fetch', () => {
|
|
48
|
+
it('成功时出短 URL、状态码与体量', () => {
|
|
49
|
+
const summary = summarizeWebAction(
|
|
50
|
+
'web_fetch',
|
|
51
|
+
{ url: 'https://example.com/docs/spec.html' },
|
|
52
|
+
{
|
|
53
|
+
success: true,
|
|
54
|
+
data: {
|
|
55
|
+
url: 'https://example.com/docs/spec.html',
|
|
56
|
+
status: 200,
|
|
57
|
+
bytes: 12_288,
|
|
58
|
+
truncated: false,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
expect(summary).toBe('抓取 example.com/docs/spec.html → 200 · 12.0 KB');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('截断的抓取带"已截断"标记', () => {
|
|
66
|
+
const summary = summarizeWebAction(
|
|
67
|
+
'web_fetch',
|
|
68
|
+
{ url: 'https://example.com/big' },
|
|
69
|
+
{ success: true, data: { status: 200, bytes: 1_000_000, truncated: true } },
|
|
70
|
+
);
|
|
71
|
+
expect(summary).toBe('抓取 example.com/big → 200 · 976.6 KB · 已截断');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('失败时只出意图头(SSRF 拒绝 / 超时 / 跨域重定向)', () => {
|
|
75
|
+
const summary = summarizeWebAction(
|
|
76
|
+
'web_fetch',
|
|
77
|
+
{ url: 'http://169.254.169.254/latest' },
|
|
78
|
+
{ success: false, error: 'refusing to fetch a non-public address' },
|
|
79
|
+
);
|
|
80
|
+
expect(summary).toBe('抓取 169.254.169.254/latest');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('无法解析的 URL 原样截断展示', () => {
|
|
84
|
+
const summary = summarizeWebAction(
|
|
85
|
+
'web_fetch',
|
|
86
|
+
{ url: 'not a url at all' },
|
|
87
|
+
{ success: false, error: 'unsupported scheme' },
|
|
88
|
+
);
|
|
89
|
+
expect(summary).toBe('抓取 not a url at all');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('summarizeWebAction / 其他工具', () => {
|
|
94
|
+
it('非 web 工具返回 null(调用方回落通用摘要)', () => {
|
|
95
|
+
expect(
|
|
96
|
+
summarizeWebAction('local_exec_shell', { command: 'ls' }, { success: true }),
|
|
97
|
+
).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('summarizeRunCodeAction / expandRunCodeActions', () => {
|
|
102
|
+
it('摘要写出描述和内层工具数,而不是整段 code', () => {
|
|
103
|
+
const summary = summarizeRunCodeAction(
|
|
104
|
+
'run_code',
|
|
105
|
+
{ code: 'return tools.call("stub_a")', description: 'two stubs' },
|
|
106
|
+
{
|
|
107
|
+
success: true,
|
|
108
|
+
data: {
|
|
109
|
+
calls: [
|
|
110
|
+
{ tool: 'stub_a', arguments: {}, result: { success: true } },
|
|
111
|
+
{ tool: 'stub_b', arguments: {}, result: { success: true } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
expect(summary).toBe('程序「two stubs」· 2 个内层工具');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('展开为程序行 + 内层工具行', () => {
|
|
120
|
+
const rows = expandRunCodeActions([
|
|
121
|
+
{
|
|
122
|
+
tool: 'run_code',
|
|
123
|
+
arguments: { description: 'two stubs' },
|
|
124
|
+
result: {
|
|
125
|
+
success: true,
|
|
126
|
+
data: {
|
|
127
|
+
calls: [{ tool: 'stub_a', arguments: { x: 1 }, result: { success: true } }],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
expect(rows.map((r) => r.tool)).toEqual(['run_code', 'stub_a']);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutedActionsCard 的纯函数半侧(W5-2):web_search / web_fetch 的
|
|
3
|
+
* 中文摘要。从 action.arguments / action.result 提取关键信息,让工具卡
|
|
4
|
+
* 一眼可读(查了什么、抓了什么、几条结果、HTTP 状态),而不是甩原始 JSON。
|
|
5
|
+
*
|
|
6
|
+
* result 的形态:sidecar ToolResult `{ success, data?, error? }`;桌面
|
|
7
|
+
* CoreLoop 路径上 data 可能被 context-compactor 截断过大字段,但
|
|
8
|
+
* result_count / status / bytes / truncated 这些小字段原样保留。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface WebToolResultData {
|
|
12
|
+
result_count?: unknown;
|
|
13
|
+
status?: unknown;
|
|
14
|
+
bytes?: unknown;
|
|
15
|
+
truncated?: unknown;
|
|
16
|
+
url?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resultData(result: unknown): WebToolResultData | null {
|
|
20
|
+
if (!result || typeof result !== 'object') return null;
|
|
21
|
+
const data = (result as { data?: unknown }).data;
|
|
22
|
+
return data && typeof data === 'object' ? (data as WebToolResultData) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shortUrl(raw: unknown): string | null {
|
|
26
|
+
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(raw);
|
|
29
|
+
const path = u.pathname === '/' ? '' : u.pathname;
|
|
30
|
+
const text = `${u.host}${path}`;
|
|
31
|
+
return text.length > 48 ? `${text.slice(0, 45)}…` : text;
|
|
32
|
+
} catch {
|
|
33
|
+
return raw.length > 48 ? `${raw.slice(0, 45)}…` : raw;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatBytes(n: unknown): string | null {
|
|
38
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
|
|
39
|
+
if (n < 1024) return `${n} B`;
|
|
40
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* web_search / web_fetch 的卡片摘要;非这两个工具返回 null(调用方回落到
|
|
45
|
+
* 通用参数摘要)。
|
|
46
|
+
*/
|
|
47
|
+
export function summarizeWebAction(
|
|
48
|
+
tool: string,
|
|
49
|
+
args: unknown,
|
|
50
|
+
result: unknown,
|
|
51
|
+
): string | null {
|
|
52
|
+
const obj =
|
|
53
|
+
args && typeof args === 'object' ? (args as Record<string, unknown>) : {};
|
|
54
|
+
const data = resultData(result);
|
|
55
|
+
const failed =
|
|
56
|
+
result &&
|
|
57
|
+
typeof result === 'object' &&
|
|
58
|
+
(result as { success?: unknown }).success === false;
|
|
59
|
+
|
|
60
|
+
if (tool === 'web_search') {
|
|
61
|
+
const query = typeof obj.query === 'string' ? obj.query : '';
|
|
62
|
+
const head = `搜索“${query.length > 40 ? `${query.slice(0, 37)}…` : query}”`;
|
|
63
|
+
if (failed || !data) return head;
|
|
64
|
+
const count = typeof data.result_count === 'number' ? data.result_count : null;
|
|
65
|
+
return count === null ? head : `${head} → ${count} 条结果`;
|
|
66
|
+
}
|
|
67
|
+
if (tool === 'web_fetch') {
|
|
68
|
+
const head = `抓取 ${shortUrl(obj.url) ?? '未知地址'}`;
|
|
69
|
+
if (failed || !data) return head;
|
|
70
|
+
const parts: string[] = [];
|
|
71
|
+
if (typeof data.status === 'number') parts.push(String(data.status));
|
|
72
|
+
const size = formatBytes(data.bytes);
|
|
73
|
+
if (size) parts.push(size);
|
|
74
|
+
if (data.truncated === true) parts.push('已截断');
|
|
75
|
+
return parts.length > 0 ? `${head} → ${parts.join(' · ')}` : head;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface RunCodeCall {
|
|
81
|
+
tool?: unknown;
|
|
82
|
+
arguments?: unknown;
|
|
83
|
+
result?: unknown;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* run_code 的卡片摘要:描述 + 内层工具次数,而不是整段程序 JSON。
|
|
88
|
+
*/
|
|
89
|
+
export function summarizeRunCodeAction(
|
|
90
|
+
tool: string,
|
|
91
|
+
args: unknown,
|
|
92
|
+
result: unknown,
|
|
93
|
+
): string | null {
|
|
94
|
+
if (tool !== 'run_code') return null;
|
|
95
|
+
const obj =
|
|
96
|
+
args && typeof args === 'object' ? (args as Record<string, unknown>) : {};
|
|
97
|
+
const description =
|
|
98
|
+
typeof obj.description === 'string' && obj.description.trim()
|
|
99
|
+
? obj.description.trim()
|
|
100
|
+
: '程序';
|
|
101
|
+
const head =
|
|
102
|
+
description.length > 40 ? `${description.slice(0, 37)}…` : description;
|
|
103
|
+
const data = resultData(result);
|
|
104
|
+
const calls = Array.isArray((data as { calls?: unknown } | null)?.calls)
|
|
105
|
+
? ((data as { calls: RunCodeCall[] }).calls)
|
|
106
|
+
: [];
|
|
107
|
+
const failed =
|
|
108
|
+
result &&
|
|
109
|
+
typeof result === 'object' &&
|
|
110
|
+
(result as { success?: unknown }).success === false;
|
|
111
|
+
if (failed) return `程序「${head}」失败`;
|
|
112
|
+
return calls.length > 0
|
|
113
|
+
? `程序「${head}」· ${calls.length} 个内层工具`
|
|
114
|
+
: `程序「${head}」`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ExecutedActionLike {
|
|
118
|
+
tool: string;
|
|
119
|
+
arguments?: unknown;
|
|
120
|
+
result?: unknown;
|
|
121
|
+
sandbox?: { backend?: string; enforcement: string };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Flatten a run_code action into the outer program plus inner tool rows. */
|
|
125
|
+
export function expandRunCodeActions<T extends ExecutedActionLike>(actions: T[]): T[] {
|
|
126
|
+
const out: T[] = [];
|
|
127
|
+
for (const action of actions) {
|
|
128
|
+
out.push(action);
|
|
129
|
+
if (action.tool !== 'run_code') continue;
|
|
130
|
+
const data = resultData(action.result) as { calls?: RunCodeCall[] } | null;
|
|
131
|
+
const calls = Array.isArray(data?.calls) ? data.calls : [];
|
|
132
|
+
for (const call of calls) {
|
|
133
|
+
const name = typeof call.tool === 'string' ? call.tool : 'tool';
|
|
134
|
+
out.push({
|
|
135
|
+
...action,
|
|
136
|
+
tool: name,
|
|
137
|
+
arguments: call.arguments,
|
|
138
|
+
result: call.result,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
childrenToCardModel,
|
|
4
|
+
foldOrchestrationChildEvents,
|
|
5
|
+
type ChildInfo,
|
|
6
|
+
} from './orchestration-children-model';
|
|
7
|
+
|
|
8
|
+
// OrchestrationChildrenCard 的纯映射层测试:agent.child 生命周期事件累积出的
|
|
9
|
+
// ChildInfo 列表 → OrchestrationPlanCard 的 payload + taskStatuses。卡片本身
|
|
10
|
+
// 的点击展开/收起交互由框架 agent-ui 的 cards.test.tsx 覆盖;桌面端到端
|
|
11
|
+
// (真实 Electron + CDP 模拟用户操作)由 scripts/desktop-canary.mjs 覆盖。
|
|
12
|
+
|
|
13
|
+
describe('childrenToCardModel / P3.1 编排卡片映射', () => {
|
|
14
|
+
it('空列表产出空任务面', () => {
|
|
15
|
+
const { payload, taskStatuses } = childrenToCardModel([]);
|
|
16
|
+
expect(payload.tasks).toEqual([]);
|
|
17
|
+
expect(taskStatuses).toEqual({});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('spawn 的子代理进 tasks,id/agentId 用 lineage childId', () => {
|
|
21
|
+
const children: ChildInfo[] = [
|
|
22
|
+
{ childId: '0.1', task: '扫日志', depth: 1, status: 'running' },
|
|
23
|
+
{ childId: '0.2', task: '读配置', depth: 1, status: 'running' },
|
|
24
|
+
];
|
|
25
|
+
const { payload } = childrenToCardModel(children);
|
|
26
|
+
expect(payload.mode).toBe('parallel');
|
|
27
|
+
expect(payload.tasks).toEqual([
|
|
28
|
+
{ id: '0.1', agentId: '0.1', prompt: '扫日志' },
|
|
29
|
+
{ id: '0.2', agentId: '0.2', prompt: '读配置' },
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('生命周期状态映射到卡片状态点', () => {
|
|
34
|
+
const children: ChildInfo[] = [
|
|
35
|
+
{ childId: '0.1', status: 'running' },
|
|
36
|
+
{ childId: '0.2', status: 'completed' },
|
|
37
|
+
{ childId: '0.3', status: 'failed' },
|
|
38
|
+
{ childId: '0.4', status: 'cancelled' },
|
|
39
|
+
{ childId: '0.5', status: 'interrupted' },
|
|
40
|
+
];
|
|
41
|
+
const { taskStatuses } = childrenToCardModel(children);
|
|
42
|
+
expect(taskStatuses).toEqual({
|
|
43
|
+
'0.1': 'running',
|
|
44
|
+
'0.2': 'ok',
|
|
45
|
+
'0.3': 'failed',
|
|
46
|
+
'0.4': 'skipped',
|
|
47
|
+
// interrupted 是可恢复的暂停,不是终态
|
|
48
|
+
'0.5': 'pending',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('未知状态退化为 pending;缺 task 时 prompt 为空串', () => {
|
|
53
|
+
const { payload, taskStatuses } = childrenToCardModel([
|
|
54
|
+
{ childId: '0.1', status: 'mystery' },
|
|
55
|
+
]);
|
|
56
|
+
expect(taskStatuses['0.1']).toBe('pending');
|
|
57
|
+
expect(payload.tasks[0].prompt).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('delegate 子代理的 agentId 显示 profile 名(delegate-on-pool)', () => {
|
|
61
|
+
// delegate_subagent 的 child_spawned 事件带 profile;卡片把 profile
|
|
62
|
+
// 作为执行者名显示,无 profile 的编排子代理仍退回 lineage childId。
|
|
63
|
+
const children: ChildInfo[] = [
|
|
64
|
+
{ childId: '0.1', task: '扫日志', profile: 'researcher', status: 'running' },
|
|
65
|
+
{ childId: '0.2', task: '读配置', profile: 'general-purpose', status: 'completed' },
|
|
66
|
+
{ childId: '0.3', task: '六件套子代理', status: 'running' },
|
|
67
|
+
];
|
|
68
|
+
const { payload } = childrenToCardModel(children);
|
|
69
|
+
expect(payload.tasks).toEqual([
|
|
70
|
+
{ id: '0.1', agentId: 'researcher', prompt: '扫日志' },
|
|
71
|
+
{ id: '0.2', agentId: 'general-purpose', prompt: '读配置' },
|
|
72
|
+
{ id: '0.3', agentId: '0.3', prompt: '六件套子代理' },
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('foldOrchestrationChildEvents / 切回重建子代理列表', () => {
|
|
78
|
+
it('空事件流产出空列表', () => {
|
|
79
|
+
expect(foldOrchestrationChildEvents([])).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('spawn → completed → resumed 折叠为终态/恢复态', () => {
|
|
83
|
+
const list = foldOrchestrationChildEvents([
|
|
84
|
+
{ kind: 'child_spawned', childId: '0.1', task: '扫日志', depth: 1 },
|
|
85
|
+
{ kind: 'child_completed', childId: '0.1', status: 'completed' },
|
|
86
|
+
{ kind: 'child_resumed', childId: '0.1', status: 'running' },
|
|
87
|
+
]);
|
|
88
|
+
expect(list).toEqual([{ childId: '0.1', task: '扫日志', depth: 1, status: 'running' }]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('未 spawn 就出现的终端事件被跳过', () => {
|
|
92
|
+
expect(
|
|
93
|
+
foldOrchestrationChildEvents([{ kind: 'child_failed', childId: 'ghost' }]),
|
|
94
|
+
).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('重复 spawn 幂等,不会产生重复条目', () => {
|
|
98
|
+
const list = foldOrchestrationChildEvents([
|
|
99
|
+
{ kind: 'child_spawned', childId: '0.1', task: 'A' },
|
|
100
|
+
{ kind: 'child_spawned', childId: '0.1', task: 'A' },
|
|
101
|
+
]);
|
|
102
|
+
expect(list).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('非法条目(缺 kind/childId)被忽略', () => {
|
|
106
|
+
// 刻意传入非结构化条目验证鲁棒性——运行时可从 JSON 收到任意形状。
|
|
107
|
+
const list = foldOrchestrationChildEvents([
|
|
108
|
+
{ childId: '0.1' },
|
|
109
|
+
{ kind: 'child_spawned' },
|
|
110
|
+
null,
|
|
111
|
+
42,
|
|
112
|
+
] as unknown as Parameters<typeof foldOrchestrationChildEvents>[0]);
|
|
113
|
+
expect(list).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|