tinker-agent 2.6.0 → 2.7.0
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/CHANGELOG.md +24 -1
- package/package.json +1 -1
- package/src/agent/loop.ts +22 -0
- package/src/agent/runtime-session.ts +158 -0
- package/src/cli/tui-runner.tsx +14 -1
- package/src/events/stdout-event-printer.ts +14 -0
- package/src/events/types.ts +9 -0
- package/src/memory/contracts.ts +46 -0
- package/src/memory/memory-coordinator.ts +445 -4
- package/src/memory/memory-create-tool.ts +117 -0
- package/src/memory/memory-delete-tool.ts +88 -0
- package/src/memory/memory-store.ts +239 -0
- package/src/memory/memory-update-tool.ts +142 -0
- package/src/observation/observation-builder.ts +70 -0
- package/src/session/session-tool-result-codec.ts +4 -0
- package/src/tools/ask-user.ts +100 -0
- package/src/tools/registry.ts +30 -0
- package/src/tools/types.ts +71 -0
- package/src/tui/app.tsx +33 -5
- package/src/tui/components/ask-user.tsx +61 -0
- package/src/tui/components/footer.tsx +14 -1
- package/src/tui/components/resume-session-picker.tsx +118 -37
- package/src/tui/event-store.ts +57 -0
- package/src/tui/tui-session-controller.ts +8 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
2
|
+
import {
|
|
3
|
+
defineToolExecutor,
|
|
4
|
+
type AskUserRawResult,
|
|
5
|
+
type AskUserRequest,
|
|
6
|
+
type ToolExecutionContext,
|
|
7
|
+
type ToolExecutor,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export function createAskUserToolExecutor(): ToolExecutor {
|
|
11
|
+
return defineToolExecutor("ask_user", {
|
|
12
|
+
definition: {
|
|
13
|
+
name: "AskUser",
|
|
14
|
+
description:
|
|
15
|
+
"Ask the user one multiple-choice question when a material ambiguity prevents correct progress. Investigate the conversation and workspace first. Provide 2-6 options, each as a complete answer the user can select. The user may select one option or dismiss the question. If dismissed, use your own judgment and do not immediately repeat the same question. Call AskUser alone, without other tool calls in the same response.",
|
|
16
|
+
parameters: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
question: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "The question shown to the user.",
|
|
22
|
+
},
|
|
23
|
+
options: {
|
|
24
|
+
type: "array",
|
|
25
|
+
minItems: 2,
|
|
26
|
+
maxItems: 6,
|
|
27
|
+
items: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
description: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "An answer the user can select.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
required: ["description"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ["question", "options"],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
async execute(
|
|
43
|
+
args,
|
|
44
|
+
_call,
|
|
45
|
+
context: ToolExecutionContext,
|
|
46
|
+
): Promise<AskUserRawResult> {
|
|
47
|
+
throwIfTurnCancelled(context.signal);
|
|
48
|
+
const parsed = parseAskUserArgs(args);
|
|
49
|
+
if (!parsed.ok) {
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
if (context.askUser === undefined) {
|
|
53
|
+
return { ok: false, error: "AskUser interaction is unavailable." };
|
|
54
|
+
}
|
|
55
|
+
const response = await context.askUser(parsed.request);
|
|
56
|
+
throwIfTurnCancelled(context.signal);
|
|
57
|
+
return response.outcome === "selected"
|
|
58
|
+
? { ok: true, outcome: "selected", answer: response.answer }
|
|
59
|
+
: { ok: true, outcome: "dismissed" };
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseAskUserArgs(
|
|
65
|
+
args: unknown,
|
|
66
|
+
): { ok: true; request: AskUserRequest } | { ok: false; error: string } {
|
|
67
|
+
if (!isRecord(args)) {
|
|
68
|
+
return { ok: false, error: "AskUser arguments must be an object." };
|
|
69
|
+
}
|
|
70
|
+
if (typeof args.question !== "string") {
|
|
71
|
+
return { ok: false, error: "AskUser question must be a string." };
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(args.options)) {
|
|
74
|
+
return { ok: false, error: "AskUser options must be an array." };
|
|
75
|
+
}
|
|
76
|
+
if (args.options.length < 2 || args.options.length > 6) {
|
|
77
|
+
return { ok: false, error: "AskUser options must contain between 2 and 6 items." };
|
|
78
|
+
}
|
|
79
|
+
const options: { description: string }[] = [];
|
|
80
|
+
for (const [index, option] of args.options.entries()) {
|
|
81
|
+
if (!isRecord(option) || typeof option.description !== "string") {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
error: `AskUser option ${index + 1} must be an object with a string description.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
options.push({ description: option.description });
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
request: Object.freeze({
|
|
92
|
+
question: args.question,
|
|
93
|
+
options: Object.freeze(options.map((option) => Object.freeze(option))),
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
package/src/tools/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAskUserToolExecutor } from "./ask-user";
|
|
1
2
|
import { createBashToolExecutor } from "./bash";
|
|
2
3
|
import { ShellTaskManager } from "./bash-task";
|
|
3
4
|
import { createCwdState } from "./cwd-state";
|
|
@@ -86,6 +87,11 @@ export class ToolRuntime {
|
|
|
86
87
|
): Promise<"allow" | "deny">;
|
|
87
88
|
},
|
|
88
89
|
private readonly contextMaintenance?: ContextMaintenanceHandle,
|
|
90
|
+
private readonly askUser?: (
|
|
91
|
+
call: ToolCall,
|
|
92
|
+
request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
|
|
93
|
+
signal: AbortSignal,
|
|
94
|
+
) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>,
|
|
89
95
|
) {}
|
|
90
96
|
|
|
91
97
|
async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
|
|
@@ -117,6 +123,9 @@ export class ToolRuntime {
|
|
|
117
123
|
...(this.contextMaintenance === undefined
|
|
118
124
|
? {}
|
|
119
125
|
: { contextMaintenance: this.contextMaintenance }),
|
|
126
|
+
...(this.askUser === undefined
|
|
127
|
+
? {}
|
|
128
|
+
: { askUser: (request) => this.askUser!(call, request, context.signal) }),
|
|
120
129
|
...(this.bashGuard === undefined
|
|
121
130
|
? {}
|
|
122
131
|
: {
|
|
@@ -173,9 +182,17 @@ export function createDefaultTooling(options: {
|
|
|
173
182
|
toolingConfig?: PublicToolingConfig;
|
|
174
183
|
memorySearch?: ToolExecutor;
|
|
175
184
|
memoryGet?: ToolExecutor;
|
|
185
|
+
memoryCreate?: ToolExecutor;
|
|
186
|
+
memoryUpdate?: ToolExecutor;
|
|
187
|
+
memoryDelete?: ToolExecutor;
|
|
176
188
|
enableTurnUndo?: boolean;
|
|
177
189
|
imageAssetStore?: ImageAssetStore;
|
|
178
190
|
supportsViewImage?: boolean;
|
|
191
|
+
askUser?: (
|
|
192
|
+
call: ToolCall,
|
|
193
|
+
request: Parameters<NonNullable<ToolExecutionContext["askUser"]>>[0],
|
|
194
|
+
signal: AbortSignal,
|
|
195
|
+
) => ReturnType<NonNullable<ToolExecutionContext["askUser"]>>;
|
|
179
196
|
bashGuard?: {
|
|
180
197
|
readonly surface: "tui" | "one-shot";
|
|
181
198
|
confirm(
|
|
@@ -201,6 +218,9 @@ export function createDefaultTooling(options: {
|
|
|
201
218
|
...(options.homeRoot === undefined ? {} : { homeRoot: options.homeRoot }),
|
|
202
219
|
});
|
|
203
220
|
|
|
221
|
+
if (options.askUser !== undefined) {
|
|
222
|
+
registry.register(createAskUserToolExecutor());
|
|
223
|
+
}
|
|
204
224
|
registry.register(
|
|
205
225
|
createGlobToolExecutor({
|
|
206
226
|
workspaceRoot: options.workspaceRoot,
|
|
@@ -247,6 +267,15 @@ export function createDefaultTooling(options: {
|
|
|
247
267
|
if (options.memoryGet !== undefined) {
|
|
248
268
|
registry.register(options.memoryGet);
|
|
249
269
|
}
|
|
270
|
+
if (options.memoryCreate !== undefined) {
|
|
271
|
+
registry.register(options.memoryCreate);
|
|
272
|
+
}
|
|
273
|
+
if (options.memoryUpdate !== undefined) {
|
|
274
|
+
registry.register(options.memoryUpdate);
|
|
275
|
+
}
|
|
276
|
+
if (options.memoryDelete !== undefined) {
|
|
277
|
+
registry.register(options.memoryDelete);
|
|
278
|
+
}
|
|
250
279
|
if (options.skillCatalog !== undefined) {
|
|
251
280
|
if (options.skillCatalog.skills.size === 0) {
|
|
252
281
|
throw new Error("An empty Agent Skill catalog must not register tooling.");
|
|
@@ -321,6 +350,7 @@ export function createDefaultTooling(options: {
|
|
|
321
350
|
registry,
|
|
322
351
|
options.bashGuard,
|
|
323
352
|
options.runtimeSession.contextMaintenance,
|
|
353
|
+
options.askUser,
|
|
324
354
|
),
|
|
325
355
|
snapshots,
|
|
326
356
|
taskManager,
|
package/src/tools/types.ts
CHANGED
|
@@ -428,6 +428,58 @@ export type MemoryGetRawResult =
|
|
|
428
428
|
error: string;
|
|
429
429
|
};
|
|
430
430
|
|
|
431
|
+
export type MemoryCreateRawResult =
|
|
432
|
+
| {
|
|
433
|
+
ok: true;
|
|
434
|
+
status: "created" | "already_exists";
|
|
435
|
+
memoryId: string;
|
|
436
|
+
createdAt: string;
|
|
437
|
+
}
|
|
438
|
+
| {
|
|
439
|
+
ok: false;
|
|
440
|
+
error: string;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export type MemoryUpdateRawResult =
|
|
444
|
+
| {
|
|
445
|
+
ok: true;
|
|
446
|
+
status: "updated";
|
|
447
|
+
memoryId: string;
|
|
448
|
+
}
|
|
449
|
+
| {
|
|
450
|
+
ok: false;
|
|
451
|
+
code: "memory_not_found";
|
|
452
|
+
error: string;
|
|
453
|
+
}
|
|
454
|
+
| {
|
|
455
|
+
ok: false;
|
|
456
|
+
code: "memory_duplicate";
|
|
457
|
+
conflictMemoryId: string;
|
|
458
|
+
error: string;
|
|
459
|
+
}
|
|
460
|
+
| {
|
|
461
|
+
ok: false;
|
|
462
|
+
code?: undefined;
|
|
463
|
+
error: string;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
export type MemoryDeleteRawResult =
|
|
467
|
+
| {
|
|
468
|
+
ok: true;
|
|
469
|
+
status: "deleted";
|
|
470
|
+
memoryId: string;
|
|
471
|
+
}
|
|
472
|
+
| {
|
|
473
|
+
ok: false;
|
|
474
|
+
code: "memory_not_found";
|
|
475
|
+
error: string;
|
|
476
|
+
}
|
|
477
|
+
| {
|
|
478
|
+
ok: false;
|
|
479
|
+
code?: undefined;
|
|
480
|
+
error: string;
|
|
481
|
+
};
|
|
482
|
+
|
|
431
483
|
export type SkillRawResult =
|
|
432
484
|
| {
|
|
433
485
|
ok: true;
|
|
@@ -488,6 +540,20 @@ export type WaitRawResult =
|
|
|
488
540
|
error: string;
|
|
489
541
|
};
|
|
490
542
|
|
|
543
|
+
export type AskUserRequest = {
|
|
544
|
+
readonly question: string;
|
|
545
|
+
readonly options: readonly { readonly description: string }[];
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
export type AskUserResponse =
|
|
549
|
+
| { readonly outcome: "selected"; readonly answer: string }
|
|
550
|
+
| { readonly outcome: "dismissed" };
|
|
551
|
+
|
|
552
|
+
export type AskUserRawResult =
|
|
553
|
+
| { ok: true; outcome: "selected"; answer: string }
|
|
554
|
+
| { ok: true; outcome: "dismissed" }
|
|
555
|
+
| { ok: false; error: string };
|
|
556
|
+
|
|
491
557
|
export type ToolRawResultByKind = {
|
|
492
558
|
read: ReadFileRawResult;
|
|
493
559
|
view_image: ViewImageRawResult;
|
|
@@ -508,7 +574,11 @@ export type ToolRawResultByKind = {
|
|
|
508
574
|
context_maintenance: ContextMaintenanceRawResult;
|
|
509
575
|
memory_search: MemorySearchRawResult;
|
|
510
576
|
memory_get: MemoryGetRawResult;
|
|
577
|
+
memory_create: MemoryCreateRawResult;
|
|
578
|
+
memory_update: MemoryUpdateRawResult;
|
|
579
|
+
memory_delete: MemoryDeleteRawResult;
|
|
511
580
|
wait: WaitRawResult;
|
|
581
|
+
ask_user: AskUserRawResult;
|
|
512
582
|
skill: SkillRawResult;
|
|
513
583
|
mcp: McpToolRawResult;
|
|
514
584
|
generic: GenericToolRawResult;
|
|
@@ -553,6 +623,7 @@ export function defineToolExecutor<TKind extends ToolRawResultKind>(
|
|
|
553
623
|
|
|
554
624
|
export type ToolExecutionContext = {
|
|
555
625
|
signal: AbortSignal;
|
|
626
|
+
askUser?: (request: AskUserRequest) => Promise<AskUserResponse>;
|
|
556
627
|
contextMaintenance?: ContextMaintenanceHandle;
|
|
557
628
|
confirmBashCommand?: (request: {
|
|
558
629
|
command: string;
|
package/src/tui/app.tsx
CHANGED
|
@@ -25,6 +25,7 @@ import { Footer } from "./components/footer";
|
|
|
25
25
|
import { AssistantMarkdownProvider } from "./components/assistant-markdown";
|
|
26
26
|
import { ContextStatus } from "./components/context-status";
|
|
27
27
|
import { BackgroundTasks } from "./components/background-tasks";
|
|
28
|
+
import { AskUser } from "./components/ask-user";
|
|
28
29
|
import { BashConfirmation } from "./components/bash-confirmation";
|
|
29
30
|
import { Header } from "./components/header";
|
|
30
31
|
import { ModelPicker } from "./components/model-picker";
|
|
@@ -142,6 +143,11 @@ export function App(props: AppProps) {
|
|
|
142
143
|
() => binding.bashGuard(),
|
|
143
144
|
() => binding.bashGuard(),
|
|
144
145
|
);
|
|
146
|
+
const askUser = useSyncExternalStore(
|
|
147
|
+
(listener) => binding.subscribeAskUser(listener),
|
|
148
|
+
() => binding.askUser(),
|
|
149
|
+
() => binding.askUser(),
|
|
150
|
+
);
|
|
145
151
|
const promptScheduler = useSyncExternalStore(
|
|
146
152
|
(listener) => binding.subscribePromptScheduler?.(listener) ?? (() => undefined),
|
|
147
153
|
() => binding.promptScheduler?.() ?? IDLE_PROMPT_SCHEDULER,
|
|
@@ -276,7 +282,7 @@ export function App(props: AppProps) {
|
|
|
276
282
|
setIsCancelling(true);
|
|
277
283
|
setNotice("Cancelling current turn...");
|
|
278
284
|
},
|
|
279
|
-
{ isActive: executionRunning },
|
|
285
|
+
{ isActive: executionRunning && askUser.pending === undefined },
|
|
280
286
|
);
|
|
281
287
|
|
|
282
288
|
const closeResumePicker = () => {
|
|
@@ -906,9 +912,11 @@ export function App(props: AppProps) {
|
|
|
906
912
|
status={
|
|
907
913
|
isCancelling
|
|
908
914
|
? "cancelling"
|
|
909
|
-
:
|
|
910
|
-
? "
|
|
911
|
-
:
|
|
915
|
+
: askUser.pending !== undefined
|
|
916
|
+
? "waiting_for_answer"
|
|
917
|
+
: executionRunning
|
|
918
|
+
? "running"
|
|
919
|
+
: state.status
|
|
912
920
|
}
|
|
913
921
|
workedForMs={state.workedForMs}
|
|
914
922
|
yolo={bashGuard.mode === "yolo"}
|
|
@@ -916,7 +924,26 @@ export function App(props: AppProps) {
|
|
|
916
924
|
/>
|
|
917
925
|
</Box>
|
|
918
926
|
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
|
919
|
-
{
|
|
927
|
+
{askUser.pending !== undefined ? (
|
|
928
|
+
<AskUser
|
|
929
|
+
question={askUser.pending.question}
|
|
930
|
+
options={askUser.pending.options}
|
|
931
|
+
onSelect={(selectedIndex) => {
|
|
932
|
+
void binding
|
|
933
|
+
.resolveAskUser({ outcome: "selected", selectedIndex })
|
|
934
|
+
.catch((error: unknown) =>
|
|
935
|
+
setNotice(`Answer failed: ${errorMessage(error)}`),
|
|
936
|
+
);
|
|
937
|
+
}}
|
|
938
|
+
onDismiss={() => {
|
|
939
|
+
void binding
|
|
940
|
+
.resolveAskUser({ outcome: "dismissed" })
|
|
941
|
+
.catch((error: unknown) =>
|
|
942
|
+
setNotice(`Dismiss failed: ${errorMessage(error)}`),
|
|
943
|
+
);
|
|
944
|
+
}}
|
|
945
|
+
/>
|
|
946
|
+
) : bashGuard.pending !== undefined ? (
|
|
920
947
|
<BashConfirmation
|
|
921
948
|
command={bashGuard.pending.command}
|
|
922
949
|
reason={bashGuard.pending.reason}
|
|
@@ -949,6 +976,7 @@ export function App(props: AppProps) {
|
|
|
949
976
|
isSessionOperation ||
|
|
950
977
|
isCopying ||
|
|
951
978
|
isCancelling ||
|
|
979
|
+
askUser.pending !== undefined ||
|
|
952
980
|
bashGuard.pending !== undefined
|
|
953
981
|
}
|
|
954
982
|
history={props.history}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Box, Text, useInput } from "ink";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
|
|
4
|
+
export type AskUserProps = {
|
|
5
|
+
question: string;
|
|
6
|
+
options: readonly { readonly description: string }[];
|
|
7
|
+
onSelect(selectedIndex: number): void;
|
|
8
|
+
onDismiss(): void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function AskUser(props: AskUserProps) {
|
|
12
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
13
|
+
|
|
14
|
+
useInput((input, key) => {
|
|
15
|
+
if (key.escape) {
|
|
16
|
+
props.onDismiss();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (key.upArrow) {
|
|
20
|
+
setSelectedIndex((current) =>
|
|
21
|
+
current === 0 ? props.options.length - 1 : current - 1,
|
|
22
|
+
);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (key.downArrow) {
|
|
26
|
+
setSelectedIndex((current) => (current + 1) % props.options.length);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.return) {
|
|
30
|
+
props.onSelect(selectedIndex);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (/^[1-6]$/.test(input)) {
|
|
34
|
+
const index = Number(input) - 1;
|
|
35
|
+
if (index < props.options.length) {
|
|
36
|
+
props.onSelect(index);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
|
|
43
|
+
<Text color="cyan" bold>
|
|
44
|
+
Tinker asks
|
|
45
|
+
</Text>
|
|
46
|
+
<Text>{props.question}</Text>
|
|
47
|
+
<Box flexDirection="column" marginTop={1}>
|
|
48
|
+
{props.options.map((option, index) => (
|
|
49
|
+
<Text key={`${index}:${option.description}`}>
|
|
50
|
+
<Text color={index === selectedIndex ? "cyan" : undefined}>
|
|
51
|
+
{index === selectedIndex ? "❯" : " "} {index + 1}. {option.description}
|
|
52
|
+
</Text>
|
|
53
|
+
</Text>
|
|
54
|
+
))}
|
|
55
|
+
</Box>
|
|
56
|
+
<Text dimColor>
|
|
57
|
+
↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc skip
|
|
58
|
+
</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
2
2
|
|
|
3
3
|
export type FooterProps = {
|
|
4
|
-
status:
|
|
4
|
+
status:
|
|
5
|
+
| "idle"
|
|
6
|
+
| "running"
|
|
7
|
+
| "waiting_for_answer"
|
|
8
|
+
| "cancelling"
|
|
9
|
+
| "cancelled"
|
|
10
|
+
| "done"
|
|
11
|
+
| "failed";
|
|
5
12
|
workedForMs?: number;
|
|
6
13
|
yolo?: boolean;
|
|
7
14
|
pendingFollowUps?: number;
|
|
@@ -26,6 +33,12 @@ export function Footer(props: FooterProps) {
|
|
|
26
33
|
return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
if (props.status === "waiting_for_answer") {
|
|
37
|
+
return (
|
|
38
|
+
<StatusMessage variant="info">Waiting for your selection{suffix}</StatusMessage>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
if (props.status === "running") {
|
|
30
43
|
const queued =
|
|
31
44
|
props.pendingFollowUps === undefined || props.pendingFollowUps === 0
|