@kingsoftcloud/agentkit-harness-ui 0.0.0-stage → 0.0.1
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 +201 -0
- package/NOTICE +116 -0
- package/README.md +292 -2
- package/markdown/build/build-markdown.cjs +66 -0
- package/markdown/dist/THIRD_PARTY_LICENSES.txt +2993 -0
- package/markdown/dist/markdown.css +1 -0
- package/markdown/dist/markdown.mjs +325 -0
- package/markdown/source/markdown-factory.mjs +613 -0
- package/markdown/source/markdown.css +288 -0
- package/markdown/source/table-factory.mjs +1588 -0
- package/markdown/source/table.css +487 -0
- package/package.json +96 -4
- package/src/activity-groups.mjs +74 -0
- package/src/browser-response-groups.mjs +57 -0
- package/src/command-output.mjs +57 -0
- package/src/components.mjs +9 -0
- package/src/context-markers.mjs +19 -0
- package/src/context-status.mjs +34 -0
- package/src/diff.mjs +240 -0
- package/src/explore-tool-call.mjs +139 -0
- package/src/file-preview.mjs +50 -0
- package/src/host/native-a2a.mjs +90 -0
- package/src/host/native-child-file-pane.mjs +49 -0
- package/src/host/native-child-pane.mjs +94 -0
- package/src/host/native-command-pane.mjs +64 -0
- package/src/host/native-run-panel.mjs +202 -0
- package/src/host/session-task-events.mjs +247 -0
- package/src/host/state/native-a2a.mjs +122 -0
- package/src/host/state/native-child-observation.mjs +35 -0
- package/src/host/state/native-children.mjs +93 -0
- package/src/host/state/native-outcome.mjs +35 -0
- package/src/host/state/native-recovery.mjs +187 -0
- package/src/host/state/native-runtime-route.mjs +26 -0
- package/src/host/state/native-session-stop.mjs +40 -0
- package/src/host/state/native-status-panel.mjs +23 -0
- package/src/host/state/native-task-read-model.mjs +89 -0
- package/src/host/state/task-links.mjs +40 -0
- package/src/host/task-timeline.mjs +185 -0
- package/src/index.mjs +52 -0
- package/src/input-preview.mjs +44 -0
- package/src/inspector.mjs +40 -0
- package/src/project.mjs +132 -0
- package/src/protocol.mjs +362 -0
- package/src/react-binding.mjs +486 -0
- package/src/reducer.mjs +355 -0
- package/src/renderers.mjs +644 -0
- package/src/status-panel.mjs +1156 -0
- package/src/styles.mjs +278 -0
- package/src/summary-playback.mjs +39 -0
- package/src/summary.mjs +160 -0
- package/src/terminal.mjs +157 -0
- package/src/tool-detail.mjs +208 -0
- package/src/tool-error.mjs +33 -0
- package/src/tool-identity.mjs +40 -0
- package/src/view.mjs +364 -0
- package/src/work-segments.mjs +41 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — public entry
|
|
2
|
+
//
|
|
3
|
+
// Re-exports the stable public surface. Hosts that want only the pure logic
|
|
4
|
+
// (no React) should import from the subpaths (./protocol, ./reducer, ./view,
|
|
5
|
+
// etc.) to avoid pulling React into their bundle. Importing from "." pulls
|
|
6
|
+
// the React binding as well.
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
PROTOCOL_VERSION, EventType, TaskState, TaskOutcome, ToolStatus, DisplayKind,
|
|
10
|
+
TASK_TRANSITIONS, normalizeEvent, canTransition, isTaskState, isToolStatus, isEventType, isDisplayKind,
|
|
11
|
+
} from "./protocol.mjs";
|
|
12
|
+
|
|
13
|
+
export { initialState, reduce, reduceAll } from "./reducer.mjs";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
visibleItems, isStreaming, isWaitingForFirstToken, isTerminal,
|
|
17
|
+
planProgress, activeTool, failedTools, taskStatusLabel, DEFAULT_LABELS,
|
|
18
|
+
} from "./project.mjs";
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
latestNonEmptyLine, truncateText, redactValue, boundedDetailLines,
|
|
22
|
+
SummaryQueue, SENSITIVE_KEYS,
|
|
23
|
+
} from "./summary.mjs";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
toolTitle, toolDetailLines, workspaceRelative, fileDiffPreview, redactError,
|
|
27
|
+
MAX_TOOL_DETAIL_LINES,
|
|
28
|
+
} from "./tool-detail.mjs";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
countChangeStat, patchFilePath, inferOperationKind, parseDiffHunks, diffPreviewLines,
|
|
32
|
+
MAX_DIFF_PREVIEW_LINES,
|
|
33
|
+
} from "./diff.mjs";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
extractCommand, extractOutput, sanitizeAnsi, normalizeCwd,
|
|
37
|
+
} from "./terminal.mjs";
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
renderTool, toolRendererKind,
|
|
41
|
+
} from "./renderers.mjs";
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
buildViewModel, h, isElement, ELEMENT, DEFAULT_VIEW_OPTIONS,
|
|
45
|
+
} from "./view.mjs";
|
|
46
|
+
|
|
47
|
+
export { styles } from "./styles.mjs";
|
|
48
|
+
export { toolIdentity, toolConversationVisible, localizedToolTitle } from './tool-identity.mjs';
|
|
49
|
+
|
|
50
|
+
// React binding. Imported lazily-safe only because this barrel re-exports it;
|
|
51
|
+
// hosts that do not need React should import the subpaths directly.
|
|
52
|
+
export { HarnessTimeline } from "./components.mjs";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Presentation only. A partial argument prefix is never an executable call.
|
|
2
|
+
const FIELDS = new Set(['path', 'file_path', 'filePath', 'filename', 'command', 'content', 'new_text', 'old_text', 'pattern', 'query']);
|
|
3
|
+
const MAX_PREFIX = 128 * 1024;
|
|
4
|
+
export function previewToolInput(raw) {
|
|
5
|
+
const text = String(raw ?? '').slice(0, MAX_PREFIX), input = {};
|
|
6
|
+
let i = 0;
|
|
7
|
+
const space = () => { while (/\s/.test(text[i] ?? '') && i < text.length) i++; };
|
|
8
|
+
const string = partial => {
|
|
9
|
+
if (text[i++] !== '"') return;
|
|
10
|
+
const start = i; let escaped = false;
|
|
11
|
+
while (i < text.length) {
|
|
12
|
+
const ch = text[i++];
|
|
13
|
+
if (escaped) { escaped = false; continue; }
|
|
14
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
15
|
+
if (ch === '"') { try { return JSON.parse(text.slice(start - 1, i)); } catch { return; } }
|
|
16
|
+
}
|
|
17
|
+
if (!partial) return;
|
|
18
|
+
const prefix = text.slice(start).replace(/\\u[0-9a-fA-F]{0,3}$/, '').replace(/\\$/, '');
|
|
19
|
+
try { return JSON.parse(`"${prefix}"`); } catch { return; }
|
|
20
|
+
};
|
|
21
|
+
space(); if (text[i++] !== '{') return input;
|
|
22
|
+
while (i < text.length) {
|
|
23
|
+
space(); if (text[i] === '}') break;
|
|
24
|
+
const key = string(false); if (key === undefined) break;
|
|
25
|
+
space(); if (text[i++] !== ':') break; space();
|
|
26
|
+
if (text[i] === '"') {
|
|
27
|
+
const value = string(true);
|
|
28
|
+
if (FIELDS.has(key) && value !== undefined) input[key] = value.slice(0, key === 'content' || key.endsWith('_text') ? 8000 : 2000);
|
|
29
|
+
} else {
|
|
30
|
+
let depth = 0, quoted = false, escaped = false;
|
|
31
|
+
while (i < text.length) {
|
|
32
|
+
const ch = text[i];
|
|
33
|
+
if (!quoted && depth === 0 && (ch === ',' || ch === '}')) break;
|
|
34
|
+
i++;
|
|
35
|
+
if (quoted) { if (escaped) escaped = false; else if (ch === '\\') escaped = true; else if (ch === '"') quoted = false; }
|
|
36
|
+
else if (ch === '"') quoted = true;
|
|
37
|
+
else if (ch === '[' || ch === '{') depth++;
|
|
38
|
+
else if (ch === ']' || ch === '}') depth--;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
space(); if (text[i++] !== ',') break;
|
|
42
|
+
}
|
|
43
|
+
return input;
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { redactValue, truncateText } from './summary.mjs';
|
|
2
|
+
|
|
3
|
+
const bounded = value => {
|
|
4
|
+
let parsed = value;
|
|
5
|
+
if (typeof value === 'string') { try { parsed = JSON.parse(value); } catch { /* text observation */ } }
|
|
6
|
+
const text = typeof parsed === 'string' ? parsed : JSON.stringify(parsed, (key, value) => redactValue(key, value), 2);
|
|
7
|
+
return truncateText(text.slice(0, 24000), { maxChars: 4000, maxLines: 500 });
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** The host selects a committed call; the package owns the read-only dialog. */
|
|
11
|
+
export function createToolInspector(React) {
|
|
12
|
+
const { createElement: h, useEffect, useRef } = React;
|
|
13
|
+
return function ToolInspector({ item, onClose, labels = {} }) {
|
|
14
|
+
const ref = useRef(null);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const dialog = ref.current;
|
|
17
|
+
if (!item || !dialog) return;
|
|
18
|
+
dialog.showModal?.();
|
|
19
|
+
return () => { if (dialog.open) dialog.close?.(); };
|
|
20
|
+
}, [item?.callId]);
|
|
21
|
+
if (!item) return null;
|
|
22
|
+
return h('dialog', { ref, className: 'harness-tool-inspector', 'aria-label': labels.toolDetails || 'Tool details',
|
|
23
|
+
onCancel: event => { event.preventDefault(); onClose(); },
|
|
24
|
+
style: { color: 'inherit', background: 'var(--harness-bg, Canvas)', border: '1px solid color-mix(in srgb, currentColor 20%, transparent)',
|
|
25
|
+
borderRadius: '10px', padding: '20px', width: 'min(720px, calc(100vw - 40px))', maxHeight: '85vh', overflow: 'auto' } },
|
|
26
|
+
h('style', null, '.harness-tool-inspector::backdrop{background:rgb(0 0 0 / .35)}.harness-tool-inspector pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.6}'),
|
|
27
|
+
h('header', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
|
|
28
|
+
h('strong', null, item.name), h('button', { type: 'button', onClick: onClose, autoFocus: true }, labels.close || 'Close')),
|
|
29
|
+
h('p', null, `${labels.state || 'State'}: ${item.status}`),
|
|
30
|
+
h('h4', null, labels.arguments || 'Arguments'), h('pre', null, bounded(item.input ?? {})),
|
|
31
|
+
h('h4', null, labels.result || 'Result'),
|
|
32
|
+
h('pre', null, item.error ? bounded(item.error) : item.result != null ? bounded(item.result)
|
|
33
|
+
: labels.noSettledResult || 'No settled result is available'),
|
|
34
|
+
item.display?.kind === 'file_edit' ? h(React.Fragment, null,
|
|
35
|
+
h('h4', null, labels.appliedChanges || 'Applied changes'),
|
|
36
|
+
...item.display.files.map(file => h('section', { key: file.path }, h('strong', null, file.path),
|
|
37
|
+
h('pre', null, file.patch || labels.diffUnavailable || 'No diff preview is available'),
|
|
38
|
+
file.truncated ? h('p', null, labels.diffTruncated || 'Diff preview is truncated') : null))) : null);
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { contextTimelineItems } from './context-markers.mjs';
|
|
2
|
+
// @kingsoftcloud/agentkit-harness-ui — projection selectors
|
|
3
|
+
//
|
|
4
|
+
// Pure read-only selectors over reducer state. They derive the concise facts the
|
|
5
|
+
// timeline renders (ordered items, streaming/waiting flags, plan progress,
|
|
6
|
+
// active tool, errors) without mutating state. Kept separate from the view so
|
|
7
|
+
// the same projections can be tested and reused by hosts and screens readers.
|
|
8
|
+
|
|
9
|
+
import { TaskState, ToolStatus } from "./protocol.mjs";
|
|
10
|
+
import { toolConversationVisible } from './tool-identity.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Ordered timeline items in stream order. The reducer already maintains order;
|
|
14
|
+
* this selector is the stable seam for hosts that build their own view.
|
|
15
|
+
* @param {object} state
|
|
16
|
+
* @returns {object[]}
|
|
17
|
+
*/
|
|
18
|
+
export function visibleItems(state, { includeInternal = false } = {}) {
|
|
19
|
+
const items = Array.isArray(state?.items) ? state.items : [];
|
|
20
|
+
const timeline = contextTimelineItems(items, state?.contextMarkers);
|
|
21
|
+
if (includeInternal) return timeline;
|
|
22
|
+
const visible = [];
|
|
23
|
+
for (const item of timeline) {
|
|
24
|
+
if (['tool', 'tool-preview'].includes(item.kind) && !toolConversationVisible(item)) continue;
|
|
25
|
+
if (item.kind === 'thought' && !String(item.text ?? '').trim()) continue;
|
|
26
|
+
const previous = visible.at(-1);
|
|
27
|
+
if (item.kind === 'thought' && previous?.kind === 'thought') {
|
|
28
|
+
const parts = [...previous.parts ?? [previous], item];
|
|
29
|
+
visible[visible.length - 1] = { ...previous, parts, memberIds: parts.map(part => part.id),
|
|
30
|
+
...(parts.some(part => part.responseId) ? { responseId: parts.every(part => part.responseId === parts[0].responseId) ? parts[0].responseId : undefined } : {}),
|
|
31
|
+
text: parts.map(part => part.text).join('\n\n'), streaming: parts.some(part => part.streaming),
|
|
32
|
+
duration: parts.every(part => Number.isFinite(part.duration)) ? parts.reduce((sum, part) => sum + part.duration, 0) : undefined };
|
|
33
|
+
} else visible.push(item);
|
|
34
|
+
}
|
|
35
|
+
return visible;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when any text or thought is still streaming. */
|
|
39
|
+
export function isStreaming(state) {
|
|
40
|
+
return visibleItems(state).some(item => item.streaming);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True when the task is running but no visible transcript item has arrived yet.
|
|
45
|
+
* The timeline shows a neutral "Thinking…" status placeholder in this
|
|
46
|
+
* window. This is a status indicator, not a transcript item — it is never
|
|
47
|
+
* invented reasoning content, and `thinking.start` alone (which creates no
|
|
48
|
+
* item) still leaves the window open until the first delta lands.
|
|
49
|
+
* @param {object} state
|
|
50
|
+
*/
|
|
51
|
+
export function isWaitingForFirstToken(state) {
|
|
52
|
+
if (!state) return false;
|
|
53
|
+
if (state.taskState !== TaskState.Running) return false;
|
|
54
|
+
return visibleItems(state).length === 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A finished interrupted driver can still have an unverified outcome. */
|
|
58
|
+
export function isTerminal(state) {
|
|
59
|
+
return state?.taskState === TaskState.Interrupted && state.taskOutcome != null
|
|
60
|
+
|| state?.taskState === TaskState.Completed
|
|
61
|
+
|| state?.taskState === TaskState.Failed
|
|
62
|
+
|| state?.taskState === TaskState.Cancelled
|
|
63
|
+
|| state?.taskState === TaskState.Blocked
|
|
64
|
+
|| state?.taskState === TaskState.Unverified;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Concise plan progress: completed count, total, and the in-progress label. */
|
|
68
|
+
export function planProgress(state) {
|
|
69
|
+
const plan = state?.plan;
|
|
70
|
+
if (!Array.isArray(plan) || !plan.length) return null;
|
|
71
|
+
const total = plan.length;
|
|
72
|
+
const completed = plan.filter(item => item.status === "completed").length;
|
|
73
|
+
const inProgress = plan.find(item => item.status === "in_progress");
|
|
74
|
+
return {
|
|
75
|
+
total,
|
|
76
|
+
completed,
|
|
77
|
+
inProgress: inProgress ? { id: inProgress.id, content: inProgress.content } : null,
|
|
78
|
+
ratio: total ? completed / total : 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The most recent tool that is still scheduled/running, if any. */
|
|
83
|
+
export function activeTool(state) {
|
|
84
|
+
const items = visibleItems(state);
|
|
85
|
+
for (let i = items.length - 1; i >= 0; i -= 1) {
|
|
86
|
+
const item = items[i];
|
|
87
|
+
if (item.kind === "tool" && (item.status === ToolStatus.Running || item.status === ToolStatus.Scheduled)) {
|
|
88
|
+
return item;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Failed tool items, newest first, for concise error surfacing. */
|
|
95
|
+
export function failedTools(state) {
|
|
96
|
+
return visibleItems(state).filter(item => item.kind === "tool" && item.status === ToolStatus.Failed);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A one-line concise status string for the task, host-declared only. */
|
|
100
|
+
export function taskStatusLabel(state, labels = DEFAULT_LABELS) {
|
|
101
|
+
const s = state?.taskState;
|
|
102
|
+
if (s === TaskState.Running) {
|
|
103
|
+
const tool = activeTool(state);
|
|
104
|
+
if (tool) return tool.name;
|
|
105
|
+
if (state.pendingThinking?.size || visibleItems(state).some(i => i.kind === "thought" && i.streaming)) {
|
|
106
|
+
return labels.thinking;
|
|
107
|
+
}
|
|
108
|
+
return labels.running;
|
|
109
|
+
}
|
|
110
|
+
if (s === TaskState.Interrupted) return labels.interrupted;
|
|
111
|
+
if (s === TaskState.Recovered) return labels.recovered;
|
|
112
|
+
if (s === TaskState.Failed) return labels.failed;
|
|
113
|
+
if (s === TaskState.Completed) return labels.completed;
|
|
114
|
+
if (s === TaskState.Cancelled) return labels.cancelled;
|
|
115
|
+
if (s === TaskState.Blocked) return labels.blocked || "Blocked";
|
|
116
|
+
if (s === TaskState.Unverified) return state.verificationRequired === true
|
|
117
|
+
? labels.unverified || "Unverified" : labels.ended || "Finished";
|
|
118
|
+
if (s === TaskState.Waiting) return labels.waiting || "Waiting for input";
|
|
119
|
+
if (s === TaskState.Paused) return labels.paused || "Paused";
|
|
120
|
+
return labels.idle;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const DEFAULT_LABELS = Object.freeze({
|
|
124
|
+
idle: "",
|
|
125
|
+
running: "Running",
|
|
126
|
+
thinking: "Thinking…",
|
|
127
|
+
interrupted: "Interrupted",
|
|
128
|
+
recovered: "Recovering",
|
|
129
|
+
failed: "Failed",
|
|
130
|
+
completed: "Completed",
|
|
131
|
+
cancelled: "Cancelled",
|
|
132
|
+
});
|
package/src/protocol.mjs
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
// @kingsoftcloud/agentkit-harness-ui — event protocol
|
|
2
|
+
//
|
|
3
|
+
// Explicitly versioned stable event protocol that a host (the AgentKit Native
|
|
4
|
+
// runtime, a Studio adapter, or a test harness) emits against. The UI consumes
|
|
5
|
+
// only these events; it never calls runtime, provider, or session-store
|
|
6
|
+
// internals. The reducer and the timeline are pure projections of this stream.
|
|
7
|
+
//
|
|
8
|
+
// Versioning: every event carries `v`. Bump PROTOCOL_VERSION when the event
|
|
9
|
+
// shapes change in a way the reducer cannot normalize. Unknown future fields
|
|
10
|
+
// are ignored, not rejected, so additive changes remain backward compatible.
|
|
11
|
+
// The reducer accepts `v` up to its known ceiling and normalizes missing
|
|
12
|
+
// optional fields. See README for host integration.
|
|
13
|
+
|
|
14
|
+
export const PROTOCOL_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Event type constants. Use the string form on the wire; the constants exist
|
|
18
|
+
* for readable host code and stable comparisons in tests.
|
|
19
|
+
*/
|
|
20
|
+
export const EventType = Object.freeze({
|
|
21
|
+
TaskStarted: "task.started",
|
|
22
|
+
TaskState: "task.state",
|
|
23
|
+
TaskFinished: "task.finished",
|
|
24
|
+
TextDelta: "text.delta",
|
|
25
|
+
TextDone: "text.done",
|
|
26
|
+
ThinkingStart: "thinking.start",
|
|
27
|
+
ThinkingDelta: "thinking.delta",
|
|
28
|
+
ThinkingDone: "thinking.done",
|
|
29
|
+
ToolPreview: "tool.preview",
|
|
30
|
+
SubagentBound: "subagent.bound",
|
|
31
|
+
ToolScheduled: "tool.scheduled",
|
|
32
|
+
ToolStarted: "tool.started",
|
|
33
|
+
ToolState: "tool.state",
|
|
34
|
+
ToolProgress: "tool.progress",
|
|
35
|
+
ToolResult: "tool.result",
|
|
36
|
+
ToolError: "tool.error",
|
|
37
|
+
PlanUpdated: "plan.updated",
|
|
38
|
+
ContextMarker: "context.marker",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Task lifecycle states the host may declare. */
|
|
42
|
+
export const TaskState = Object.freeze({
|
|
43
|
+
Idle: "idle",
|
|
44
|
+
Running: "running",
|
|
45
|
+
Interrupted: "interrupted",
|
|
46
|
+
Failed: "failed",
|
|
47
|
+
Recovered: "recovered",
|
|
48
|
+
Completed: "completed",
|
|
49
|
+
Cancelled: "cancelled",
|
|
50
|
+
Blocked: "blocked",
|
|
51
|
+
Unverified: "unverified",
|
|
52
|
+
Waiting: "waiting",
|
|
53
|
+
Paused: "paused",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Host-declared terminal outcomes. */
|
|
57
|
+
export const TaskOutcome = Object.freeze({
|
|
58
|
+
Blocked: "blocked",
|
|
59
|
+
Unverified: "unverified",
|
|
60
|
+
Completed: "completed",
|
|
61
|
+
Failed: "failed",
|
|
62
|
+
Cancelled: "cancelled",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Allowed task-state transitions. `cancelled` is reachable from any state
|
|
67
|
+
* (the host may cancel mid-flight). Terminal states (`completed`, `cancelled`)
|
|
68
|
+
* accept no further transitions. The reducer ignores `task.state` events that
|
|
69
|
+
* are not in this table from the current state; it never synthesizes a state.
|
|
70
|
+
*/
|
|
71
|
+
export const TASK_TRANSITIONS = Object.freeze({
|
|
72
|
+
[TaskState.Idle]: [TaskState.Running],
|
|
73
|
+
[TaskState.Running]: [TaskState.Interrupted, TaskState.Failed, TaskState.Completed, TaskState.Cancelled, TaskState.Waiting, TaskState.Paused, TaskState.Blocked, TaskState.Unverified],
|
|
74
|
+
[TaskState.Waiting]: [TaskState.Running, TaskState.Interrupted, TaskState.Cancelled, TaskState.Failed],
|
|
75
|
+
[TaskState.Paused]: [TaskState.Running, TaskState.Interrupted, TaskState.Cancelled, TaskState.Failed],
|
|
76
|
+
[TaskState.Blocked]: [],
|
|
77
|
+
[TaskState.Unverified]: [],
|
|
78
|
+
[TaskState.Interrupted]: [TaskState.Running, TaskState.Recovered, TaskState.Failed, TaskState.Cancelled],
|
|
79
|
+
[TaskState.Failed]: [TaskState.Running, TaskState.Recovered, TaskState.Cancelled],
|
|
80
|
+
[TaskState.Recovered]: [TaskState.Running, TaskState.Cancelled],
|
|
81
|
+
[TaskState.Completed]: [],
|
|
82
|
+
[TaskState.Cancelled]: [],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Tool lifecycle statuses, keyed by callId. */
|
|
86
|
+
export const ToolStatus = Object.freeze({
|
|
87
|
+
NotDispatched: "not_dispatched",
|
|
88
|
+
Uncertain: "uncertain",
|
|
89
|
+
Interrupted: "interrupted",
|
|
90
|
+
Waiting: "waiting",
|
|
91
|
+
Scheduled: "scheduled",
|
|
92
|
+
Running: "running",
|
|
93
|
+
Completed: "completed",
|
|
94
|
+
Failed: "failed",
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Explicit presentation payload kinds a host may attach to `tool.result`
|
|
99
|
+
* `display`. The UI renders a purpose-built block for each known kind; an
|
|
100
|
+
* unknown kind falls back to the generic bounded object preview. Every kind
|
|
101
|
+
* is optional — a host that only emits a raw `result` still renders fine.
|
|
102
|
+
*/
|
|
103
|
+
export const DisplayKind = Object.freeze({
|
|
104
|
+
CommandOutput: "command_output",
|
|
105
|
+
CommandPreview: "command_preview",
|
|
106
|
+
FileDiff: "file_diff",
|
|
107
|
+
FileRead: "file_read",
|
|
108
|
+
FileEdit: "file_edit",
|
|
109
|
+
Generic: "generic",
|
|
110
|
+
});
|
|
111
|
+
const DISPLAY_KINDS = new Set(Object.values(DisplayKind));
|
|
112
|
+
|
|
113
|
+
const EVENT_TYPES = new Set(Object.values(EventType));
|
|
114
|
+
const TASK_STATES = new Set(Object.values(TaskState));
|
|
115
|
+
const TASK_OUTCOMES = new Set(Object.values(TaskOutcome));
|
|
116
|
+
const TOOL_STATUSES = new Set(Object.values(ToolStatus));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @typedef {Object} HarnessEvent envelope shared by every event.
|
|
120
|
+
* @property {1} v protocol version; must equal PROTOCOL_VERSION.
|
|
121
|
+
* @property {string} type one of EventType.
|
|
122
|
+
* @property {string} id stable event id; replaying the same id is a no-op.
|
|
123
|
+
* @property {"interrupted"} [executionState] closed driver state, separate from an unverified outcome.
|
|
124
|
+
* @property {number} [ts] client-observed timestamp (ms); for diagnostics only.
|
|
125
|
+
* @property {number} [seq] optional host monotonic sequence; for replay audit.
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate an event envelope and its type-specific payload. Returns a normalized
|
|
130
|
+
* copy with missing optional fields filled. Throws on an unrecoverable shape
|
|
131
|
+
* (unknown event type, wrong protocol version, missing required id/type).
|
|
132
|
+
* Malformed payloads the UI can survive (e.g. a tool input that is not an
|
|
133
|
+
* object) are coerced to a safe default rather than thrown, matching the
|
|
134
|
+
* tool-detail-block failure contract: skip invalid fields, never break render.
|
|
135
|
+
*
|
|
136
|
+
* @param {HarnessEvent & Record<string, unknown>} event
|
|
137
|
+
* @returns {HarnessEvent & Record<string, unknown>}
|
|
138
|
+
*/
|
|
139
|
+
export function normalizeEvent(event) {
|
|
140
|
+
if (!event || typeof event !== "object") throw new TypeError("event must be an object");
|
|
141
|
+
const v = event.v;
|
|
142
|
+
if (v !== PROTOCOL_VERSION) {
|
|
143
|
+
throw new RangeError(`event.v is ${String(v)}; expected protocol version ${PROTOCOL_VERSION}`);
|
|
144
|
+
}
|
|
145
|
+
const type = String(event.type || "");
|
|
146
|
+
if (!EVENT_TYPES.has(type)) throw new RangeError(`unknown event type: ${String(event.type)}`);
|
|
147
|
+
const id = event.id == null ? "" : String(event.id);
|
|
148
|
+
if (!id) throw new RangeError(`event.id is required for ${type}`);
|
|
149
|
+
const base = { v, type, id, ts: typeof event.ts === "number" ? event.ts : null, seq: typeof event.seq === "number" ? event.seq : null };
|
|
150
|
+
// Optional presentation lineage, supplied by the Host's response boundary.
|
|
151
|
+
// It is never a Run identity, tool claim or an execution permission.
|
|
152
|
+
if (['text.delta', 'text.done', 'thinking.delta', 'thinking.done', 'tool.preview', 'tool.scheduled'].includes(type)
|
|
153
|
+
&& typeof event.responseId === 'string' && event.responseId.length > 0 && event.responseId.length <= 1024)
|
|
154
|
+
base.responseId = event.responseId;
|
|
155
|
+
return Object.assign(base, normalizePayload(type, event));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizePayload(type, event) {
|
|
159
|
+
switch (type) {
|
|
160
|
+
case EventType.TaskStarted:
|
|
161
|
+
return { taskId: str(event.taskId), title: str(event.title) || null, runId: str(event.runId) || null,
|
|
162
|
+
...(hostTime(event.startedAt) !== null ? { startedAt: event.startedAt } : {}) };
|
|
163
|
+
case EventType.TaskState:
|
|
164
|
+
return { state: requiredChoice(event.state, TASK_STATES, "task state"), reason: str(event.reason) || null, message: str(event.message) || null };
|
|
165
|
+
case EventType.TaskFinished: {
|
|
166
|
+
const outcome = requiredChoice(event.outcome, TASK_OUTCOMES, "task outcome");
|
|
167
|
+
if (event.executionState !== undefined && (event.executionState !== TaskState.Interrupted || outcome !== TaskOutcome.Unverified))
|
|
168
|
+
throw new RangeError('interrupted execution requires an unverified outcome');
|
|
169
|
+
return { outcome, summary: str(event.summary) || null, error: str(event.error) || null, reason: str(event.reason) || null,
|
|
170
|
+
...(hostTime(event.endedAt) !== null ? { endedAt: event.endedAt } : {}),
|
|
171
|
+
...(typeof event.finalAnswerId === 'string' && event.finalAnswerId ? { finalAnswerId: event.finalAnswerId } : {}),
|
|
172
|
+
...(event.executionState === TaskState.Interrupted ? { executionState: TaskState.Interrupted } : {}),
|
|
173
|
+
verificationRequired: typeof event.verificationRequired === 'boolean' ? event.verificationRequired : null };
|
|
174
|
+
}
|
|
175
|
+
case EventType.TextDelta:
|
|
176
|
+
return { itemId: str(event.itemId), delta: str(event.delta) };
|
|
177
|
+
case EventType.TextDone:
|
|
178
|
+
return { itemId: str(event.itemId), ...(typeof event.sourceMessageId === "string" ? { sourceMessageId: event.sourceMessageId } : {}), ...(typeof event.text === "string" ? { text: event.text } : {}),
|
|
179
|
+
...(event.role === "user" ? { role: "user" } : {}) };
|
|
180
|
+
case EventType.ThinkingStart:
|
|
181
|
+
return { itemId: str(event.itemId), provider: true };
|
|
182
|
+
case EventType.ThinkingDelta:
|
|
183
|
+
return { itemId: str(event.itemId), delta: str(event.delta), provider: true };
|
|
184
|
+
case EventType.ThinkingDone:
|
|
185
|
+
return { itemId: str(event.itemId), provider: true,
|
|
186
|
+
...(typeof event.sourceMessageId === "string" ? { sourceMessageId: event.sourceMessageId } : {}),
|
|
187
|
+
...(typeof event.text === "string" ? { text: event.text } : {}),
|
|
188
|
+
...(event.duration != null ? { duration: numberOrNull(event.duration) } : {}) };
|
|
189
|
+
case EventType.SubagentBound: {
|
|
190
|
+
const child = event.child;
|
|
191
|
+
if (!child || !['runId', 'sessionId', 'parentRunId', 'role', 'title'].every(key => typeof child[key] === 'string')
|
|
192
|
+
|| !child.runId || !child.sessionId || !child.parentRunId || child.title.length > 512) throw Error('Invalid subagent identity');
|
|
193
|
+
return { callId: str(event.callId), child: { runId: child.runId, sessionId: child.sessionId,
|
|
194
|
+
parentRunId: child.parentRunId, role: child.role, title: child.title, phase: str(child.phase),
|
|
195
|
+
activity: child.activity && typeof child.activity.name === 'string' && TOOL_STATUSES.has(child.activity.status)
|
|
196
|
+
? { name: child.activity.name, input: asObject(child.activity.input), status: child.activity.status } : null } };
|
|
197
|
+
}
|
|
198
|
+
case EventType.ToolState:
|
|
199
|
+
return { callId: str(event.callId), status: requiredChoice(event.status, TOOL_STATUSES, "tool state"), message: str(event.message) || null };
|
|
200
|
+
case EventType.ToolPreview:
|
|
201
|
+
return { previewId: str(event.previewId), name: str(event.name) || 'tool', input: asObject(event.input),
|
|
202
|
+
receivedChars: Number.isSafeInteger(event.receivedChars) && event.receivedChars >= 0 ? event.receivedChars : 0 };
|
|
203
|
+
case EventType.ToolScheduled:
|
|
204
|
+
return { callId: str(event.callId), name: str(event.name) || "tool", input: asObject(event.input) };
|
|
205
|
+
case EventType.ToolStarted:
|
|
206
|
+
return { callId: str(event.callId) };
|
|
207
|
+
case EventType.ToolProgress:
|
|
208
|
+
return { callId: str(event.callId), progress: str(event.progress) || null, message: str(event.message) || null,
|
|
209
|
+
display: normalizeDisplay(event.display) };
|
|
210
|
+
case EventType.ToolResult:
|
|
211
|
+
return { callId: str(event.callId), result: event.result === undefined ? null : event.result, display: normalizeDisplay(event.display) };
|
|
212
|
+
case EventType.ToolError:
|
|
213
|
+
return { callId: str(event.callId), error: str(event.error) || "tool failed" };
|
|
214
|
+
case EventType.ContextMarker: {
|
|
215
|
+
const marker = event.marker;
|
|
216
|
+
if (!marker || typeof marker.id !== 'string' || !marker.id || marker.id.length > 256
|
|
217
|
+
|| !['running', 'completed', 'failed', 'interrupted', 'noop'].includes(marker.status)
|
|
218
|
+
|| !['auto', 'manual'].includes(marker.origin) || !marker.after
|
|
219
|
+
|| !['start', 'tool', 'message'].includes(marker.after.kind)
|
|
220
|
+
|| marker.after.kind !== 'start' && (typeof marker.after.id !== 'string' || !marker.after.id || marker.after.id.length > 256))
|
|
221
|
+
throw Error('Invalid context marker');
|
|
222
|
+
return { marker: { id: marker.id, status: marker.status, origin: marker.origin,
|
|
223
|
+
after: { kind: marker.after.kind, ...(marker.after.id ? { id: marker.after.id } : {}) } } };
|
|
224
|
+
}
|
|
225
|
+
case EventType.PlanUpdated:
|
|
226
|
+
return { items: Array.isArray(event.items) ? event.items.map(normalizePlanItem).filter(Boolean) : [] };
|
|
227
|
+
default:
|
|
228
|
+
return {};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizePlanItem(item) {
|
|
233
|
+
if (!item || typeof item !== "object") return null;
|
|
234
|
+
return {
|
|
235
|
+
id: str(item.id),
|
|
236
|
+
content: str(item.content),
|
|
237
|
+
status: pick(item.status, new Set(["pending", "in_progress", "completed"]), "pending"),
|
|
238
|
+
priority: str(item.priority) || null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function str(value) {
|
|
243
|
+
if (value == null) return "";
|
|
244
|
+
if (typeof value === "string") return value;
|
|
245
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
246
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function asObject(value) {
|
|
250
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return value;
|
|
251
|
+
if (typeof value === "string") {
|
|
252
|
+
try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value }; }
|
|
253
|
+
catch { return { value }; }
|
|
254
|
+
}
|
|
255
|
+
return value == null ? {} : { value: str(value) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Normalize a `tool.result`/`tool.progress` display payload. A known `kind`
|
|
260
|
+
* yields a purpose-built shape; an unknown or missing kind falls back to a
|
|
261
|
+
* generic object so the generic renderer still renders something safe. The
|
|
262
|
+
* normalizer never reads files or infers a write succeeded — it only carries
|
|
263
|
+
* the host-supplied fields through, coerced to safe primitives.
|
|
264
|
+
* @param {unknown} display
|
|
265
|
+
* @returns {Record<string, unknown>}
|
|
266
|
+
*/
|
|
267
|
+
function normalizeDisplay(display) {
|
|
268
|
+
const obj = asObject(display);
|
|
269
|
+
if (!obj || typeof obj !== "object") return {};
|
|
270
|
+
const kind = pick(obj.kind, DISPLAY_KINDS, DisplayKind.Generic);
|
|
271
|
+
switch (kind) {
|
|
272
|
+
case DisplayKind.CommandOutput:
|
|
273
|
+
return {
|
|
274
|
+
kind, stdout: str(obj.stdout), stderr: str(obj.stderr),
|
|
275
|
+
exit: numberOrNull(obj.exit ?? obj.exitCode ?? obj.code ?? obj.returncode),
|
|
276
|
+
cwd: str(obj.cwd),
|
|
277
|
+
};
|
|
278
|
+
case DisplayKind.CommandPreview:
|
|
279
|
+
return { kind, fullText: str(obj.fullText ?? obj.stdout ?? obj.text),
|
|
280
|
+
...(typeof obj.stderr === 'string' ? { stderr: obj.stderr } : {}) };
|
|
281
|
+
case DisplayKind.FileDiff:
|
|
282
|
+
return {
|
|
283
|
+
kind, path: str(obj.path), patch: str(obj.patch),
|
|
284
|
+
changeStat: normalizeChangeStat(obj.changeStat),
|
|
285
|
+
operationKind: str(obj.operationKind) || null,
|
|
286
|
+
};
|
|
287
|
+
case DisplayKind.FileRead:
|
|
288
|
+
return { kind, path: str(obj.path), entryType: pick(obj.entryType, new Set(["file", "directory"]), "file") };
|
|
289
|
+
case DisplayKind.FileEdit:
|
|
290
|
+
return { kind, omittedFiles: Number.isSafeInteger(obj.omittedFiles) && obj.omittedFiles > 0 ? obj.omittedFiles : 0,
|
|
291
|
+
files: Array.isArray(obj.files) ? obj.files.map(normalizeFileEdit).filter(Boolean) : [] };
|
|
292
|
+
default:
|
|
293
|
+
return { ...obj, kind };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function normalizeFileEdit(file) {
|
|
298
|
+
if (!file || typeof file !== "object") return null;
|
|
299
|
+
return {
|
|
300
|
+
path: str(file.path),
|
|
301
|
+
patch: str(file.patch),
|
|
302
|
+
truncated: file.truncated === true,
|
|
303
|
+
changeStat: normalizeChangeStat(file.changeStat),
|
|
304
|
+
operationKind: str(file.operationKind) || null,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function normalizeChangeStat(stat) {
|
|
309
|
+
if (!stat || typeof stat !== "object") return null;
|
|
310
|
+
const added = numberOrNull(stat.added);
|
|
311
|
+
const removed = numberOrNull(stat.removed);
|
|
312
|
+
if (added == null && removed == null) return null;
|
|
313
|
+
return { added: Math.max(0, added ?? 0), removed: Math.max(0, removed ?? 0) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function numberOrNull(value) {
|
|
317
|
+
if (value == null || value === "") return null;
|
|
318
|
+
const n = Number(value);
|
|
319
|
+
return Number.isFinite(n) ? n : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function hostTime(value) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; }
|
|
323
|
+
|
|
324
|
+
/** @returns {boolean} */
|
|
325
|
+
export function isDisplayKind(value) {
|
|
326
|
+
return DISPLAY_KINDS.has(String(value));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function pick(value, allowed, fallback) {
|
|
330
|
+
const s = str(value);
|
|
331
|
+
return allowed.has(s) ? s : fallback;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {unknown} state
|
|
336
|
+
* @returns {boolean}
|
|
337
|
+
*/
|
|
338
|
+
export function canTransition(from, to) {
|
|
339
|
+
if (to === TaskState.Cancelled) return from !== TaskState.Completed && from !== TaskState.Cancelled;
|
|
340
|
+
const allowed = TASK_TRANSITIONS[from];
|
|
341
|
+
return Boolean(allowed && allowed.includes(to));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @returns {boolean} */
|
|
345
|
+
export function isTaskState(value) {
|
|
346
|
+
return TASK_STATES.has(String(value));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** @returns {boolean} */
|
|
350
|
+
export function isToolStatus(value) {
|
|
351
|
+
return TOOL_STATUSES.has(String(value));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** @returns {boolean} */
|
|
355
|
+
export function isEventType(value) {
|
|
356
|
+
return EVENT_TYPES.has(String(value));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function requiredChoice(value, allowed, label) {
|
|
360
|
+
if (!allowed.has(value)) throw new TypeError(`Missing or unsupported ${label}`);
|
|
361
|
+
return value;
|
|
362
|
+
}
|