@agent-native/toolkit 0.204.0 → 0.204.1-nightly-20261008065100
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/agent-native.eject.json +2 -0
- package/dist/app/agent-page/AgentJobsTab.d.ts.map +1 -1
- package/dist/app/agent-page/AgentJobsTab.js +4 -2
- package/dist/app/agent-page/AgentJobsTab.js.map +1 -1
- package/dist/app/agent-page/AutomationDetailsDialog.d.ts +2 -1
- package/dist/app/agent-page/AutomationDetailsDialog.d.ts.map +1 -1
- package/dist/app/agent-page/AutomationDetailsDialog.js +8 -4
- package/dist/app/agent-page/AutomationDetailsDialog.js.map +1 -1
- package/dist/app/agent-page/ScheduledTriggerNotice.d.ts.map +1 -1
- package/dist/app/agent-page/ScheduledTriggerNotice.js +41 -19
- package/dist/app/agent-page/ScheduledTriggerNotice.js.map +1 -1
- package/dist/app/agent-page/use-jobs.d.ts +1 -1
- package/dist/app/agent-page/use-jobs.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +124 -45
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/context.d.ts +22 -1
- package/dist/app/agentkit/react/context.d.ts.map +1 -1
- package/dist/app/agentkit/react/context.js +14 -1
- package/dist/app/agentkit/react/context.js.map +1 -1
- package/dist/app/agentkit/react/root.d.ts +3 -2
- package/dist/app/agentkit/react/root.d.ts.map +1 -1
- package/dist/app/agentkit/react/root.js +2 -2
- package/dist/app/agentkit/react/root.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +32 -2
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +74 -7
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/AgentPanel.js +1 -1
- package/dist/app/chat/AgentPanel.js.map +1 -1
- package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/MultiTabAssistantChat.js +10 -9
- package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
- package/dist/app/chat/RunStuckBanner.d.ts +8 -1
- package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
- package/dist/app/chat/RunStuckBanner.js +68 -6
- package/dist/app/chat/RunStuckBanner.js.map +1 -1
- package/dist/app/chat/chat/run-recovery.d.ts +4 -2
- package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
- package/dist/app/chat/chat/run-recovery.js +3 -3
- package/dist/app/chat/chat/run-recovery.js.map +1 -1
- package/dist/app/chat/chat/surface-types.d.ts +1 -0
- package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
- package/dist/app/chat/composer/use-mention-search.d.ts +1 -0
- package/dist/app/chat/composer/use-mention-search.d.ts.map +1 -1
- package/dist/app/chat/fallback-chat-title.d.ts +2 -0
- package/dist/app/chat/fallback-chat-title.d.ts.map +1 -0
- package/dist/app/chat/fallback-chat-title.js +11 -0
- package/dist/app/chat/fallback-chat-title.js.map +1 -0
- package/dist/app/chat/feedback-report.d.ts +12 -0
- package/dist/app/chat/feedback-report.d.ts.map +1 -0
- package/dist/app/chat/feedback-report.js +21 -0
- package/dist/app/chat/feedback-report.js.map +1 -0
- package/dist/app/chat/reconcile-settled-run.d.ts +15 -0
- package/dist/app/chat/reconcile-settled-run.d.ts.map +1 -0
- package/dist/app/chat/reconcile-settled-run.js +41 -0
- package/dist/app/chat/reconcile-settled-run.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +9 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +9 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +9 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +9 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +9 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +9 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +9 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +9 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +9 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +9 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +9 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +9 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/providers/AppProviders.d.ts.map +1 -1
- package/dist/app/providers/AppProviders.js +7 -1
- package/dist/app/providers/AppProviders.js.map +1 -1
- package/dist/app/settings/shell/pages/audit.d.ts.map +1 -1
- package/dist/app/settings/shell/pages/audit.js +11 -2
- package/dist/app/settings/shell/pages/audit.js.map +1 -1
- package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
- package/dist/app/shared/AppShellSkeleton.js +4 -0
- package/dist/app/shared/AppShellSkeleton.js.map +1 -1
- package/dist/composer/ComposerContextMenu.d.ts +14 -1
- package/dist/composer/ComposerContextMenu.d.ts.map +1 -1
- package/dist/composer/ComposerContextMenu.js +65 -19
- package/dist/composer/ComposerContextMenu.js.map +1 -1
- package/dist/composer/MentionPopover.d.ts.map +1 -1
- package/dist/composer/MentionPopover.js +3 -3
- package/dist/composer/MentionPopover.js.map +1 -1
- package/dist/composer/PromptComposer.d.ts +1 -1
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +2 -2
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +92 -36
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/use-mention-search.d.ts +1 -0
- package/dist/composer/use-mention-search.d.ts.map +1 -1
- package/dist/composer/use-mention-search.js +8 -1
- package/dist/composer/use-mention-search.js.map +1 -1
- package/package.json +4 -4
- package/src/app/agent-page/AgentJobsTab.blocked.spec.tsx +43 -0
- package/src/app/agent-page/AgentJobsTab.tsx +14 -5
- package/src/app/agent-page/AutomationDetailsDialog.tsx +22 -8
- package/src/app/agent-page/ScheduledTriggerNotice.spec.tsx +45 -3
- package/src/app/agent-page/ScheduledTriggerNotice.tsx +52 -31
- package/src/app/agent-page/use-jobs.ts +1 -1
- package/src/app/agentkit/react/components.interaction.spec.tsx +311 -1
- package/src/app/agentkit/react/components.spec.tsx +106 -2
- package/src/app/agentkit/react/components.tsx +217 -80
- package/src/app/agentkit/react/context.tsx +36 -0
- package/src/app/agentkit/react/root.tsx +4 -0
- package/src/app/agentkit/react/styles.css +32 -2
- package/src/app/chat/AgentKitAssistantChat.spec.tsx +140 -18
- package/src/app/chat/AgentKitAssistantChat.tsx +123 -7
- package/src/app/chat/AgentPanel.header.spec.ts +43 -7
- package/src/app/chat/AgentPanel.tsx +1 -1
- package/src/app/chat/MultiTabAssistantChat.spec.tsx +34 -0
- package/src/app/chat/MultiTabAssistantChat.tsx +10 -9
- package/src/app/chat/RunStuckBanner.spec.tsx +258 -17
- package/src/app/chat/RunStuckBanner.tsx +107 -5
- package/src/app/chat/chat/run-recovery.tsx +10 -2
- package/src/app/chat/chat/surface-types.ts +1 -0
- package/src/app/chat/fallback-chat-title.ts +10 -0
- package/src/app/chat/feedback-report.spec.ts +45 -0
- package/src/app/chat/feedback-report.ts +27 -0
- package/src/app/chat/reconcile-settled-run.spec.ts +112 -0
- package/src/app/chat/reconcile-settled-run.ts +57 -0
- package/src/app/i18n/catalogs/ar-SA.ts +12 -0
- package/src/app/i18n/catalogs/de-DE.ts +12 -0
- package/src/app/i18n/catalogs/en-US.ts +12 -0
- package/src/app/i18n/catalogs/es-ES.ts +12 -0
- package/src/app/i18n/catalogs/fr-FR.ts +12 -0
- package/src/app/i18n/catalogs/hi-IN.ts +12 -0
- package/src/app/i18n/catalogs/ja-JP.ts +12 -0
- package/src/app/i18n/catalogs/ko-KR.ts +12 -0
- package/src/app/i18n/catalogs/pt-BR.ts +12 -0
- package/src/app/i18n/catalogs/zh-CN.ts +10 -0
- package/src/app/i18n/catalogs/zh-TW.ts +10 -0
- package/src/app/providers/AppProviders.spec.tsx +29 -0
- package/src/app/providers/AppProviders.tsx +13 -0
- package/src/app/settings/shell/pages/audit.spec.tsx +30 -0
- package/src/app/settings/shell/pages/audit.tsx +12 -1
- package/src/app/shared/AppShellSkeleton.tsx +4 -0
- package/src/composer/ComposerContextMenu.tsx +94 -22
- package/src/composer/ComposerPlusMenu.parity.spec.tsx +218 -233
- package/src/composer/MentionPopover.tsx +12 -3
- package/src/composer/PromptComposer.context.spec.tsx +527 -195
- package/src/composer/PromptComposer.tsx +1 -1
- package/src/composer/TiptapComposer.tsx +105 -37
- package/src/composer/use-mention-search.ts +8 -1
|
@@ -86,6 +86,7 @@ import type {
|
|
|
86
86
|
AssistantChatHandle,
|
|
87
87
|
AssistantChatSendOptions,
|
|
88
88
|
} from "./chat/surface-types.js";
|
|
89
|
+
import { fallbackChatTitle } from "./fallback-chat-title.js";
|
|
89
90
|
|
|
90
91
|
type AgentActionScope = NonNullable<AgentChatMessage["actionScope"]>;
|
|
91
92
|
|
|
@@ -2725,15 +2726,14 @@ export function MultiTabAssistantChat({
|
|
|
2725
2726
|
selection: { engine?: string; model?: string },
|
|
2726
2727
|
) => {
|
|
2727
2728
|
void generateTitle(threadId, message, selection).then((title) => {
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
}
|
|
2729
|
+
const resolvedTitle = title ?? fallbackChatTitle(message);
|
|
2730
|
+
if (!resolvedTitle) return;
|
|
2731
|
+
void saveThreadData(threadId, {
|
|
2732
|
+
threadData: "",
|
|
2733
|
+
title: resolvedTitle,
|
|
2734
|
+
preview: message.slice(0, 120),
|
|
2735
|
+
titleSource: title ? "generated" : "fallback",
|
|
2736
|
+
});
|
|
2737
2737
|
});
|
|
2738
2738
|
},
|
|
2739
2739
|
[generateTitle, saveThreadData],
|
|
@@ -2747,6 +2747,7 @@ export function MultiTabAssistantChat({
|
|
|
2747
2747
|
title: string;
|
|
2748
2748
|
preview: string;
|
|
2749
2749
|
messageCount: number;
|
|
2750
|
+
titleSource?: "fallback";
|
|
2750
2751
|
},
|
|
2751
2752
|
) => {
|
|
2752
2753
|
void saveThreadData(threadId, {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
+
import type { RunStuckState } from "@agent-native/core/client/agent-chat";
|
|
3
4
|
import { act } from "react";
|
|
4
5
|
import { createRoot, type Root } from "react-dom/client";
|
|
5
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -8,20 +9,42 @@ import { RunStuckBanner } from "./RunStuckBanner.js";
|
|
|
8
9
|
|
|
9
10
|
const trackEventMock = vi.hoisted(() => vi.fn());
|
|
10
11
|
|
|
12
|
+
const STUCK_STATE: RunStuckState = {
|
|
13
|
+
isStuck: true,
|
|
14
|
+
runId: "run-1",
|
|
15
|
+
status: "running",
|
|
16
|
+
lastProgressAt: 0,
|
|
17
|
+
stuckSinceMs: 90_000,
|
|
18
|
+
lastProgressSeq: 1,
|
|
19
|
+
heartbeatAt: null,
|
|
20
|
+
heartbeatSinceMs: null,
|
|
21
|
+
dispatchMode: null,
|
|
22
|
+
hasInFlightWork: false,
|
|
23
|
+
serverSettled: false,
|
|
24
|
+
statusUnreadable: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SETTLED_STATE: RunStuckState = {
|
|
28
|
+
...STUCK_STATE,
|
|
29
|
+
isStuck: false,
|
|
30
|
+
status: "idle",
|
|
31
|
+
stuckSinceMs: null,
|
|
32
|
+
serverSettled: true,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const hookState = vi.hoisted(() => ({
|
|
36
|
+
current: null as RunStuckState | null,
|
|
37
|
+
awaitingResponse: undefined as boolean | undefined,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
const abortRunMock = vi.hoisted(() => vi.fn(async () => null));
|
|
41
|
+
|
|
11
42
|
vi.mock("@agent-native/core/client/agent-chat", () => ({
|
|
12
|
-
useRunStuckDetection: () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
stuckSinceMs: 90_000,
|
|
18
|
-
lastProgressSeq: 1,
|
|
19
|
-
heartbeatAt: null,
|
|
20
|
-
heartbeatSinceMs: null,
|
|
21
|
-
dispatchMode: null,
|
|
22
|
-
hasInFlightWork: false,
|
|
23
|
-
}),
|
|
24
|
-
useAbortRun: () => vi.fn(),
|
|
43
|
+
useRunStuckDetection: (options: { awaitingResponse?: boolean }) => {
|
|
44
|
+
hookState.awaitingResponse = options.awaitingResponse;
|
|
45
|
+
return hookState.current;
|
|
46
|
+
},
|
|
47
|
+
useAbortRun: () => abortRunMock,
|
|
25
48
|
}));
|
|
26
49
|
|
|
27
50
|
vi.mock("@agent-native/core/client/analytics", () => ({
|
|
@@ -38,28 +61,246 @@ describe("RunStuckBanner", () => {
|
|
|
38
61
|
|
|
39
62
|
beforeEach(() => {
|
|
40
63
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
64
|
+
vi.useFakeTimers({ toFake: ["Date"] });
|
|
65
|
+
vi.setSystemTime(1_000_000);
|
|
66
|
+
trackEventMock.mockClear();
|
|
67
|
+
abortRunMock.mockClear();
|
|
68
|
+
window.localStorage.clear();
|
|
69
|
+
hookState.current = STUCK_STATE;
|
|
41
70
|
container = document.createElement("div");
|
|
42
71
|
document.body.appendChild(container);
|
|
72
|
+
root = createRoot(container);
|
|
43
73
|
});
|
|
44
74
|
|
|
45
75
|
afterEach(() => {
|
|
76
|
+
act(() => root.unmount());
|
|
46
77
|
container.remove();
|
|
78
|
+
vi.useRealTimers();
|
|
47
79
|
vi.unstubAllGlobals();
|
|
48
80
|
});
|
|
49
81
|
|
|
82
|
+
async function render(props: Parameters<typeof RunStuckBanner>[0]) {
|
|
83
|
+
await act(async () => {
|
|
84
|
+
root.render(<RunStuckBanner {...props} />);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
50
88
|
it("reports a stuck run once, even when the chat view remounts", async () => {
|
|
51
89
|
for (let mount = 0; mount < 2; mount += 1) {
|
|
52
|
-
|
|
90
|
+
const remounted = createRoot(document.createElement("div"));
|
|
53
91
|
await act(async () => {
|
|
54
|
-
|
|
92
|
+
remounted.render(<RunStuckBanner threadId="thread-1" />);
|
|
55
93
|
});
|
|
56
|
-
await act(async () =>
|
|
94
|
+
await act(async () => remounted.unmount());
|
|
57
95
|
}
|
|
58
96
|
|
|
59
97
|
expect(trackEventMock).toHaveBeenCalledTimes(1);
|
|
60
98
|
expect(trackEventMock).toHaveBeenCalledWith(
|
|
61
99
|
"agent_chat_stuck_detected",
|
|
62
|
-
expect.objectContaining({
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
runId: "run-1",
|
|
102
|
+
threadId: "thread-1",
|
|
103
|
+
reason: "no_progress",
|
|
104
|
+
dispatchMode: null,
|
|
105
|
+
hasInFlightWork: false,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const isAwaitingResponse = () => true;
|
|
111
|
+
|
|
112
|
+
describe("when the server stops tracking a run the chat shows as running", () => {
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
hookState.current = SETTLED_STATE;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("reloads the thread once and says nothing when that settles the chat", async () => {
|
|
118
|
+
const onServerSettled = vi.fn(async () => "settled" as const);
|
|
119
|
+
|
|
120
|
+
await render({
|
|
121
|
+
threadId: "thread-1",
|
|
122
|
+
onServerSettled,
|
|
123
|
+
isAwaitingResponse,
|
|
124
|
+
});
|
|
125
|
+
await render({
|
|
126
|
+
threadId: "thread-1",
|
|
127
|
+
onServerSettled,
|
|
128
|
+
isAwaitingResponse,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(onServerSettled).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(container.textContent).toBe("");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not reconcile a host that cannot say whether the chat is waiting", async () => {
|
|
136
|
+
const onServerSettled = vi.fn(async () => "settled" as const);
|
|
137
|
+
|
|
138
|
+
await render({ threadId: "thread-1", onServerSettled });
|
|
139
|
+
|
|
140
|
+
expect(onServerSettled).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("does nothing for a chat that is not waiting on a response", async () => {
|
|
144
|
+
const onServerSettled = vi.fn(async () => "settled" as const);
|
|
145
|
+
|
|
146
|
+
await render({
|
|
147
|
+
threadId: "thread-1",
|
|
148
|
+
onServerSettled,
|
|
149
|
+
isAwaitingResponse: () => false,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(onServerSettled).not.toHaveBeenCalled();
|
|
153
|
+
expect(container.textContent).toBe("");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("tells the user when the reload could not settle the chat, and retries at a bounded pace", async () => {
|
|
157
|
+
const onServerSettled = vi.fn(async () => "still_running" as const);
|
|
158
|
+
|
|
159
|
+
await render({
|
|
160
|
+
threadId: "thread-1",
|
|
161
|
+
onServerSettled,
|
|
162
|
+
isAwaitingResponse,
|
|
163
|
+
});
|
|
164
|
+
expect(container.textContent).toContain(
|
|
165
|
+
"agentChat.recovery.statusMismatch",
|
|
166
|
+
);
|
|
167
|
+
expect(container.textContent).toContain("agentChat.recovery.reload");
|
|
168
|
+
expect(container.textContent).not.toContain("recovery.stuckTitle");
|
|
169
|
+
|
|
170
|
+
// Every poll hands the banner a fresh state object.
|
|
171
|
+
vi.setSystemTime(1_015_000);
|
|
172
|
+
hookState.current = { ...SETTLED_STATE };
|
|
173
|
+
await render({
|
|
174
|
+
threadId: "thread-1",
|
|
175
|
+
onServerSettled,
|
|
176
|
+
isAwaitingResponse,
|
|
177
|
+
});
|
|
178
|
+
expect(onServerSettled).toHaveBeenCalledTimes(1);
|
|
179
|
+
|
|
180
|
+
vi.setSystemTime(1_031_000);
|
|
181
|
+
hookState.current = { ...SETTLED_STATE };
|
|
182
|
+
await render({
|
|
183
|
+
threadId: "thread-1",
|
|
184
|
+
onServerSettled,
|
|
185
|
+
isAwaitingResponse,
|
|
186
|
+
});
|
|
187
|
+
expect(onServerSettled).toHaveBeenCalledTimes(2);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("tells the detector whether the chat is waiting, so a settle predating the wait is dropped", async () => {
|
|
191
|
+
await render({ threadId: "thread-1", isAwaitingResponse: () => true });
|
|
192
|
+
expect(hookState.awaitingResponse).toBe(true);
|
|
193
|
+
|
|
194
|
+
await render({ threadId: "thread-1", isAwaitingResponse: () => false });
|
|
195
|
+
expect(hookState.awaitingResponse).toBe(false);
|
|
196
|
+
|
|
197
|
+
await render({ threadId: "thread-1" });
|
|
198
|
+
expect(hookState.awaitingResponse).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("ignores a reload that finishes after the server started a run again", async () => {
|
|
202
|
+
let finishReload: (outcome: "still_running") => void = () => {};
|
|
203
|
+
const onServerSettled = vi.fn(
|
|
204
|
+
() =>
|
|
205
|
+
new Promise<"still_running">((resolve) => {
|
|
206
|
+
finishReload = resolve;
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await render({
|
|
211
|
+
threadId: "thread-1",
|
|
212
|
+
onServerSettled,
|
|
213
|
+
isAwaitingResponse,
|
|
214
|
+
});
|
|
215
|
+
expect(onServerSettled).toHaveBeenCalledTimes(1);
|
|
216
|
+
|
|
217
|
+
hookState.current = {
|
|
218
|
+
...STUCK_STATE,
|
|
219
|
+
isStuck: false,
|
|
220
|
+
serverSettled: false,
|
|
221
|
+
};
|
|
222
|
+
await render({
|
|
223
|
+
threadId: "thread-1",
|
|
224
|
+
onServerSettled,
|
|
225
|
+
isAwaitingResponse,
|
|
226
|
+
});
|
|
227
|
+
await act(async () => finishReload("still_running"));
|
|
228
|
+
|
|
229
|
+
expect(container.textContent).toBe("");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("shows an unreadable status, not a finished chat, when the reload itself fails", async () => {
|
|
233
|
+
const onServerSettled = vi.fn(async () => {
|
|
234
|
+
throw new Error("thread unavailable");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await render({
|
|
238
|
+
threadId: "thread-1",
|
|
239
|
+
onServerSettled,
|
|
240
|
+
isAwaitingResponse,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(container.textContent).toContain(
|
|
244
|
+
"agentChat.recovery.statusUnreadable",
|
|
245
|
+
);
|
|
246
|
+
expect(container.textContent).not.toContain(
|
|
247
|
+
"agentChat.recovery.statusMismatch",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("shows an unreadable status when the run's status cannot be fetched", async () => {
|
|
253
|
+
hookState.current = {
|
|
254
|
+
...STUCK_STATE,
|
|
255
|
+
isStuck: false,
|
|
256
|
+
stuckSinceMs: null,
|
|
257
|
+
statusUnreadable: true,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
await render({ threadId: "thread-1" });
|
|
261
|
+
expect(container.textContent).toContain(
|
|
262
|
+
"agentChat.recovery.statusUnreadable",
|
|
263
|
+
);
|
|
264
|
+
expect(container.textContent).not.toContain("recovery.stuckTitle");
|
|
265
|
+
expect(container.textContent).not.toContain("agentChat.recovery.reload");
|
|
266
|
+
|
|
267
|
+
await render({ threadId: "thread-1", isAwaitingResponse: () => false });
|
|
268
|
+
expect(container.textContent).toBe("");
|
|
269
|
+
|
|
270
|
+
// A host that cannot say whether the chat waits only doubts a run it saw.
|
|
271
|
+
hookState.current = { ...hookState.current!, status: "idle", runId: null };
|
|
272
|
+
await render({ threadId: "thread-1" });
|
|
273
|
+
expect(container.textContent).toBe("");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("shows the unreadable status, not a stuck banner, when the status is unreadable", async () => {
|
|
277
|
+
hookState.current = { ...STUCK_STATE, statusUnreadable: true };
|
|
278
|
+
|
|
279
|
+
await render({ threadId: "thread-1" });
|
|
280
|
+
|
|
281
|
+
expect(container.textContent).toContain(
|
|
282
|
+
"agentChat.recovery.statusUnreadable",
|
|
63
283
|
);
|
|
284
|
+
expect(container.textContent).not.toContain(
|
|
285
|
+
"agentChat.recovery.stuckTitle",
|
|
286
|
+
);
|
|
287
|
+
expect(container.querySelectorAll("button")).toHaveLength(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not auto-abort a run on a stuck verdict it cannot confirm", async () => {
|
|
291
|
+
const props = {
|
|
292
|
+
threadId: "thread-auto-retry",
|
|
293
|
+
autoRetry: true,
|
|
294
|
+
autoRetryOwnerId: "owner-1",
|
|
295
|
+
};
|
|
296
|
+
hookState.current = { ...STUCK_STATE, statusUnreadable: true };
|
|
297
|
+
|
|
298
|
+
await render(props);
|
|
299
|
+
expect(abortRunMock).not.toHaveBeenCalled();
|
|
300
|
+
|
|
301
|
+
hookState.current = { ...STUCK_STATE };
|
|
302
|
+
await render(props);
|
|
303
|
+
await act(async () => {});
|
|
304
|
+
expect(abortRunMock).toHaveBeenCalledWith("run-1", "auto_stuck_retry");
|
|
64
305
|
});
|
|
65
306
|
});
|
|
@@ -18,11 +18,20 @@ export interface RunStuckBannerProps {
|
|
|
18
18
|
hasInFlightWork?: () => boolean;
|
|
19
19
|
isAwaitingResponse?: () => boolean;
|
|
20
20
|
onStuckStateChange?: (state: RunStuckState) => void;
|
|
21
|
+
/**
|
|
22
|
+
* The server has stopped tracking a run this chat still shows as running.
|
|
23
|
+
* Resolves `settled` once the chat no longer shows it running; rejects when
|
|
24
|
+
* the thread could not be refreshed.
|
|
25
|
+
*/
|
|
26
|
+
onServerSettled?: () => Promise<RunReconcileOutcome>;
|
|
21
27
|
autoRetry?: boolean;
|
|
22
28
|
autoRetryOwnerId?: string;
|
|
23
29
|
className?: string;
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
export type RunReconcileOutcome = "settled" | "still_running";
|
|
33
|
+
|
|
34
|
+
const RECONCILE_RETRY_MS = 30_000;
|
|
26
35
|
const AUTO_RETRY_CLAIM_TTL_MS = 5 * 60 * 1000;
|
|
27
36
|
const BACKGROUND_WORKER_FRESH_HEARTBEAT_MS = 30_000;
|
|
28
37
|
|
|
@@ -123,6 +132,7 @@ export function RunStuckBanner({
|
|
|
123
132
|
stuckThresholdMs,
|
|
124
133
|
onRetry,
|
|
125
134
|
onStuckStateChange,
|
|
135
|
+
onServerSettled,
|
|
126
136
|
autoRetry = false,
|
|
127
137
|
autoRetryOwnerId,
|
|
128
138
|
hasInFlightWork,
|
|
@@ -130,9 +140,11 @@ export function RunStuckBanner({
|
|
|
130
140
|
className,
|
|
131
141
|
}: RunStuckBannerProps) {
|
|
132
142
|
const t = useT();
|
|
143
|
+
const chatAwaiting = isAwaitingResponse?.();
|
|
133
144
|
const state = useRunStuckDetection({
|
|
134
145
|
threadId,
|
|
135
146
|
enabled,
|
|
147
|
+
awaitingResponse: chatAwaiting,
|
|
136
148
|
stuckThresholdMs,
|
|
137
149
|
apiUrl,
|
|
138
150
|
});
|
|
@@ -148,9 +160,13 @@ export function RunStuckBanner({
|
|
|
148
160
|
const backgroundWorkerStillAlive = isFreshBackgroundWorker(state);
|
|
149
161
|
const inFlightWork =
|
|
150
162
|
state.hasInFlightWork === true || (hasInFlightWork?.() ?? false);
|
|
151
|
-
const awaitingResponse =
|
|
163
|
+
const awaitingResponse = chatAwaiting ?? true;
|
|
164
|
+
// While the status is unreadable `isStuck` is the last answer carried forward
|
|
165
|
+
// by the clock, not a fresh one: only the unreadable notice may speak, and
|
|
166
|
+
// nothing may abort a run on it.
|
|
167
|
+
const isStuck = state.isStuck && !state.statusUnreadable;
|
|
152
168
|
const showsStuckBanner =
|
|
153
|
-
|
|
169
|
+
isStuck &&
|
|
154
170
|
!!state.runId &&
|
|
155
171
|
!backgroundWorkerStillAlive &&
|
|
156
172
|
!inFlightWork &&
|
|
@@ -159,6 +175,50 @@ export function RunStuckBanner({
|
|
|
159
175
|
state.dispatchMode === "foreground-self-chain" ||
|
|
160
176
|
state.dispatchMode?.startsWith("background") === true;
|
|
161
177
|
|
|
178
|
+
// Only a host that says when the chat is waiting can tell a lost ending from
|
|
179
|
+
// an idle chat, so reconciling needs `isAwaitingResponse`.
|
|
180
|
+
const chatShowsRunning = isAwaitingResponse != null && awaitingResponse;
|
|
181
|
+
const settledWhileRunning = chatShowsRunning && state.serverSettled;
|
|
182
|
+
const [reconcile, setReconcile] = useState<"idle" | "failed" | "unsettled">(
|
|
183
|
+
"idle",
|
|
184
|
+
);
|
|
185
|
+
const lastReconcileAtRef = useRef(0);
|
|
186
|
+
const reconcileEpochRef = useRef(0);
|
|
187
|
+
const mountedRef = useRef(true);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
mountedRef.current = true;
|
|
190
|
+
return () => {
|
|
191
|
+
mountedRef.current = false;
|
|
192
|
+
};
|
|
193
|
+
}, []);
|
|
194
|
+
// `state` changes on every poll, so a reconcile that did not settle the chat
|
|
195
|
+
// is retried at most once per RECONCILE_RETRY_MS, never in a loop.
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!settledWhileRunning || !onServerSettled) {
|
|
198
|
+
// A reload still in flight answers a chat that has since moved on.
|
|
199
|
+
reconcileEpochRef.current += 1;
|
|
200
|
+
lastReconcileAtRef.current = 0;
|
|
201
|
+
setReconcile("idle");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
if (now - lastReconcileAtRef.current < RECONCILE_RETRY_MS) return;
|
|
206
|
+
lastReconcileAtRef.current = now;
|
|
207
|
+
const epoch = reconcileEpochRef.current;
|
|
208
|
+
const current = () =>
|
|
209
|
+
mountedRef.current && epoch === reconcileEpochRef.current;
|
|
210
|
+
onServerSettled().then(
|
|
211
|
+
(outcome) => {
|
|
212
|
+
if (current()) {
|
|
213
|
+
setReconcile(outcome === "settled" ? "idle" : "unsettled");
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
() => {
|
|
217
|
+
if (current()) setReconcile("failed");
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}, [onServerSettled, settledWhileRunning, state]);
|
|
221
|
+
|
|
162
222
|
const lastReportedRef = useRef<{
|
|
163
223
|
isStuck: boolean;
|
|
164
224
|
runId: string | null;
|
|
@@ -185,6 +245,13 @@ export function RunStuckBanner({
|
|
|
185
245
|
? Math.floor(state.stuckSinceMs / 1000)
|
|
186
246
|
: null,
|
|
187
247
|
runStatus: state.status,
|
|
248
|
+
reason: "no_progress",
|
|
249
|
+
dispatchMode: state.dispatchMode,
|
|
250
|
+
hasInFlightWork: state.hasInFlightWork,
|
|
251
|
+
heartbeatSinceSec:
|
|
252
|
+
state.heartbeatSinceMs != null
|
|
253
|
+
? Math.floor(state.heartbeatSinceMs / 1000)
|
|
254
|
+
: null,
|
|
188
255
|
});
|
|
189
256
|
}, [showsStuckBanner, state, threadId]);
|
|
190
257
|
|
|
@@ -204,7 +271,7 @@ export function RunStuckBanner({
|
|
|
204
271
|
backgroundWorkerStillAlive ||
|
|
205
272
|
inFlightWork ||
|
|
206
273
|
!awaitingResponse ||
|
|
207
|
-
!
|
|
274
|
+
!isStuck ||
|
|
208
275
|
!state.runId ||
|
|
209
276
|
busy.type !== "none" ||
|
|
210
277
|
autoRetriedRunIdsRef.current.has(state.runId)
|
|
@@ -239,9 +306,9 @@ export function RunStuckBanner({
|
|
|
239
306
|
busy,
|
|
240
307
|
inFlightWork,
|
|
241
308
|
isServerContinuedDispatch,
|
|
309
|
+
isStuck,
|
|
242
310
|
onRetry,
|
|
243
311
|
ownerId,
|
|
244
|
-
state.isStuck,
|
|
245
312
|
state.runId,
|
|
246
313
|
state.stuckSinceMs,
|
|
247
314
|
threadId,
|
|
@@ -249,7 +316,42 @@ export function RunStuckBanner({
|
|
|
249
316
|
]);
|
|
250
317
|
|
|
251
318
|
if (!showsStuckBanner) {
|
|
252
|
-
|
|
319
|
+
const notice =
|
|
320
|
+
reconcile === "unsettled"
|
|
321
|
+
? "agentChat.recovery.statusMismatch"
|
|
322
|
+
: (isAwaitingResponse
|
|
323
|
+
? awaitingResponse
|
|
324
|
+
: state.status === "running") &&
|
|
325
|
+
(state.statusUnreadable || reconcile === "failed")
|
|
326
|
+
? "agentChat.recovery.statusUnreadable"
|
|
327
|
+
: null;
|
|
328
|
+
if (!notice) return null;
|
|
329
|
+
return (
|
|
330
|
+
<div
|
|
331
|
+
role="status"
|
|
332
|
+
aria-live="polite"
|
|
333
|
+
className={cn(
|
|
334
|
+
"mx-3 mt-2 flex items-center gap-2.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-xs text-foreground",
|
|
335
|
+
className,
|
|
336
|
+
)}
|
|
337
|
+
>
|
|
338
|
+
<IconAlertTriangle
|
|
339
|
+
size={16}
|
|
340
|
+
className="shrink-0 text-amber-500"
|
|
341
|
+
aria-hidden="true"
|
|
342
|
+
/>
|
|
343
|
+
<span className="min-w-0 flex-1 leading-snug">{t(notice)}</span>
|
|
344
|
+
{reconcile === "unsettled" ? (
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => window.location.reload()}
|
|
348
|
+
className="inline-flex h-7 shrink-0 cursor-pointer items-center rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90"
|
|
349
|
+
>
|
|
350
|
+
{t("agentChat.recovery.reload")}
|
|
351
|
+
</button>
|
|
352
|
+
) : null}
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
253
355
|
}
|
|
254
356
|
|
|
255
357
|
const handleCancel = async () => {
|
|
@@ -523,9 +523,12 @@ export function RunErrorRecoveryCard({
|
|
|
523
523
|
onFork,
|
|
524
524
|
onDismiss,
|
|
525
525
|
onProviderConnected,
|
|
526
|
+
continueError,
|
|
526
527
|
}: {
|
|
527
528
|
info: RunErrorInfo;
|
|
528
|
-
|
|
529
|
+
/** Absent when the stopped run can no longer be continued. */
|
|
530
|
+
onContinue?: () => void;
|
|
531
|
+
continueError?: string | null;
|
|
529
532
|
onRetry: () => void;
|
|
530
533
|
retryHasUnavailableAttachment?: boolean;
|
|
531
534
|
onFork?: () => void | boolean | Promise<void | boolean>;
|
|
@@ -801,7 +804,7 @@ export function RunErrorRecoveryCard({
|
|
|
801
804
|
</button>
|
|
802
805
|
</DeferredBuilderConnectPopover>
|
|
803
806
|
)}
|
|
804
|
-
{canRecover && (
|
|
807
|
+
{canRecover && onContinue && (
|
|
805
808
|
<button
|
|
806
809
|
type="button"
|
|
807
810
|
onClick={onContinue}
|
|
@@ -902,6 +905,11 @@ export function RunErrorRecoveryCard({
|
|
|
902
905
|
{forkError && (
|
|
903
906
|
<p className="mt-2 text-xs leading-relaxed text-red-500">{forkError}</p>
|
|
904
907
|
)}
|
|
908
|
+
{continueError && (
|
|
909
|
+
<p className="mt-2 text-xs leading-relaxed text-destructive">
|
|
910
|
+
{continueError}
|
|
911
|
+
</p>
|
|
912
|
+
)}
|
|
905
913
|
</div>
|
|
906
914
|
);
|
|
907
915
|
}
|
|
@@ -232,6 +232,7 @@ export interface AssistantChatProps {
|
|
|
232
232
|
title: string;
|
|
233
233
|
preview: string;
|
|
234
234
|
messageCount: number;
|
|
235
|
+
titleSource?: "fallback";
|
|
235
236
|
},
|
|
236
237
|
) => void;
|
|
237
238
|
/** Callback to generate a title from the first user message, on the model it was sent with */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function fallbackChatTitle(message: string): string {
|
|
2
|
+
return message
|
|
3
|
+
.replace(/<context\b[^>]*>[\s\S]*?<\/context>\n?/gi, "")
|
|
4
|
+
.replace(/<context\b[^>]*>[\s\S]*$/gi, "")
|
|
5
|
+
.replace(/<\/context>/gi, "")
|
|
6
|
+
.replace(/@\[([^\]|]+)\|[^\]]*\]/g, "@$1")
|
|
7
|
+
.replace(/\s+/g, " ")
|
|
8
|
+
.trim()
|
|
9
|
+
.slice(0, 80);
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { formatFeedbackReport } from "./feedback-report.js";
|
|
6
|
+
|
|
7
|
+
describe("formatFeedbackReport", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sessionStorage.clear();
|
|
10
|
+
window.history.replaceState(null, "", "/");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("names the run, thread link, message and note so a pasted report opens the exact run", () => {
|
|
14
|
+
const lines = formatFeedbackReport({
|
|
15
|
+
threadId: "thr_1",
|
|
16
|
+
runId: "run_1",
|
|
17
|
+
messageId: "msg_1",
|
|
18
|
+
note: "Said done,\nbut wasn't. Wrong numbers",
|
|
19
|
+
}).split("\n");
|
|
20
|
+
|
|
21
|
+
expect(lines).toEqual(
|
|
22
|
+
expect.arrayContaining([
|
|
23
|
+
"Agent-Native failure report",
|
|
24
|
+
`thread: ${window.location.origin}/?thread=thr_1`,
|
|
25
|
+
"run: run_1",
|
|
26
|
+
'inspect: get-agent-thread-debug {"runId":"run_1"}',
|
|
27
|
+
"message: msg_1",
|
|
28
|
+
"note: Said done, but wasn't. Wrong numbers",
|
|
29
|
+
]),
|
|
30
|
+
);
|
|
31
|
+
expect(lines.some((line) => line.startsWith("build: "))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("omits the note line and the run when there are none", () => {
|
|
35
|
+
const text = formatFeedbackReport({
|
|
36
|
+
threadId: "thr_2",
|
|
37
|
+
messageId: "msg_2",
|
|
38
|
+
note: " ",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(text).not.toContain("note:");
|
|
42
|
+
expect(text).not.toContain("run:");
|
|
43
|
+
expect(text).toContain("message: msg_2");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { formatClientFailureReport } from "@agent-native/core/client/failure-report";
|
|
2
|
+
|
|
3
|
+
const MAX_NOTE_CHARS = 600;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* "Copy details" for a thumbs-down: the same packet an error card copies, so a
|
|
7
|
+
* pasted report opens the exact thread and run, plus the message and the
|
|
8
|
+
* reader's note flattened to one line.
|
|
9
|
+
*/
|
|
10
|
+
export function formatFeedbackReport(input: {
|
|
11
|
+
threadId: string;
|
|
12
|
+
runId?: string;
|
|
13
|
+
messageId: string;
|
|
14
|
+
note: string;
|
|
15
|
+
}): string {
|
|
16
|
+
const note = input.note.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE_CHARS);
|
|
17
|
+
return [
|
|
18
|
+
formatClientFailureReport({
|
|
19
|
+
threadId: input.threadId,
|
|
20
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
21
|
+
}),
|
|
22
|
+
`message: ${input.messageId}`,
|
|
23
|
+
note ? `note: ${note}` : "",
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("\n");
|
|
27
|
+
}
|