@robota-sdk/agent-transport 3.0.0-beta.64
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 +21 -0
- package/dist/node/headless/index.cjs +1 -0
- package/dist/node/headless/index.d.ts +2 -0
- package/dist/node/headless/index.js +1 -0
- package/dist/node/headless-CWEpJXFK.js +7 -0
- package/dist/node/headless-CWEpJXFK.js.map +1 -0
- package/dist/node/headless-CsZFelG9.cjs +6 -0
- package/dist/node/http/index.cjs +1 -0
- package/dist/node/http/index.d.ts +2 -0
- package/dist/node/http/index.js +1 -0
- package/dist/node/http-CM3TJhrF.cjs +1 -0
- package/dist/node/http-DwO1AHG-.js +2 -0
- package/dist/node/http-DwO1AHG-.js.map +1 -0
- package/dist/node/index--Ti9NzQX.d.ts +64 -0
- package/dist/node/index--Ti9NzQX.d.ts.map +1 -0
- package/dist/node/index-B_rcr14p.d.ts +47 -0
- package/dist/node/index-B_rcr14p.d.ts.map +1 -0
- package/dist/node/index-C9LWCL4l.d.ts +34 -0
- package/dist/node/index-C9LWCL4l.d.ts.map +1 -0
- package/dist/node/index-CAr3ioVh.d.ts +64 -0
- package/dist/node/index-CAr3ioVh.d.ts.map +1 -0
- package/dist/node/index-CEs25wVk.d.ts +213 -0
- package/dist/node/index-CEs25wVk.d.ts.map +1 -0
- package/dist/node/index-CvXLpjJO.d.ts +213 -0
- package/dist/node/index-CvXLpjJO.d.ts.map +1 -0
- package/dist/node/index-D34WUfFH.d.ts +26 -0
- package/dist/node/index-D34WUfFH.d.ts.map +1 -0
- package/dist/node/index-Y0zHb1Bz.d.ts +47 -0
- package/dist/node/index-Y0zHb1Bz.d.ts.map +1 -0
- package/dist/node/index-k3TUjA-T.d.ts +26 -0
- package/dist/node/index-k3TUjA-T.d.ts.map +1 -0
- package/dist/node/index-nBlMTFkZ.d.ts +34 -0
- package/dist/node/index-nBlMTFkZ.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +1 -0
- package/dist/node/mcp/index.cjs +1 -0
- package/dist/node/mcp/index.d.ts +2 -0
- package/dist/node/mcp/index.js +1 -0
- package/dist/node/mcp-BXBwF6Wu.js +2 -0
- package/dist/node/mcp-BXBwF6Wu.js.map +1 -0
- package/dist/node/mcp-DcHuGokt.cjs +1 -0
- package/dist/node/tui/index.cjs +1 -0
- package/dist/node/tui/index.d.ts +2 -0
- package/dist/node/tui/index.js +1 -0
- package/dist/node/tui-CeD_6rSo.cjs +24 -0
- package/dist/node/tui-zmDTPk4b.js +25 -0
- package/dist/node/tui-zmDTPk4b.js.map +1 -0
- package/dist/node/ws/index.cjs +1 -0
- package/dist/node/ws/index.d.ts +2 -0
- package/dist/node/ws/index.js +1 -0
- package/dist/node/ws-B-oRccFl.js +2 -0
- package/dist/node/ws-B-oRccFl.js.map +1 -0
- package/dist/node/ws-COnIgnmn.cjs +1 -0
- package/package.json +141 -0
- package/src/headless/__tests__/headless-runner-initialization.test.ts +45 -0
- package/src/headless/__tests__/headless-runner.test.ts +484 -0
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +430 -0
- package/src/headless/__tests__/headless-transport.test.ts +268 -0
- package/src/headless/headless-runner.ts +141 -0
- package/src/headless/headless-stream-json.ts +142 -0
- package/src/headless/headless-transport.ts +43 -0
- package/src/headless/index.ts +4 -0
- package/src/http/__tests__/http-transport.test.ts +55 -0
- package/src/http/__tests__/routes.test.ts +168 -0
- package/src/http/http-transport.ts +42 -0
- package/src/http/index.ts +4 -0
- package/src/http/routes.ts +151 -0
- package/src/index.ts +5 -0
- package/src/mcp/__tests__/mcp-server.test.ts +66 -0
- package/src/mcp/__tests__/mcp-transport.test.ts +46 -0
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp-server.ts +162 -0
- package/src/mcp/mcp-transport.ts +48 -0
- package/src/tui/App.tsx +478 -0
- package/src/tui/BackgroundTaskPanel.tsx +34 -0
- package/src/tui/CjkTextInput.tsx +204 -0
- package/src/tui/ConfirmPrompt.tsx +69 -0
- package/src/tui/ExecutionWorkspaceDetailPane.tsx +62 -0
- package/src/tui/ExecutionWorkspaceSwitcher.tsx +185 -0
- package/src/tui/InkTerminal.ts +42 -0
- package/src/tui/InputArea.tsx +298 -0
- package/src/tui/InteractivePrompt.tsx +57 -0
- package/src/tui/ListPicker.tsx +94 -0
- package/src/tui/MenuSelect.tsx +103 -0
- package/src/tui/MessageList.tsx +282 -0
- package/src/tui/PermissionPrompt.tsx +84 -0
- package/src/tui/PluginTUI.tsx +256 -0
- package/src/tui/SessionPicker.tsx +66 -0
- package/src/tui/SessionStatusBar.tsx +66 -0
- package/src/tui/SlashAutocomplete.tsx +110 -0
- package/src/tui/StatusBar.tsx +213 -0
- package/src/tui/StreamingIndicator.tsx +91 -0
- package/src/tui/TextPrompt.tsx +80 -0
- package/src/tui/ToolCommandOutput.tsx +37 -0
- package/src/tui/ToolDiffBlock.tsx +30 -0
- package/src/tui/TransportTUI.tsx +116 -0
- package/src/tui/UpdateNotice.tsx +14 -0
- package/src/tui/UsageSummaryEntry.tsx +38 -0
- package/src/tui/WaveText.tsx +44 -0
- package/src/tui/__tests__/InteractivePrompt.test.tsx +82 -0
- package/src/tui/__tests__/ListPicker.test.tsx +159 -0
- package/src/tui/__tests__/MenuSelect.test.tsx +103 -0
- package/src/tui/__tests__/PluginTUI.test.tsx +167 -0
- package/src/tui/__tests__/SlashAutocomplete.test.tsx +140 -0
- package/src/tui/__tests__/TextPrompt.test.tsx +98 -0
- package/src/tui/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/tui/__tests__/abort-after-permission.test.tsx +169 -0
- package/src/tui/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/tui/__tests__/background-task-panel.test.tsx +53 -0
- package/src/tui/__tests__/background-task-row-format.test.ts +59 -0
- package/src/tui/__tests__/cjk-text-input-flow.test.ts +109 -0
- package/src/tui/__tests__/cjk-text-input.test.ts +191 -0
- package/src/tui/__tests__/command-effect-handler.test.ts +128 -0
- package/src/tui/__tests__/command-output-summary.test.ts +95 -0
- package/src/tui/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/tui/__tests__/confirm-permission-flow.test.ts +91 -0
- package/src/tui/__tests__/confirm-prompt.test.tsx +87 -0
- package/src/tui/__tests__/execution-workspace-switcher.test.tsx +110 -0
- package/src/tui/__tests__/execution-workspace-view-model.test.ts +93 -0
- package/src/tui/__tests__/fixtures/provider-setup-prompt-driver.tsx +122 -0
- package/src/tui/__tests__/input-area-flow.test.ts +152 -0
- package/src/tui/__tests__/message-list-rendering.test.tsx +353 -0
- package/src/tui/__tests__/model-change-side-effect.test.ts +91 -0
- package/src/tui/__tests__/prompt-queue.test.tsx +255 -0
- package/src/tui/__tests__/provider-setup-pty-e2e.test.ts +233 -0
- package/src/tui/__tests__/render-markdown.test.ts +72 -0
- package/src/tui/__tests__/selection-flow.test.ts +61 -0
- package/src/tui/__tests__/slash-routing-effects.test.ts +225 -0
- package/src/tui/__tests__/status-activity.test.ts +71 -0
- package/src/tui/__tests__/status-bar.test.tsx +157 -0
- package/src/tui/__tests__/streaming-indicator.test.tsx +137 -0
- package/src/tui/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/tui/__tests__/tui-state-manager.test.ts +401 -0
- package/src/tui/background-task-row-format.ts +52 -0
- package/src/tui/command-output-summary.ts +122 -0
- package/src/tui/execution-workspace-view-model.ts +123 -0
- package/src/tui/flows/cjk-text-input-flow.ts +285 -0
- package/src/tui/flows/confirm-prompt-flow.ts +45 -0
- package/src/tui/flows/input-area-flow.ts +186 -0
- package/src/tui/flows/permission-prompt-flow.ts +76 -0
- package/src/tui/flows/selection-flow.ts +126 -0
- package/src/tui/flows/text-prompt-flow.ts +98 -0
- package/src/tui/hooks/command-effect-handler.ts +98 -0
- package/src/tui/hooks/command-effect-queue.ts +39 -0
- package/src/tui/hooks/model-change-side-effect.ts +63 -0
- package/src/tui/hooks/side-effects-types.ts +38 -0
- package/src/tui/hooks/use-interactive-session-init.ts +50 -0
- package/src/tui/hooks/useAutocomplete.ts +85 -0
- package/src/tui/hooks/useInteractiveSession.ts +273 -0
- package/src/tui/hooks/usePermissionQueue.ts +51 -0
- package/src/tui/hooks/usePluginCallbacks.ts +30 -0
- package/src/tui/hooks/usePluginScreenData.ts +84 -0
- package/src/tui/hooks/useSideEffects.ts +210 -0
- package/src/tui/hooks/useSlashRouting.ts +117 -0
- package/src/tui/hooks/useStatusLineSettings.ts +35 -0
- package/src/tui/index.ts +3 -0
- package/src/tui/plugin-tui-handlers.ts +163 -0
- package/src/tui/render-markdown.ts +129 -0
- package/src/tui/render.tsx +60 -0
- package/src/tui/status-activity.ts +63 -0
- package/src/tui/tui-cli-adapter-context.tsx +12 -0
- package/src/tui/tui-cli-adapter.ts +25 -0
- package/src/tui/tui-state-manager.ts +225 -0
- package/src/tui/tui-transport.ts +32 -0
- package/src/tui/types.ts +14 -0
- package/src/tui/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/tui/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/tui/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/tui/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/tui/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/tui/utils/edit-diff.ts +152 -0
- package/src/tui/utils/paste-labels.ts +9 -0
- package/src/tui/utils/tool-call-extractor.ts +91 -0
- package/src/tui/utils/tool-diff-summary.ts +75 -0
- package/src/ws/__tests__/ws-handler.test.ts +407 -0
- package/src/ws/__tests__/ws-transport.test.ts +53 -0
- package/src/ws/index.ts +13 -0
- package/src/ws/ws-background-messages.ts +170 -0
- package/src/ws/ws-handler.ts +279 -0
- package/src/ws/ws-protocol.ts +76 -0
- package/src/ws/ws-transport-configurable.ts +123 -0
- package/src/ws/ws-transport.ts +42 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
applyTextPromptInput,
|
|
4
|
+
createTextPromptFlowState,
|
|
5
|
+
getTextPromptInputAction,
|
|
6
|
+
type ITextPromptFlowState,
|
|
7
|
+
} from '../flows/text-prompt-flow.js';
|
|
8
|
+
|
|
9
|
+
describe('text prompt flow', () => {
|
|
10
|
+
it('Given typed text When submit is applied Then it emits trimmed submit value', () => {
|
|
11
|
+
const typed = applyTextPromptInput(
|
|
12
|
+
createTextPromptFlowState(),
|
|
13
|
+
{ type: 'insert', value: ' hello ' },
|
|
14
|
+
{ allowEmpty: false },
|
|
15
|
+
).state;
|
|
16
|
+
|
|
17
|
+
const result = applyTextPromptInput(typed, { type: 'submit' }, { allowEmpty: false });
|
|
18
|
+
|
|
19
|
+
expect(result.effect).toEqual({ type: 'submit', value: 'hello' });
|
|
20
|
+
expect(result.state.resolved).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('Given empty required text When submit is applied Then validation error stays in state', () => {
|
|
24
|
+
const result = applyTextPromptInput(
|
|
25
|
+
createTextPromptFlowState(),
|
|
26
|
+
{ type: 'submit' },
|
|
27
|
+
{ allowEmpty: false, validate: (value) => (value.length === 0 ? 'Required' : undefined) },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(result.effect).toEqual({ type: 'none' });
|
|
31
|
+
expect(result.state.error).toBe('Required');
|
|
32
|
+
expect(result.state.resolved).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('Given defaultable empty text When submit is applied Then empty submit is allowed', () => {
|
|
36
|
+
const result = applyTextPromptInput(
|
|
37
|
+
createTextPromptFlowState(),
|
|
38
|
+
{ type: 'submit' },
|
|
39
|
+
{ allowEmpty: true },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(result.effect).toEqual({ type: 'submit', value: '' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('Given text with an error When delete or insert is applied Then the error is cleared', () => {
|
|
46
|
+
const state: ITextPromptFlowState = { value: 'ab', error: 'Invalid', resolved: false };
|
|
47
|
+
|
|
48
|
+
const deleted = applyTextPromptInput(state, { type: 'delete' }, { allowEmpty: false });
|
|
49
|
+
const inserted = applyTextPromptInput(
|
|
50
|
+
state,
|
|
51
|
+
{ type: 'insert', value: 'c' },
|
|
52
|
+
{ allowEmpty: false },
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(deleted.state).toMatchObject({ value: 'a', error: undefined });
|
|
56
|
+
expect(inserted.state).toMatchObject({ value: 'abc', error: undefined });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('Given prompt is already resolved When input is applied Then no second effect is emitted', () => {
|
|
60
|
+
const state: ITextPromptFlowState = { value: 'done', resolved: true };
|
|
61
|
+
|
|
62
|
+
const result = applyTextPromptInput(state, { type: 'submit' }, { allowEmpty: false });
|
|
63
|
+
|
|
64
|
+
expect(result.effect).toEqual({ type: 'none' });
|
|
65
|
+
expect(result.state).toBe(state);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('Given raw Ink key info When mapped Then terminal details become prompt actions', () => {
|
|
69
|
+
expect(getTextPromptInputAction('', { escape: true })).toEqual({ type: 'cancel' });
|
|
70
|
+
expect(getTextPromptInputAction('', { return: true })).toEqual({ type: 'submit' });
|
|
71
|
+
expect(getTextPromptInputAction('', { backspace: true })).toEqual({ type: 'delete' });
|
|
72
|
+
expect(getTextPromptInputAction('x', { ctrl: false, meta: false })).toEqual({
|
|
73
|
+
type: 'insert',
|
|
74
|
+
value: 'x',
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for TuiStateManager — pure TypeScript, no React.
|
|
3
|
+
* Tests event → state transitions that must survive any refactoring.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
import { TuiStateManager } from '../tui-state-manager.js';
|
|
8
|
+
import type { IToolState, IExecutionResult } from '@robota-sdk/agent-framework';
|
|
9
|
+
|
|
10
|
+
function makeResult(overrides?: Partial<IExecutionResult>): IExecutionResult {
|
|
11
|
+
return {
|
|
12
|
+
response: 'test response',
|
|
13
|
+
history: [],
|
|
14
|
+
toolSummaries: [],
|
|
15
|
+
contextState: {
|
|
16
|
+
usedPercentage: 10,
|
|
17
|
+
remainingPercentage: 90,
|
|
18
|
+
usedTokens: 1000,
|
|
19
|
+
maxTokens: 200000,
|
|
20
|
+
},
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('TuiStateManager', () => {
|
|
26
|
+
// ── Streaming text ────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
it('accumulates streaming text on text_delta', () => {
|
|
29
|
+
const mgr = new TuiStateManager();
|
|
30
|
+
mgr.onTextDelta('Hello');
|
|
31
|
+
mgr.onTextDelta(' world');
|
|
32
|
+
expect(mgr.streamingText).toBe('Hello world');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('clears streaming text on thinking=true', () => {
|
|
36
|
+
const mgr = new TuiStateManager();
|
|
37
|
+
mgr.onTextDelta('old text');
|
|
38
|
+
mgr.onThinking(true);
|
|
39
|
+
expect(mgr.streamingText).toBe('');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('clears streaming text on complete', () => {
|
|
43
|
+
const mgr = new TuiStateManager();
|
|
44
|
+
mgr.onTextDelta('streaming');
|
|
45
|
+
mgr.onComplete(makeResult());
|
|
46
|
+
expect(mgr.streamingText).toBe('');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('clears streaming text on interrupted', () => {
|
|
50
|
+
const mgr = new TuiStateManager();
|
|
51
|
+
mgr.onTextDelta('partial');
|
|
52
|
+
mgr.onInterrupted();
|
|
53
|
+
expect(mgr.streamingText).toBe('');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Tool state ────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
it('adds tool on tool_start', () => {
|
|
59
|
+
const mgr = new TuiStateManager();
|
|
60
|
+
const tool: IToolState = { toolName: 'Read', firstArg: 'file.ts', isRunning: true };
|
|
61
|
+
mgr.onToolStart(tool);
|
|
62
|
+
expect(mgr.activeTools).toHaveLength(1);
|
|
63
|
+
expect(mgr.activeTools[0]!.toolName).toBe('Read');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('updates tool on tool_end', () => {
|
|
67
|
+
const mgr = new TuiStateManager();
|
|
68
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'file.ts', isRunning: true });
|
|
69
|
+
mgr.onToolEnd({ toolName: 'Read', firstArg: 'file.ts', isRunning: false, result: 'success' });
|
|
70
|
+
expect(mgr.activeTools[0]!.isRunning).toBe(false);
|
|
71
|
+
expect(mgr.activeTools[0]!.result).toBe('success');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('clears tools on thinking=true (next execution start)', () => {
|
|
75
|
+
const mgr = new TuiStateManager();
|
|
76
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
77
|
+
mgr.onThinking(true);
|
|
78
|
+
expect(mgr.activeTools).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('clears tools on complete (tool summary now in messages)', () => {
|
|
82
|
+
const mgr = new TuiStateManager();
|
|
83
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
84
|
+
mgr.onComplete(makeResult());
|
|
85
|
+
expect(mgr.activeTools).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('clears tools on interrupted (tool summary now in messages)', () => {
|
|
89
|
+
const mgr = new TuiStateManager();
|
|
90
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
91
|
+
mgr.onInterrupted();
|
|
92
|
+
expect(mgr.activeTools).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('clears rendered history, streaming text, and active tools on explicit history clear', () => {
|
|
96
|
+
const mgr = new TuiStateManager();
|
|
97
|
+
mgr.addEntry({
|
|
98
|
+
id: 'old',
|
|
99
|
+
timestamp: new Date('2026-05-03T00:00:00.000Z'),
|
|
100
|
+
category: 'chat',
|
|
101
|
+
type: 'user',
|
|
102
|
+
data: { role: 'user', content: 'old message' },
|
|
103
|
+
});
|
|
104
|
+
mgr.onTextDelta('partial');
|
|
105
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'file.ts', isRunning: true });
|
|
106
|
+
|
|
107
|
+
mgr.clearHistory();
|
|
108
|
+
|
|
109
|
+
expect(mgr.history).toEqual([]);
|
|
110
|
+
expect(mgr.streamingText).toBe('');
|
|
111
|
+
expect(mgr.activeTools).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Thinking state ────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
it('sets isThinking on thinking event', () => {
|
|
117
|
+
const mgr = new TuiStateManager();
|
|
118
|
+
mgr.onThinking(true);
|
|
119
|
+
expect(mgr.isThinking).toBe(true);
|
|
120
|
+
mgr.onThinking(false);
|
|
121
|
+
expect(mgr.isThinking).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clears isAborting on thinking=false', () => {
|
|
125
|
+
const mgr = new TuiStateManager();
|
|
126
|
+
mgr.setAborting(true);
|
|
127
|
+
expect(mgr.isAborting).toBe(true);
|
|
128
|
+
mgr.onThinking(false);
|
|
129
|
+
expect(mgr.isAborting).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Context state ─────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
it('updates context on complete', () => {
|
|
135
|
+
const mgr = new TuiStateManager();
|
|
136
|
+
mgr.onComplete(
|
|
137
|
+
makeResult({
|
|
138
|
+
contextState: {
|
|
139
|
+
usedPercentage: 50,
|
|
140
|
+
remainingPercentage: 50,
|
|
141
|
+
usedTokens: 5000,
|
|
142
|
+
maxTokens: 10000,
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
expect(mgr.contextState.percentage).toBe(50);
|
|
147
|
+
expect(mgr.contextState.usedTokens).toBe(5000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── Messages ──────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
it('addMessage appends to messages', () => {
|
|
153
|
+
const mgr = new TuiStateManager();
|
|
154
|
+
mgr.addEntry({ role: 'user', content: 'hello' } as never);
|
|
155
|
+
mgr.addEntry({ role: 'assistant', content: 'world' } as never);
|
|
156
|
+
expect(mgr.history).toHaveLength(2);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('addEntry windows to MAX_RENDERED_MESSAGES', () => {
|
|
160
|
+
const mgr = new TuiStateManager();
|
|
161
|
+
for (let i = 0; i < 110; i++) {
|
|
162
|
+
mgr.addEntry({
|
|
163
|
+
id: `${i}`,
|
|
164
|
+
timestamp: new Date(),
|
|
165
|
+
category: 'chat',
|
|
166
|
+
type: 'user',
|
|
167
|
+
data: { content: `msg ${i}` },
|
|
168
|
+
} as never);
|
|
169
|
+
}
|
|
170
|
+
expect(mgr.history).toHaveLength(100);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('syncMessages replaces all messages', () => {
|
|
174
|
+
const mgr = new TuiStateManager();
|
|
175
|
+
mgr.addEntry({ role: 'user', content: 'old' } as never);
|
|
176
|
+
mgr.syncHistory([
|
|
177
|
+
{ role: 'user', content: 'new1' } as never,
|
|
178
|
+
{ role: 'assistant', content: 'new2' } as never,
|
|
179
|
+
]);
|
|
180
|
+
expect(mgr.history).toHaveLength(2);
|
|
181
|
+
expect((mgr.history[0]! as unknown as { content: string }).content).toBe('new1');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── onChange notification ──────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
it('calls onChange on every non-debounced state change', () => {
|
|
187
|
+
const mgr = new TuiStateManager();
|
|
188
|
+
const onChange = vi.fn();
|
|
189
|
+
mgr.onChange = onChange;
|
|
190
|
+
|
|
191
|
+
mgr.onTextDelta('hi'); // debounced — does NOT call onChange immediately
|
|
192
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
193
|
+
mgr.onThinking(true); // flushes debounce timer
|
|
194
|
+
mgr.addEntry({ role: 'user', content: 'test' } as never);
|
|
195
|
+
mgr.setAborting(true);
|
|
196
|
+
mgr.setPendingPrompt('queued');
|
|
197
|
+
mgr.setContextState({ percentage: 50, usedTokens: 5000, maxTokens: 10000 });
|
|
198
|
+
|
|
199
|
+
expect(onChange).toHaveBeenCalledTimes(6);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('onTextDelta debounces notify calls', async () => {
|
|
203
|
+
const mgr = new TuiStateManager();
|
|
204
|
+
const onChange = vi.fn();
|
|
205
|
+
mgr.onChange = onChange;
|
|
206
|
+
|
|
207
|
+
mgr.onTextDelta('a');
|
|
208
|
+
mgr.onTextDelta('b');
|
|
209
|
+
mgr.onTextDelta('c');
|
|
210
|
+
|
|
211
|
+
expect(onChange).toHaveBeenCalledTimes(0);
|
|
212
|
+
expect(mgr.streamingText).toBe('abc');
|
|
213
|
+
|
|
214
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
215
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not crash when onChange is null', () => {
|
|
219
|
+
const mgr = new TuiStateManager();
|
|
220
|
+
expect(() => mgr.onTextDelta('hi')).not.toThrow();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('stores SDK execution workspace snapshots and preserves selected entry across updates', () => {
|
|
224
|
+
const mgr = new TuiStateManager();
|
|
225
|
+
const snapshot = {
|
|
226
|
+
sessionId: 'session_1',
|
|
227
|
+
selectedEntryId: 'main:session_1',
|
|
228
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
229
|
+
entries: [
|
|
230
|
+
{
|
|
231
|
+
id: 'main:session_1',
|
|
232
|
+
sourceId: 'session_1',
|
|
233
|
+
kind: 'main_thread',
|
|
234
|
+
origin: { kind: 'user_prompt', sessionId: 'session_1' },
|
|
235
|
+
status: 'idle',
|
|
236
|
+
title: 'Main thread',
|
|
237
|
+
unread: false,
|
|
238
|
+
attention: 'none',
|
|
239
|
+
visibility: 'default',
|
|
240
|
+
updatedAt: '2026-05-09T00:00:00.000Z',
|
|
241
|
+
controls: ['select'],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: 'task:agent_1',
|
|
245
|
+
sourceId: 'agent_1',
|
|
246
|
+
kind: 'background_task',
|
|
247
|
+
origin: { kind: 'slash_command', sessionId: 'session_1' },
|
|
248
|
+
taskKind: 'agent',
|
|
249
|
+
status: 'running',
|
|
250
|
+
title: 'Explore',
|
|
251
|
+
unread: false,
|
|
252
|
+
attention: 'none',
|
|
253
|
+
visibility: 'default',
|
|
254
|
+
updatedAt: '2026-05-09T00:00:01.000Z',
|
|
255
|
+
controls: ['select', 'cancel'],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
} as const;
|
|
259
|
+
|
|
260
|
+
mgr.syncExecutionWorkspaceSnapshot(snapshot);
|
|
261
|
+
mgr.selectExecutionWorkspaceEntry('task:agent_1');
|
|
262
|
+
mgr.syncExecutionWorkspaceSnapshot({ ...snapshot, updatedAt: '2026-05-09T00:00:02.000Z' });
|
|
263
|
+
|
|
264
|
+
expect(mgr.selectedExecutionEntryId).toBe('task:agent_1');
|
|
265
|
+
expect(mgr.executionWorkspaceSnapshot?.selectedEntryId).toBe('task:agent_1');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// ── Display order: Tool → Robota ──────────────────────────────
|
|
269
|
+
|
|
270
|
+
it('streaming state is cleared on complete (tools moved to messages)', () => {
|
|
271
|
+
const mgr = new TuiStateManager();
|
|
272
|
+
mgr.onThinking(true);
|
|
273
|
+
mgr.onTextDelta('streaming response');
|
|
274
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'f.ts', isRunning: true });
|
|
275
|
+
mgr.onToolEnd({ toolName: 'Read', firstArg: 'f.ts', isRunning: false, result: 'success' });
|
|
276
|
+
|
|
277
|
+
expect(mgr.streamingText).toBe('streaming response');
|
|
278
|
+
expect(mgr.activeTools).toHaveLength(1);
|
|
279
|
+
|
|
280
|
+
mgr.onComplete(makeResult());
|
|
281
|
+
|
|
282
|
+
// After complete: streaming cleared, tools cleared
|
|
283
|
+
expect(mgr.streamingText).toBe('');
|
|
284
|
+
expect(mgr.activeTools).toEqual([]);
|
|
285
|
+
// Tool info is now in InteractiveSession's messages (not managed here)
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('streaming state is cleared on abort (tools moved to messages)', () => {
|
|
289
|
+
const mgr = new TuiStateManager();
|
|
290
|
+
mgr.onThinking(true);
|
|
291
|
+
mgr.onTextDelta('partial');
|
|
292
|
+
mgr.onToolStart({ toolName: 'Bash', firstArg: 'ls', isRunning: true });
|
|
293
|
+
|
|
294
|
+
mgr.onInterrupted();
|
|
295
|
+
|
|
296
|
+
expect(mgr.streamingText).toBe('');
|
|
297
|
+
expect(mgr.activeTools).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ── Full lifecycle: streaming → completion message order ──────
|
|
301
|
+
|
|
302
|
+
it('during streaming: tools and text visible in StreamingIndicator state', () => {
|
|
303
|
+
const mgr = new TuiStateManager();
|
|
304
|
+
|
|
305
|
+
// Execution starts
|
|
306
|
+
mgr.onThinking(true);
|
|
307
|
+
|
|
308
|
+
// Tools arrive
|
|
309
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'file.ts', isRunning: true });
|
|
310
|
+
mgr.onToolEnd({ toolName: 'Read', firstArg: 'file.ts', isRunning: false, result: 'success' });
|
|
311
|
+
mgr.onToolStart({ toolName: 'Edit', firstArg: 'file.ts', isRunning: true });
|
|
312
|
+
|
|
313
|
+
// Streaming text arrives
|
|
314
|
+
mgr.onTextDelta('Here is the ');
|
|
315
|
+
mgr.onTextDelta('result');
|
|
316
|
+
|
|
317
|
+
// During streaming: both active
|
|
318
|
+
expect(mgr.activeTools).toHaveLength(2);
|
|
319
|
+
expect(mgr.streamingText).toBe('Here is the result');
|
|
320
|
+
expect(mgr.isThinking).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('after completion: streaming cleared, messages synced from session', () => {
|
|
324
|
+
const mgr = new TuiStateManager();
|
|
325
|
+
|
|
326
|
+
mgr.onThinking(true);
|
|
327
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: 'f.ts', isRunning: true });
|
|
328
|
+
mgr.onToolEnd({ toolName: 'Read', firstArg: 'f.ts', isRunning: false, result: 'success' });
|
|
329
|
+
mgr.onTextDelta('response text');
|
|
330
|
+
|
|
331
|
+
// Complete event fires, then thinking ends
|
|
332
|
+
mgr.onComplete(makeResult());
|
|
333
|
+
mgr.onThinking(false);
|
|
334
|
+
|
|
335
|
+
// StreamingIndicator state: cleared
|
|
336
|
+
expect(mgr.streamingText).toBe('');
|
|
337
|
+
expect(mgr.activeTools).toEqual([]);
|
|
338
|
+
expect(mgr.isThinking).toBe(false);
|
|
339
|
+
|
|
340
|
+
// Simulate InteractiveSession history entries being synced
|
|
341
|
+
mgr.syncHistory([
|
|
342
|
+
{ id: '1', timestamp: new Date(), category: 'chat', type: 'user' } as never,
|
|
343
|
+
{ id: '2', timestamp: new Date(), category: 'event', type: 'tool-summary' } as never,
|
|
344
|
+
{ id: '3', timestamp: new Date(), category: 'chat', type: 'assistant' } as never,
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
// MessageList now has correct order: user → tool-summary → assistant
|
|
348
|
+
expect(mgr.history).toHaveLength(3);
|
|
349
|
+
expect(mgr.history[0]!.type).toBe('user');
|
|
350
|
+
expect(mgr.history[1]!.type).toBe('tool-summary');
|
|
351
|
+
expect(mgr.history[2]!.type).toBe('assistant');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('after abort: streaming cleared, messages synced with tool → robota → system', () => {
|
|
355
|
+
const mgr = new TuiStateManager();
|
|
356
|
+
|
|
357
|
+
mgr.onThinking(true);
|
|
358
|
+
mgr.onToolStart({ toolName: 'Bash', firstArg: 'ls', isRunning: true });
|
|
359
|
+
mgr.onTextDelta('partial answer');
|
|
360
|
+
|
|
361
|
+
mgr.onInterrupted();
|
|
362
|
+
|
|
363
|
+
expect(mgr.streamingText).toBe('');
|
|
364
|
+
expect(mgr.activeTools).toEqual([]);
|
|
365
|
+
|
|
366
|
+
// Simulate InteractiveSession history entries synced after abort
|
|
367
|
+
mgr.syncHistory([
|
|
368
|
+
{ id: '1', timestamp: new Date(), category: 'chat', type: 'user' } as never,
|
|
369
|
+
{ id: '2', timestamp: new Date(), category: 'event', type: 'tool-summary' } as never,
|
|
370
|
+
{ id: '3', timestamp: new Date(), category: 'chat', type: 'assistant' } as never,
|
|
371
|
+
{ id: '4', timestamp: new Date(), category: 'chat', type: 'system' } as never,
|
|
372
|
+
]);
|
|
373
|
+
|
|
374
|
+
// Order: user → tool-summary → assistant → system
|
|
375
|
+
expect(mgr.history).toHaveLength(4);
|
|
376
|
+
expect(mgr.history[0]!.type).toBe('user');
|
|
377
|
+
expect(mgr.history[1]!.type).toBe('tool-summary');
|
|
378
|
+
expect(mgr.history[2]!.type).toBe('assistant');
|
|
379
|
+
expect(mgr.history[3]!.type).toBe('system');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('next execution clears previous tools from StreamingIndicator', () => {
|
|
383
|
+
const mgr = new TuiStateManager();
|
|
384
|
+
|
|
385
|
+
// First execution
|
|
386
|
+
mgr.onThinking(true);
|
|
387
|
+
mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
|
|
388
|
+
mgr.onComplete(makeResult());
|
|
389
|
+
expect(mgr.activeTools).toEqual([]);
|
|
390
|
+
|
|
391
|
+
// Second execution starts
|
|
392
|
+
mgr.onThinking(true);
|
|
393
|
+
// Previous tools should not reappear
|
|
394
|
+
expect(mgr.activeTools).toEqual([]);
|
|
395
|
+
|
|
396
|
+
// New tools for second execution
|
|
397
|
+
mgr.onToolStart({ toolName: 'Write', firstArg: 'new.ts', isRunning: true });
|
|
398
|
+
expect(mgr.activeTools).toHaveLength(1);
|
|
399
|
+
expect(mgr.activeTools[0]!.toolName).toBe('Write');
|
|
400
|
+
});
|
|
401
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { IExecutionWorkspaceEntry } from '@robota-sdk/agent-framework';
|
|
2
|
+
import { formatExecutionWorkspaceEntryRow } from './execution-workspace-view-model.js';
|
|
3
|
+
|
|
4
|
+
export interface IBackgroundTaskRow {
|
|
5
|
+
connector: '├' | '└';
|
|
6
|
+
marker: '□' | '■';
|
|
7
|
+
color: string;
|
|
8
|
+
label: string;
|
|
9
|
+
segments: string[];
|
|
10
|
+
preview?: string;
|
|
11
|
+
accessibleText: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IBackgroundTaskRowOptions {
|
|
15
|
+
isLast?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatBackgroundTaskRow(
|
|
19
|
+
entry: IExecutionWorkspaceEntry,
|
|
20
|
+
options: IBackgroundTaskRowOptions = {},
|
|
21
|
+
): IBackgroundTaskRow {
|
|
22
|
+
const row = formatExecutionWorkspaceEntryRow(entry);
|
|
23
|
+
const marker = isActiveEntry(entry) ? '□' : '■';
|
|
24
|
+
const segments = [row.statusLabel, row.subtitle].filter(
|
|
25
|
+
(segment): segment is string => typeof segment === 'string' && segment.length > 0,
|
|
26
|
+
);
|
|
27
|
+
return {
|
|
28
|
+
connector: options.isLast === false ? '├' : '└',
|
|
29
|
+
marker,
|
|
30
|
+
color: row.color,
|
|
31
|
+
label: row.title,
|
|
32
|
+
segments,
|
|
33
|
+
preview: row.preview,
|
|
34
|
+
accessibleText: [
|
|
35
|
+
`${options.isLast === false ? '├' : '└'} ${marker} ${row.title}`,
|
|
36
|
+
...segments,
|
|
37
|
+
row.preview,
|
|
38
|
+
]
|
|
39
|
+
.filter((part): part is string => typeof part === 'string' && part.length > 0)
|
|
40
|
+
.join(' · '),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isActiveEntry(entry: IExecutionWorkspaceEntry): boolean {
|
|
45
|
+
return (
|
|
46
|
+
entry.status === 'active' ||
|
|
47
|
+
entry.status === 'queued' ||
|
|
48
|
+
entry.status === 'running' ||
|
|
49
|
+
entry.status === 'waiting_permission' ||
|
|
50
|
+
entry.status === 'sleeping'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { TUniversalValue } from '@robota-sdk/agent-core';
|
|
2
|
+
|
|
3
|
+
const MAX_PREVIEW_LINES = 4;
|
|
4
|
+
const SUCCESS_EXIT_CODE = 0;
|
|
5
|
+
const COMMAND_TOOL_NAMES = new Set(['Bash', 'BackgroundProcess']);
|
|
6
|
+
|
|
7
|
+
export interface ICommandOutputInput {
|
|
8
|
+
toolName: string;
|
|
9
|
+
firstArg?: string;
|
|
10
|
+
isRunning?: boolean;
|
|
11
|
+
result?: string;
|
|
12
|
+
toolResultData?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ICommandOutputSummary {
|
|
16
|
+
status: 'success' | 'error';
|
|
17
|
+
statusLabel: string;
|
|
18
|
+
previewLines: string[];
|
|
19
|
+
omittedLineCount: number;
|
|
20
|
+
transcriptHint?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatCommandOutputSummary(
|
|
24
|
+
tool: ICommandOutputInput,
|
|
25
|
+
): ICommandOutputSummary | undefined {
|
|
26
|
+
if (!COMMAND_TOOL_NAMES.has(tool.toolName) || !tool.toolResultData) return undefined;
|
|
27
|
+
|
|
28
|
+
const parsed = parseToolResultData(tool.toolResultData);
|
|
29
|
+
const exitCode = getNumberValue(parsed, 'exitCode');
|
|
30
|
+
const successValue = getBooleanValue(parsed, 'success');
|
|
31
|
+
const output = buildOutputText(tool.toolResultData, parsed);
|
|
32
|
+
const lines = trimTrailingBlankLines(splitOutputLines(output));
|
|
33
|
+
const previewLines = lines.slice(0, MAX_PREVIEW_LINES);
|
|
34
|
+
const omittedLineCount = Math.max(0, lines.length - previewLines.length);
|
|
35
|
+
const isFailed =
|
|
36
|
+
tool.result === 'error' ||
|
|
37
|
+
successValue === false ||
|
|
38
|
+
(exitCode !== undefined && exitCode !== SUCCESS_EXIT_CODE);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
status: isFailed ? 'error' : 'success',
|
|
42
|
+
statusLabel: formatStatusLabel(isFailed, exitCode),
|
|
43
|
+
previewLines,
|
|
44
|
+
omittedLineCount,
|
|
45
|
+
transcriptHint:
|
|
46
|
+
omittedLineCount > 0
|
|
47
|
+
? `... +${omittedLineCount} lines (full output in session transcript)`
|
|
48
|
+
: undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseToolResultData(value: string): TUniversalValue {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(value) as TUniversalValue;
|
|
55
|
+
} catch {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildOutputText(raw: string, parsed: TUniversalValue): string {
|
|
61
|
+
if (!isUniversalObject(parsed)) return raw;
|
|
62
|
+
|
|
63
|
+
const output = getStringValue(parsed, 'output');
|
|
64
|
+
if (output !== undefined) return output;
|
|
65
|
+
|
|
66
|
+
const stdout = getStringValue(parsed, 'stdout');
|
|
67
|
+
const stderr = getStringValue(parsed, 'stderr');
|
|
68
|
+
const error = getStringValue(parsed, 'error');
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
if (stdout) lines.push(stdout);
|
|
71
|
+
if (stderr) lines.push(prefixLines(stderr, '[stderr] '));
|
|
72
|
+
if (!stdout && !stderr && error) lines.push(error);
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatStatusLabel(isFailed: boolean, exitCode: number | undefined): string {
|
|
77
|
+
if (exitCode !== undefined && exitCode !== SUCCESS_EXIT_CODE) return `exit ${exitCode}`;
|
|
78
|
+
return isFailed ? 'error' : 'ok';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function splitOutputLines(output: string): string[] {
|
|
82
|
+
if (!output) return [];
|
|
83
|
+
return output.replace(/\r\n/g, '\n').split('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function trimTrailingBlankLines(lines: string[]): string[] {
|
|
87
|
+
let end = lines.length;
|
|
88
|
+
while (end > 0 && lines[end - 1]!.trim().length === 0) {
|
|
89
|
+
end -= 1;
|
|
90
|
+
}
|
|
91
|
+
return lines.slice(0, end);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function prefixLines(value: string, prefix: string): string {
|
|
95
|
+
return splitOutputLines(value)
|
|
96
|
+
.map((line) => `${prefix}${line}`)
|
|
97
|
+
.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isUniversalObject(value: TUniversalValue): value is Record<string, TUniversalValue> {
|
|
101
|
+
return (
|
|
102
|
+
typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getStringValue(source: TUniversalValue, key: string): string | undefined {
|
|
107
|
+
if (!isUniversalObject(source)) return undefined;
|
|
108
|
+
const value = source[key];
|
|
109
|
+
return typeof value === 'string' ? value : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getNumberValue(source: TUniversalValue, key: string): number | undefined {
|
|
113
|
+
if (!isUniversalObject(source)) return undefined;
|
|
114
|
+
const value = source[key];
|
|
115
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getBooleanValue(source: TUniversalValue, key: string): boolean | undefined {
|
|
119
|
+
if (!isUniversalObject(source)) return undefined;
|
|
120
|
+
const value = source[key];
|
|
121
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
122
|
+
}
|