auq-mcp-server 2.2.2 → 2.4.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/README.md +82 -0
- package/dist/bin/auq.js +45 -39
- package/dist/bin/tui-app.js +78 -8
- package/dist/package.json +1 -1
- package/dist/src/__tests__/server.abort.test.js +214 -0
- package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
- package/dist/src/cli/commands/__tests__/config.test.js +218 -0
- package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
- package/dist/src/cli/commands/answer.js +128 -0
- package/dist/src/cli/commands/config.js +263 -0
- package/dist/src/cli/commands/sessions.js +164 -0
- package/dist/src/cli/utils.js +95 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
- package/dist/src/config/defaults.js +3 -0
- package/dist/src/config/types.js +4 -0
- package/dist/src/core/ask-user-questions.js +3 -2
- package/dist/src/i18n/locales/en.js +8 -1
- package/dist/src/i18n/locales/ko.js +8 -1
- package/dist/src/server.js +64 -11
- package/dist/src/session/SessionManager.js +69 -4
- package/dist/src/session/__tests__/SessionManager.test.js +65 -0
- package/dist/src/tui/ThemeProvider.js +2 -1
- package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
- package/dist/src/tui/components/ConfirmationDialog.js +5 -4
- package/dist/src/tui/components/Footer.js +24 -23
- package/dist/src/tui/components/ReviewScreen.js +2 -1
- package/dist/src/tui/components/SessionDots.js +33 -4
- package/dist/src/tui/components/SessionPicker.js +27 -18
- package/dist/src/tui/components/Spinner.js +19 -0
- package/dist/src/tui/components/StepperView.js +71 -7
- package/dist/src/tui/components/WaitingScreen.js +2 -1
- package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +134 -0
- package/dist/src/tui/components/__tests__/Footer.test.js +121 -0
- package/dist/src/tui/components/__tests__/ReviewScreen.test.js +89 -0
- package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
- package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +135 -0
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
- package/dist/src/tui/components/__tests__/WaitingScreen.test.js +60 -0
- package/dist/src/tui/constants/keybindings.js +40 -0
- package/dist/src/tui/session-watcher.js +50 -0
- package/dist/src/tui/themes/catppuccin-latte.js +7 -0
- package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
- package/dist/src/tui/themes/dark.js +7 -0
- package/dist/src/tui/themes/dracula.js +7 -0
- package/dist/src/tui/themes/github-dark.js +7 -0
- package/dist/src/tui/themes/github-light.js +7 -0
- package/dist/src/tui/themes/gruvbox-dark.js +7 -0
- package/dist/src/tui/themes/gruvbox-light.js +7 -0
- package/dist/src/tui/themes/light.js +7 -0
- package/dist/src/tui/themes/monokai.js +7 -0
- package/dist/src/tui/themes/nord.js +7 -0
- package/dist/src/tui/themes/one-dark.js +7 -0
- package/dist/src/tui/themes/rose-pine.js +7 -0
- package/dist/src/tui/themes/solarized-dark.js +7 -0
- package/dist/src/tui/themes/solarized-light.js +7 -0
- package/dist/src/tui/themes/tokyo-night.js +7 -0
- package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
- package/dist/src/tui/utils/staleDetection.js +51 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -255,6 +255,85 @@ auq --help # Show help
|
|
|
255
255
|
|
|
256
256
|
---
|
|
257
257
|
|
|
258
|
+
### CLI Commands
|
|
259
|
+
|
|
260
|
+
AUQ provides headless CLI commands for managing sessions and configuration without the TUI.
|
|
261
|
+
|
|
262
|
+
#### Answer Sessions
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
# Answer a session with JSON answers
|
|
266
|
+
auq answer <sessionId> --answers '{"0": {"selectedOption": "option1"}}'
|
|
267
|
+
|
|
268
|
+
# Reject a session
|
|
269
|
+
auq answer <sessionId> --reject --reason "Not applicable"
|
|
270
|
+
|
|
271
|
+
# Force answer an abandoned session
|
|
272
|
+
auq answer <sessionId> --answers '...' --force
|
|
273
|
+
|
|
274
|
+
# JSON output
|
|
275
|
+
auq answer <sessionId> --answers '...' --json
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
#### Manage Sessions
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# List pending sessions
|
|
282
|
+
auq sessions list
|
|
283
|
+
|
|
284
|
+
# List stale sessions only
|
|
285
|
+
auq sessions list --stale
|
|
286
|
+
|
|
287
|
+
# List all sessions (including completed, abandoned)
|
|
288
|
+
auq sessions list --all
|
|
289
|
+
|
|
290
|
+
# Dismiss/archive a session
|
|
291
|
+
auq sessions dismiss <sessionId>
|
|
292
|
+
|
|
293
|
+
# JSON output
|
|
294
|
+
auq sessions list --json
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
#### Configuration
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
# View all configuration
|
|
301
|
+
auq config get
|
|
302
|
+
|
|
303
|
+
# View specific setting
|
|
304
|
+
auq config get staleThreshold
|
|
305
|
+
|
|
306
|
+
# Set a value (local .auqrc.json)
|
|
307
|
+
auq config set staleThreshold 3600000
|
|
308
|
+
|
|
309
|
+
# Set globally
|
|
310
|
+
auq config set staleThreshold 3600000 --global
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### Stale Session Detection
|
|
316
|
+
|
|
317
|
+
Sessions that remain unanswered longer than the configured threshold are marked as "stale" (potentially orphaned). This helps identify sessions where the AI may have disconnected or timed out.
|
|
318
|
+
|
|
319
|
+
- **Visual indicators**: Stale sessions show a ⚠ warning icon and yellow highlighting in the TUI
|
|
320
|
+
- **Toast notifications**: A notification appears when a session becomes stale (configurable)
|
|
321
|
+
- **Grace period**: Interacting with a stale session provides a 30-minute grace period
|
|
322
|
+
- **Configurable threshold**: Default is 2 hours (7,200,000ms)
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
### Abandoned Session Handling
|
|
327
|
+
|
|
328
|
+
When an AI client disconnects, associated sessions are marked as "abandoned". These sessions:
|
|
329
|
+
|
|
330
|
+
- Remain visible in the TUI with a red indicator
|
|
331
|
+
- Show a confirmation dialog before answering ("AI가 disconnect되었습니다")
|
|
332
|
+
- Can still be answered via CLI with the `--force` flag
|
|
333
|
+
- Are detectable via `auq sessions list --all`
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
258
337
|
### 🎨 Themes
|
|
259
338
|
|
|
260
339
|
AUQ supports **16 built-in color themes** with automatic persistence. Press `Ctrl+T` to cycle through themes.
|
|
@@ -461,6 +540,9 @@ _Settings from local config override global config, which overrides defaults._
|
|
|
461
540
|
| `retentionPeriod` | number | 604800000 | 0+ (milliseconds) | How long to keep completed sessions (default: 7 days) |
|
|
462
541
|
| `notifications.enabled` | boolean | true | true/false | Enable desktop notifications for new questions |
|
|
463
542
|
| `notifications.sound` | boolean | true | true/false | Play sound with notifications |
|
|
543
|
+
| `staleThreshold` | number | 7200000 | 0+ (milliseconds) | Time before a session is considered stale (2 hours) |
|
|
544
|
+
| `notifyOnStale` | boolean | true | true/false | Show toast notification when sessions become stale |
|
|
545
|
+
| `staleAction` | string | "warn" | "warn", "remove", "archive" | Action for stale sessions |
|
|
464
546
|
|
|
465
547
|
</details>
|
|
466
548
|
|
package/dist/bin/auq.js
CHANGED
|
@@ -9,54 +9,42 @@ import { getSessionDirectory } from "../src/session/utils.js";
|
|
|
9
9
|
const args = process.argv.slice(2);
|
|
10
10
|
const command = args[0];
|
|
11
11
|
if (command === "--help" || command === "-h") {
|
|
12
|
-
console.log(`
|
|
13
|
-
AUQ - Ask User Questions
|
|
12
|
+
console.log(`auq - Ask User Questions (MCP server + TUI)
|
|
14
13
|
|
|
15
|
-
Usage:
|
|
16
|
-
auq [command] [options]
|
|
14
|
+
Usage: auq [command] [options]
|
|
17
15
|
|
|
18
16
|
Commands:
|
|
19
|
-
(default)
|
|
20
|
-
server
|
|
21
|
-
ask <json>
|
|
17
|
+
(default) Start interactive TUI
|
|
18
|
+
server Start MCP server (stdio)
|
|
19
|
+
ask <json> Ask questions via CLI
|
|
20
|
+
answer <id> [flags] Answer or reject a session
|
|
21
|
+
sessions <sub> [flags] List/dismiss sessions
|
|
22
|
+
config <sub> [flags] Get/set configuration
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
--
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
ASK COMMAND:
|
|
28
|
-
Use 'auq ask' when you need to ask the user questions during execution.
|
|
29
|
-
This allows you to:
|
|
30
|
-
1. Gather user preferences or requirements
|
|
31
|
-
2. Clarify ambiguous instructions
|
|
32
|
-
3. Get decisions on implementation choices as you work
|
|
33
|
-
4. Offer choices to the user about what direction to take
|
|
24
|
+
Answer:
|
|
25
|
+
auq answer <id> --answers '<json>' Submit answers
|
|
26
|
+
auq answer <id> --reject [--reason] Reject session
|
|
27
|
+
Flags: --force --json
|
|
34
28
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
- Users can always provide custom free-text input as an alternative to predefined options
|
|
39
|
-
- Single-select mode (default): User picks ONE option or provides custom text
|
|
40
|
-
- Multi-select mode (multiSelect: true): User can select MULTIPLE options
|
|
29
|
+
Sessions:
|
|
30
|
+
auq sessions list [--pending|--stale|--all] [--json]
|
|
31
|
+
auq sessions dismiss <id> [--force] [--json]
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
- Option labels should be concise (1-5 words)
|
|
46
|
-
- To mark a recommended option, append '(recommended)' to its label
|
|
47
|
-
- Don't include an 'Other' option - it's provided automatically
|
|
33
|
+
Config:
|
|
34
|
+
auq config get [key] [--json]
|
|
35
|
+
auq config set <key> <value> [--global] [--json]
|
|
48
36
|
|
|
49
|
-
|
|
37
|
+
Options:
|
|
38
|
+
-h, --help Show this help
|
|
39
|
+
-v, --version Show version
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
echo '{"questions": [...]}' | auq ask # Pipe JSON to ask command
|
|
41
|
+
Keys (TUI):
|
|
42
|
+
↑↓ navigate ←→/Tab questions Space toggle Enter select
|
|
43
|
+
R recommend Ctrl+R quick-submit Esc reject
|
|
44
|
+
[/] sessions 1-9 jump Ctrl+S picker Ctrl+T theme
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`);
|
|
46
|
+
Config: ./.auqrc.json (local) > ~/.config/auq/.auqrc.json (global)
|
|
47
|
+
Env: AUQ_SESSION_DIR XDG_CONFIG_HOME`);
|
|
60
48
|
process.exit(0);
|
|
61
49
|
}
|
|
62
50
|
// Display version
|
|
@@ -168,6 +156,24 @@ if (command === "ask") {
|
|
|
168
156
|
process.exit(1);
|
|
169
157
|
}
|
|
170
158
|
}
|
|
159
|
+
// Handle 'answer' command
|
|
160
|
+
if (command === "answer") {
|
|
161
|
+
const { runAnswerCommand } = await import("../src/cli/commands/answer.js");
|
|
162
|
+
await runAnswerCommand(args.slice(1));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
// Handle 'sessions' command
|
|
166
|
+
if (command === "sessions") {
|
|
167
|
+
const { runSessionsCommand } = await import("../src/cli/commands/sessions.js");
|
|
168
|
+
await runSessionsCommand(args.slice(1));
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
// Handle 'config' command
|
|
172
|
+
if (command === "config") {
|
|
173
|
+
const { runConfigCommand } = await import("../src/cli/commands/config.js");
|
|
174
|
+
await runConfigCommand(args.slice(1));
|
|
175
|
+
process.exit(0);
|
|
176
|
+
}
|
|
171
177
|
// Default: Start TUI
|
|
172
178
|
// Important: Lazy-load Ink/React so non-interactive commands (ask/server) don't pull them in.
|
|
173
179
|
// Also force production mode before importing React/Ink to avoid perf_hooks measure accumulation warnings.
|
package/dist/bin/tui-app.js
CHANGED
|
@@ -11,9 +11,11 @@ import { Toast } from "../src/tui/components/Toast.js";
|
|
|
11
11
|
import { WaitingScreen } from "../src/tui/components/WaitingScreen.js";
|
|
12
12
|
import { createNotificationBatcher, showProgress, clearProgress, calculateProgress, checkLinuxDependencies, } from "../src/tui/notifications/index.js";
|
|
13
13
|
import { createTUIWatcher } from "../src/tui/session-watcher.js";
|
|
14
|
+
import { isSessionStale, isSessionAbandoned, formatStaleToastMessage, } from "../src/tui/utils/staleDetection.js";
|
|
14
15
|
import { ThemeProvider } from "../src/tui/ThemeProvider.js";
|
|
15
16
|
import { ConfigProvider } from "../src/tui/ConfigContext.js";
|
|
16
17
|
import { getAdjustedIndexAfterRemoval, getDirectJumpIndex, getNextSessionIndex, getPrevSessionIndex, } from "../src/tui/utils/sessionSwitching.js";
|
|
18
|
+
import { KEYS } from "../src/tui/constants/keybindings.js";
|
|
17
19
|
const App = ({ config }) => {
|
|
18
20
|
const [state, setState] = useState({ mode: "WAITING" });
|
|
19
21
|
const [sessionQueue, setSessionQueue] = useState([]);
|
|
@@ -24,6 +26,9 @@ const App = ({ config }) => {
|
|
|
24
26
|
const [showSessionLog, setShowSessionLog] = useState(true);
|
|
25
27
|
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
26
28
|
const [isInReviewOrRejection, setIsInReviewOrRejection] = useState(false);
|
|
29
|
+
const [sessionMeta, setSessionMeta] = useState(new Map());
|
|
30
|
+
const [lastInteractions, setLastInteractions] = useState(new Map());
|
|
31
|
+
const [staleToastShown, setStaleToastShown] = useState(new Set());
|
|
27
32
|
// Get session directory for logging
|
|
28
33
|
const sessionDir = getSessionDirectory();
|
|
29
34
|
// Notification configuration from config
|
|
@@ -53,6 +58,7 @@ const App = ({ config }) => {
|
|
|
53
58
|
// Step 1: Load existing pending sessions
|
|
54
59
|
const watcher = createTUIWatcher();
|
|
55
60
|
const sessionIds = await watcher.getPendingSessions();
|
|
61
|
+
const sessionsWithStatus = await watcher.getPendingSessionsWithStatus();
|
|
56
62
|
const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
|
|
57
63
|
const sessionRequest = await watcher.getSessionRequest(sessionId);
|
|
58
64
|
if (!sessionRequest)
|
|
@@ -68,6 +74,12 @@ const App = ({ config }) => {
|
|
|
68
74
|
.filter((s) => s !== null)
|
|
69
75
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
70
76
|
setSessionQueue(validSessions);
|
|
77
|
+
// Build initial sessionMeta from status data
|
|
78
|
+
const initialMeta = new Map();
|
|
79
|
+
for (const meta of sessionsWithStatus) {
|
|
80
|
+
initialMeta.set(meta.sessionId, { status: meta.status, createdAt: meta.createdAt });
|
|
81
|
+
}
|
|
82
|
+
setSessionMeta(initialMeta);
|
|
71
83
|
setIsInitialized(true);
|
|
72
84
|
// Step 2: Start persistent watcher for new sessions
|
|
73
85
|
watcherInstance = createTUIWatcher({ autoLoadData: true });
|
|
@@ -152,8 +164,7 @@ const App = ({ config }) => {
|
|
|
152
164
|
const parsed = JSON.parse(content);
|
|
153
165
|
if (parsed.status === "timed_out" ||
|
|
154
166
|
parsed.status === "completed" ||
|
|
155
|
-
parsed.status === "rejected"
|
|
156
|
-
parsed.status === "abandoned") {
|
|
167
|
+
parsed.status === "rejected") {
|
|
157
168
|
return {
|
|
158
169
|
notifyAsTimedOut: parsed.status === "timed_out",
|
|
159
170
|
session,
|
|
@@ -214,11 +225,47 @@ const App = ({ config }) => {
|
|
|
214
225
|
const interval = setInterval(() => {
|
|
215
226
|
void checkPausedSessionStatuses();
|
|
216
227
|
}, 2000);
|
|
228
|
+
// --- Stale detection (runs alongside status polling) ---
|
|
229
|
+
const staleThreshold = config?.staleThreshold ?? 7200000;
|
|
230
|
+
const notifyOnStale = config?.notifyOnStale ?? true;
|
|
231
|
+
const runStaleDetection = async () => {
|
|
232
|
+
// Refresh session metadata from disk
|
|
233
|
+
const watcher = createTUIWatcher();
|
|
234
|
+
let freshMeta = [];
|
|
235
|
+
try {
|
|
236
|
+
freshMeta = await watcher.getPendingSessionsWithStatus();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Non-critical — stale detection simply skips this cycle
|
|
240
|
+
}
|
|
241
|
+
if (freshMeta.length > 0) {
|
|
242
|
+
setSessionMeta((prev) => {
|
|
243
|
+
const next = new Map(prev);
|
|
244
|
+
for (const meta of freshMeta) {
|
|
245
|
+
next.set(meta.sessionId, { status: meta.status, createdAt: meta.createdAt });
|
|
246
|
+
}
|
|
247
|
+
return next;
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
// Show toast for newly stale sessions
|
|
251
|
+
for (const session of sessionQueue) {
|
|
252
|
+
const stale = isSessionStale(session.timestamp.getTime(), staleThreshold, lastInteractions.get(session.sessionId));
|
|
253
|
+
if (stale && notifyOnStale && !staleToastShown.has(session.sessionId)) {
|
|
254
|
+
const title = session.sessionRequest.questions[0]?.title ?? session.sessionId.slice(0, 8);
|
|
255
|
+
showToast(formatStaleToastMessage(title, session.timestamp.getTime()), "info");
|
|
256
|
+
setStaleToastShown((prev) => new Set(prev).add(session.sessionId));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const staleInterval = setInterval(() => {
|
|
261
|
+
void runStaleDetection();
|
|
262
|
+
}, 2000);
|
|
217
263
|
return () => {
|
|
218
264
|
isCancelled = true;
|
|
219
265
|
clearInterval(interval);
|
|
266
|
+
clearInterval(staleInterval);
|
|
220
267
|
};
|
|
221
|
-
}, [activeSessionIndex, sessionDir, sessionQueue, state.mode]);
|
|
268
|
+
}, [activeSessionIndex, sessionDir, sessionQueue, state.mode, config?.staleThreshold, config?.notifyOnStale, lastInteractions, staleToastShown]);
|
|
222
269
|
// Handle progress updates from StepperView
|
|
223
270
|
const handleProgressUpdate = (answered, total) => {
|
|
224
271
|
const percent = calculateProgress(answered, total);
|
|
@@ -229,6 +276,8 @@ const App = ({ config }) => {
|
|
|
229
276
|
...prev,
|
|
230
277
|
[sessionId]: ui,
|
|
231
278
|
}));
|
|
279
|
+
// Track interaction for stale grace time
|
|
280
|
+
setLastInteractions((prev) => new Map(prev).set(sessionId, Date.now()));
|
|
232
281
|
}, []);
|
|
233
282
|
const handleFlowStateChange = useCallback((flowState) => {
|
|
234
283
|
setIsInReviewOrRejection(flowState.showReview || flowState.showRejectionConfirm);
|
|
@@ -247,6 +296,13 @@ const App = ({ config }) => {
|
|
|
247
296
|
}
|
|
248
297
|
setActiveSessionIndex(clampedIndex);
|
|
249
298
|
setShowSessionPicker(false);
|
|
299
|
+
// Track interaction for stale grace time
|
|
300
|
+
setLastInteractions((prev) => {
|
|
301
|
+
const targetSession = sessionQueue[clampedIndex];
|
|
302
|
+
if (!targetSession)
|
|
303
|
+
return prev;
|
|
304
|
+
return new Map(prev).set(targetSession.sessionId, Date.now());
|
|
305
|
+
});
|
|
250
306
|
}, [activeSessionIndex, sessionQueue, state.mode]);
|
|
251
307
|
const activeSession = state.mode === "PROCESSING" ? sessionQueue[activeSessionIndex] : undefined;
|
|
252
308
|
const canUseDirectJump = !activeSession ||
|
|
@@ -257,12 +313,18 @@ const App = ({ config }) => {
|
|
|
257
313
|
setShowSessionPicker(true);
|
|
258
314
|
return;
|
|
259
315
|
}
|
|
260
|
-
if (key.ctrl && input ===
|
|
316
|
+
if (!key.ctrl && !key.meta && input === KEYS.SESSION_NEXT) {
|
|
317
|
+
if (!canUseDirectJump) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
261
320
|
const nextIndex = getNextSessionIndex(activeSessionIndex, sessionQueue.length);
|
|
262
321
|
switchToSession(nextIndex);
|
|
263
322
|
return;
|
|
264
323
|
}
|
|
265
|
-
if (key.ctrl && input ===
|
|
324
|
+
if (!key.ctrl && !key.meta && input === KEYS.SESSION_PREV) {
|
|
325
|
+
if (!canUseDirectJump) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
266
328
|
const prevIndex = getPrevSessionIndex(activeSessionIndex, sessionQueue.length);
|
|
267
329
|
switchToSession(prevIndex);
|
|
268
330
|
return;
|
|
@@ -334,7 +396,7 @@ const App = ({ config }) => {
|
|
|
334
396
|
mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
|
|
335
397
|
}
|
|
336
398
|
else {
|
|
337
|
-
mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, initialState: sessionUIStates[session.sessionId], onStateSnapshot: handleStateSnapshot, onFlowStateChange: handleFlowStateChange, hasMultipleSessions: sessionQueue.length >= 2, sessionId: session.sessionId, sessionRequest: session.sessionRequest }));
|
|
399
|
+
mainContent = (React.createElement(StepperView, { key: session.sessionId, onComplete: handleSessionComplete, onProgress: handleProgressUpdate, initialState: sessionUIStates[session.sessionId], onStateSnapshot: handleStateSnapshot, onFlowStateChange: handleFlowStateChange, hasMultipleSessions: sessionQueue.length >= 2, sessionId: session.sessionId, sessionRequest: session.sessionRequest, isAbandoned: isSessionAbandoned(sessionMeta.get(session.sessionId)?.status ?? "") }));
|
|
338
400
|
}
|
|
339
401
|
}
|
|
340
402
|
// Render with header, toast overlay, and main content
|
|
@@ -347,14 +409,22 @@ const App = ({ config }) => {
|
|
|
347
409
|
? Math.max(0, sessionQueue.length - 1)
|
|
348
410
|
: sessionQueue.length }),
|
|
349
411
|
mainContent,
|
|
350
|
-
state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue
|
|
412
|
+
state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue.map((s) => ({
|
|
413
|
+
...s,
|
|
414
|
+
isStale: isSessionStale(s.timestamp.getTime(), config?.staleThreshold ?? 7200000, lastInteractions.get(s.sessionId)),
|
|
415
|
+
isAbandoned: isSessionAbandoned(sessionMeta.get(s.sessionId)?.status ?? ""),
|
|
416
|
+
})), activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates })),
|
|
351
417
|
toast && (React.createElement(Box, { marginTop: 1, justifyContent: "center" },
|
|
352
418
|
React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type, title: toast.title, duration: 5000 }))),
|
|
353
419
|
showSessionLog && (React.createElement(Box, { marginTop: 1 },
|
|
354
420
|
React.createElement(Text, { dimColor: true },
|
|
355
421
|
"[AUQ] Session directory: ",
|
|
356
422
|
sessionDir))),
|
|
357
|
-
state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue
|
|
423
|
+
state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue.map((s) => ({
|
|
424
|
+
...s,
|
|
425
|
+
isStale: isSessionStale(s.timestamp.getTime(), config?.staleThreshold ?? 7200000, lastInteractions.get(s.sessionId)),
|
|
426
|
+
isAbandoned: isSessionAbandoned(sessionMeta.get(s.sessionId)?.status ?? ""),
|
|
427
|
+
})), activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates, onSelectIndex: (idx) => {
|
|
358
428
|
switchToSession(idx);
|
|
359
429
|
setShowSessionPicker(false);
|
|
360
430
|
}, onClose: () => setShowSessionPicker(false) })),
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AbortSignal and disconnect handling in SessionManager
|
|
3
|
+
*/
|
|
4
|
+
import { promises as fs } from "fs";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { SessionManager } from "../session/index.js";
|
|
7
|
+
const testQuestions = [
|
|
8
|
+
{
|
|
9
|
+
options: [
|
|
10
|
+
{ description: "Dynamic language", label: "JavaScript" },
|
|
11
|
+
{ description: "Static typing", label: "TypeScript" },
|
|
12
|
+
],
|
|
13
|
+
prompt: "Which programming language do you prefer?",
|
|
14
|
+
title: "Language",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
describe("AbortSignal and Disconnect Handling", () => {
|
|
18
|
+
let sessionManager;
|
|
19
|
+
const testBaseDir = "/tmp/auq-test-abort";
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
|
|
22
|
+
sessionManager = new SessionManager({
|
|
23
|
+
baseDir: testBaseDir,
|
|
24
|
+
maxSessions: 10,
|
|
25
|
+
sessionTimeout: 5000,
|
|
26
|
+
});
|
|
27
|
+
await sessionManager.initialize();
|
|
28
|
+
});
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await fs.rm(testBaseDir, { force: true, recursive: true }).catch(() => { });
|
|
31
|
+
});
|
|
32
|
+
describe("waitForAnswers with AbortSignal", () => {
|
|
33
|
+
it("should throw ABORTED when signal fires during polling", async () => {
|
|
34
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
// Abort after a short delay
|
|
37
|
+
setTimeout(() => controller.abort(), 100);
|
|
38
|
+
await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
|
|
39
|
+
});
|
|
40
|
+
it("should throw ABORTED immediately when signal is already aborted", async () => {
|
|
41
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
controller.abort(); // Pre-abort
|
|
44
|
+
await expect(sessionManager.waitForAnswers(sessionId, 0, undefined, controller.signal)).rejects.toThrow("ABORTED");
|
|
45
|
+
});
|
|
46
|
+
it("should work normally when signal is never aborted", async () => {
|
|
47
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
// Write answers after a short delay
|
|
50
|
+
setTimeout(async () => {
|
|
51
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
52
|
+
answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
sessionId,
|
|
55
|
+
});
|
|
56
|
+
}, 100);
|
|
57
|
+
const result = await sessionManager.waitForAnswers(sessionId, 5000, undefined, controller.signal);
|
|
58
|
+
expect(result).toBe(sessionId);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("startSession with AbortSignal", () => {
|
|
62
|
+
it("should throw ABORTED when pre-aborted signal is passed", async () => {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
controller.abort(); // Pre-abort
|
|
65
|
+
await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
|
|
66
|
+
});
|
|
67
|
+
it("should mark session as abandoned when pre-aborted signal is passed", async () => {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
controller.abort(); // Pre-abort
|
|
70
|
+
try {
|
|
71
|
+
await sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Expected error
|
|
75
|
+
}
|
|
76
|
+
// Get session IDs and check the last one's status
|
|
77
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
78
|
+
expect(sessionIds.length).toBeGreaterThan(0);
|
|
79
|
+
const lastSessionId = sessionIds[sessionIds.length - 1];
|
|
80
|
+
const status = await sessionManager.getSessionStatus(lastSessionId);
|
|
81
|
+
expect(status?.status).toBe("abandoned");
|
|
82
|
+
});
|
|
83
|
+
it("should mark session as abandoned when signal aborts during wait", async () => {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
// Abort after session is created but before answers arrive
|
|
86
|
+
setTimeout(() => controller.abort(), 200);
|
|
87
|
+
await expect(sessionManager.startSession(testQuestions, "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
|
|
88
|
+
// Verify session was marked abandoned
|
|
89
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
90
|
+
expect(sessionIds.length).toBeGreaterThan(0);
|
|
91
|
+
const lastSessionId = sessionIds[sessionIds.length - 1];
|
|
92
|
+
// Give abort handler time to update status
|
|
93
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
94
|
+
const status = await sessionManager.getSessionStatus(lastSessionId);
|
|
95
|
+
expect(status?.status).toBe("abandoned");
|
|
96
|
+
});
|
|
97
|
+
it("should clean up abort handler after successful completion", async () => {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
const signal = controller.signal;
|
|
100
|
+
// Create a session and immediately provide answers
|
|
101
|
+
const sessionPromise = sessionManager.startSession(testQuestions, "test-call", undefined, signal);
|
|
102
|
+
// Write answers quickly
|
|
103
|
+
// First we need to get the session ID, but startSession creates it internally
|
|
104
|
+
// We'll poll for any pending session
|
|
105
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
106
|
+
const sessionIds = await sessionManager.getAllSessionIds();
|
|
107
|
+
if (sessionIds.length > 0) {
|
|
108
|
+
const lastSessionId = sessionIds[sessionIds.length - 1];
|
|
109
|
+
await sessionManager.saveSessionAnswers(lastSessionId, {
|
|
110
|
+
answers: [{ questionIndex: 0, selectedOption: "JavaScript", timestamp: new Date().toISOString() }],
|
|
111
|
+
timestamp: new Date().toISOString(),
|
|
112
|
+
sessionId: lastSessionId,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const result = await sessionPromise;
|
|
116
|
+
expect(result.formattedResponse).toBeDefined();
|
|
117
|
+
expect(result.sessionId).toBeDefined();
|
|
118
|
+
// After successful completion, aborting should have no effect
|
|
119
|
+
// (handler was cleaned up)
|
|
120
|
+
controller.abort();
|
|
121
|
+
// Session should still be completed, not abandoned
|
|
122
|
+
const status = await sessionManager.getSessionStatus(result.sessionId);
|
|
123
|
+
expect(status?.status).toBe("completed");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
describe("createAskUserQuestionsCore with abort support", () => {
|
|
127
|
+
it("should expose markAbandoned method", async () => {
|
|
128
|
+
const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
|
|
129
|
+
const core = createAskUserQuestionsCore({
|
|
130
|
+
baseDir: testBaseDir,
|
|
131
|
+
sessionManager,
|
|
132
|
+
});
|
|
133
|
+
expect(core.markAbandoned).toBeDefined();
|
|
134
|
+
expect(typeof core.markAbandoned).toBe("function");
|
|
135
|
+
});
|
|
136
|
+
it("should mark session as abandoned via markAbandoned", async () => {
|
|
137
|
+
const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
|
|
138
|
+
const core = createAskUserQuestionsCore({
|
|
139
|
+
baseDir: testBaseDir,
|
|
140
|
+
sessionManager,
|
|
141
|
+
});
|
|
142
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
143
|
+
await core.markAbandoned(sessionId);
|
|
144
|
+
const status = await sessionManager.getSessionStatus(sessionId);
|
|
145
|
+
expect(status?.status).toBe("abandoned");
|
|
146
|
+
});
|
|
147
|
+
it("should pass signal through ask to startSession", async () => {
|
|
148
|
+
const { createAskUserQuestionsCore } = await import("../core/ask-user-questions.js");
|
|
149
|
+
const core = createAskUserQuestionsCore({
|
|
150
|
+
baseDir: testBaseDir,
|
|
151
|
+
sessionManager,
|
|
152
|
+
});
|
|
153
|
+
await core.ensureInitialized();
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
controller.abort(); // Pre-abort
|
|
156
|
+
await expect(core.ask([
|
|
157
|
+
{
|
|
158
|
+
options: [
|
|
159
|
+
{ description: "Dynamic language", label: "JavaScript" },
|
|
160
|
+
{ description: "Static typing", label: "TypeScript" },
|
|
161
|
+
],
|
|
162
|
+
prompt: "Which programming language do you prefer?",
|
|
163
|
+
title: "Language",
|
|
164
|
+
multiSelect: false,
|
|
165
|
+
},
|
|
166
|
+
], "test-call", undefined, controller.signal)).rejects.toThrow("ABORTED");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("activeRequests tracking (server integration)", () => {
|
|
170
|
+
it("should track and clean up active requests via Map", () => {
|
|
171
|
+
// Unit test for the activeRequests Map pattern used in server.ts
|
|
172
|
+
const activeRequests = new Map();
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
const callId = "test-call-id";
|
|
175
|
+
// Track request
|
|
176
|
+
activeRequests.set(callId, { controller });
|
|
177
|
+
expect(activeRequests.size).toBe(1);
|
|
178
|
+
// Update with sessionId
|
|
179
|
+
const entry = activeRequests.get(callId);
|
|
180
|
+
expect(entry).toBeDefined();
|
|
181
|
+
entry.sessionId = "test-session-id";
|
|
182
|
+
// Verify update
|
|
183
|
+
expect(activeRequests.get(callId)?.sessionId).toBe("test-session-id");
|
|
184
|
+
// Simulate disconnect - abort and clean up
|
|
185
|
+
for (const [id, e] of activeRequests.entries()) {
|
|
186
|
+
e.controller.abort();
|
|
187
|
+
activeRequests.delete(id);
|
|
188
|
+
}
|
|
189
|
+
expect(activeRequests.size).toBe(0);
|
|
190
|
+
expect(controller.signal.aborted).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
it("should handle multiple concurrent requests on disconnect", () => {
|
|
193
|
+
const activeRequests = new Map();
|
|
194
|
+
// Track multiple requests
|
|
195
|
+
const controllers = [];
|
|
196
|
+
for (let i = 0; i < 3; i++) {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
controllers.push(controller);
|
|
199
|
+
activeRequests.set(`call-${i}`, {
|
|
200
|
+
controller,
|
|
201
|
+
sessionId: `session-${i}`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
expect(activeRequests.size).toBe(3);
|
|
205
|
+
// Simulate disconnect
|
|
206
|
+
for (const [callId, entry] of activeRequests.entries()) {
|
|
207
|
+
entry.controller.abort();
|
|
208
|
+
activeRequests.delete(callId);
|
|
209
|
+
}
|
|
210
|
+
expect(activeRequests.size).toBe(0);
|
|
211
|
+
controllers.forEach((c) => expect(c.signal.aborted).toBe(true));
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|