auq-mcp-server 2.3.0 → 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.
Files changed (51) hide show
  1. package/README.md +82 -0
  2. package/dist/bin/auq.js +47 -93
  3. package/dist/bin/tui-app.js +69 -6
  4. package/dist/package.json +1 -1
  5. package/dist/src/__tests__/server.abort.test.js +214 -0
  6. package/dist/src/cli/commands/__tests__/answer.test.js +199 -0
  7. package/dist/src/cli/commands/__tests__/config.test.js +218 -0
  8. package/dist/src/cli/commands/__tests__/sessions.test.js +282 -0
  9. package/dist/src/cli/commands/answer.js +128 -0
  10. package/dist/src/cli/commands/config.js +263 -0
  11. package/dist/src/cli/commands/sessions.js +164 -0
  12. package/dist/src/cli/utils.js +95 -0
  13. package/dist/src/config/__tests__/ConfigLoader.test.js +41 -0
  14. package/dist/src/config/defaults.js +3 -0
  15. package/dist/src/config/types.js +4 -0
  16. package/dist/src/core/ask-user-questions.js +3 -2
  17. package/dist/src/i18n/locales/en.js +7 -0
  18. package/dist/src/i18n/locales/ko.js +7 -0
  19. package/dist/src/server.js +64 -11
  20. package/dist/src/session/SessionManager.js +69 -4
  21. package/dist/src/session/__tests__/SessionManager.test.js +65 -0
  22. package/dist/src/tui/__tests__/session-watcher.test.js +109 -0
  23. package/dist/src/tui/components/SessionDots.js +33 -4
  24. package/dist/src/tui/components/SessionPicker.js +25 -17
  25. package/dist/src/tui/components/Spinner.js +19 -0
  26. package/dist/src/tui/components/StepperView.js +68 -5
  27. package/dist/src/tui/components/__tests__/SessionDots.test.js +160 -1
  28. package/dist/src/tui/components/__tests__/SessionPicker.test.js +43 -1
  29. package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +160 -0
  30. package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -0
  31. package/dist/src/tui/session-watcher.js +50 -0
  32. package/dist/src/tui/themes/catppuccin-latte.js +7 -0
  33. package/dist/src/tui/themes/catppuccin-mocha.js +7 -0
  34. package/dist/src/tui/themes/dark.js +7 -0
  35. package/dist/src/tui/themes/dracula.js +7 -0
  36. package/dist/src/tui/themes/github-dark.js +7 -0
  37. package/dist/src/tui/themes/github-light.js +7 -0
  38. package/dist/src/tui/themes/gruvbox-dark.js +7 -0
  39. package/dist/src/tui/themes/gruvbox-light.js +7 -0
  40. package/dist/src/tui/themes/light.js +7 -0
  41. package/dist/src/tui/themes/monokai.js +7 -0
  42. package/dist/src/tui/themes/nord.js +7 -0
  43. package/dist/src/tui/themes/one-dark.js +7 -0
  44. package/dist/src/tui/themes/rose-pine.js +7 -0
  45. package/dist/src/tui/themes/solarized-dark.js +7 -0
  46. package/dist/src/tui/themes/solarized-light.js +7 -0
  47. package/dist/src/tui/themes/tokyo-night.js +7 -0
  48. package/dist/src/tui/utils/__tests__/detectTheme.test.js +78 -0
  49. package/dist/src/tui/utils/__tests__/staleDetection.test.js +118 -0
  50. package/dist/src/tui/utils/staleDetection.js +51 -0
  51. 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,106 +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
- An MCP server and TUI for AI assistants to ask users structured questions.
16
-
17
- Usage:
18
- auq [command] [options]
14
+ Usage: auq [command] [options]
19
15
 
20
16
  Commands:
21
- (default) Start the interactive TUI (Terminal User Interface)
22
- server Start the MCP server (for use with AI assistants)
23
- ask <json> Ask questions via CLI (pipe or argument)
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
23
+
24
+ Answer:
25
+ auq answer <id> --answers '<json>' Submit answers
26
+ auq answer <id> --reject [--reason] Reject session
27
+ Flags: --force --json
28
+
29
+ Sessions:
30
+ auq sessions list [--pending|--stale|--all] [--json]
31
+ auq sessions dismiss <id> [--force] [--json]
32
+
33
+ Config:
34
+ auq config get [key] [--json]
35
+ auq config set <key> <value> [--global] [--json]
24
36
 
25
37
  Options:
26
- --help, -h Show this help message
27
- --version, -v Show version information
28
-
29
- TUI Keyboard Shortcuts:
30
- Navigation:
31
- ↑/↓ Navigate options
32
- ←/→ Navigate questions
33
- Tab/Shift+Tab Navigate questions
34
-
35
- Selection:
36
- Space Select/toggle option (multi-select)
37
- Enter Select option & advance to next question
38
- R Select recommended option(s)
39
- Ctrl+R Quick submit (auto-select all recommended)
40
-
41
- Session Management:
42
- ] Next session
43
- [ Previous session
44
- 1-9 Jump to session by number
45
- Ctrl+S Open session picker
46
-
47
- Other:
48
- E Request elaboration on current question
49
- Ctrl+T Cycle color theme
50
- Esc Reject question set
51
-
52
- Ask Command:
53
- Use 'auq ask' when you need to ask the user questions during
54
- execution. This allows you to:
55
- 1. Gather user preferences or requirements
56
- 2. Clarify ambiguous instructions
57
- 3. Get decisions on implementation choices as you work
58
- 4. Offer choices to the user about what direction to take
59
-
60
- Features:
61
- - Ask 1-5 structured questions via an interactive terminal UI
62
- - Each question includes 2-5 multiple-choice options
63
- - Users can always provide custom free-text input
64
- - Single-select mode (default): pick ONE option or custom text
65
- - Multi-select mode (multiSelect: true): select MULTIPLE options
66
-
67
- Usage Notes:
68
- - Provide a descriptive 'title' field (max 12 chars) per question
69
- - Use multiSelect: true when choices are not mutually exclusive
70
- - Option labels should be concise (1-5 words)
71
- - To mark recommended, append '(recommended)' to option label
72
- - Don't include an 'Other' option — it's provided automatically
73
-
74
- Returns a formatted summary of all questions and answers.
75
-
76
- Configuration:
77
- Config file locations (searched in order, merged):
78
- ./.auqrc.json Project-level (highest priority)
79
- ~/.config/auq/.auqrc.json User-level (global)
38
+ -h, --help Show this help
39
+ -v, --version Show version
80
40
 
81
- Available options (with defaults):
82
- maxOptions Max options per question (2-10, default: 5)
83
- maxQuestions Max questions per session (1-10, default: 5)
84
- recommendedOptions Recommended option count hint (default: 4)
85
- recommendedQuestions Recommended question count hint (default: 4)
86
- sessionTimeout Session timeout in ms (0 = infinite, default: 0)
87
- retentionPeriod Session retention in ms (default: 604800000 / 7d)
88
- language UI language ("auto" | "en" | "ko", default: "auto")
89
- theme Color theme ("system" | "dark" | "light" | custom,
90
- default: "system")
91
- autoSelectRecommended Pre-select recommended options (default: true)
92
- notifications.enabled Enable desktop notifications (default: true)
93
- notifications.sound Enable notification sounds (default: true)
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
94
45
 
95
- Custom themes: place .theme.json files in ~/.config/auq/themes/
96
-
97
- Environment Variables:
98
- AUQ_SESSION_DIR Override session storage directory
99
- XDG_CONFIG_HOME Override config base directory (default: ~/.config)
100
-
101
- Examples:
102
- auq # Start TUI (wait for questions from AI)
103
- auq server # Start MCP server (for Claude Desktop, etc.)
104
- auq ask '{"questions": [{"prompt": "Which language?", "title": "Lang",
105
- "options": [{"label": "TypeScript (recommended)"}, {"label": "Python"}],
106
- "multiSelect": false}]}'
107
- echo '{"questions": [...]}' | auq ask # Pipe JSON to ask command
108
-
109
- For more information, visit:
110
- https://github.com/paulp-o/ask-user-questions-mcp
111
- `);
46
+ Config: ./.auqrc.json (local) > ~/.config/auq/.auqrc.json (global)
47
+ Env: AUQ_SESSION_DIR XDG_CONFIG_HOME`);
112
48
  process.exit(0);
113
49
  }
114
50
  // Display version
@@ -220,6 +156,24 @@ if (command === "ask") {
220
156
  process.exit(1);
221
157
  }
222
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
+ }
223
177
  // Default: Start TUI
224
178
  // Important: Lazy-load Ink/React so non-interactive commands (ask/server) don't pull them in.
225
179
  // Also force production mode before importing React/Ink to avoid perf_hooks measure accumulation warnings.
@@ -11,6 +11,7 @@ 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";
@@ -25,6 +26,9 @@ const App = ({ config }) => {
25
26
  const [showSessionLog, setShowSessionLog] = useState(true);
26
27
  const [showSessionPicker, setShowSessionPicker] = useState(false);
27
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());
28
32
  // Get session directory for logging
29
33
  const sessionDir = getSessionDirectory();
30
34
  // Notification configuration from config
@@ -54,6 +58,7 @@ const App = ({ config }) => {
54
58
  // Step 1: Load existing pending sessions
55
59
  const watcher = createTUIWatcher();
56
60
  const sessionIds = await watcher.getPendingSessions();
61
+ const sessionsWithStatus = await watcher.getPendingSessionsWithStatus();
57
62
  const sessionData = await Promise.all(sessionIds.map(async (sessionId) => {
58
63
  const sessionRequest = await watcher.getSessionRequest(sessionId);
59
64
  if (!sessionRequest)
@@ -69,6 +74,12 @@ const App = ({ config }) => {
69
74
  .filter((s) => s !== null)
70
75
  .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
71
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);
72
83
  setIsInitialized(true);
73
84
  // Step 2: Start persistent watcher for new sessions
74
85
  watcherInstance = createTUIWatcher({ autoLoadData: true });
@@ -153,8 +164,7 @@ const App = ({ config }) => {
153
164
  const parsed = JSON.parse(content);
154
165
  if (parsed.status === "timed_out" ||
155
166
  parsed.status === "completed" ||
156
- parsed.status === "rejected" ||
157
- parsed.status === "abandoned") {
167
+ parsed.status === "rejected") {
158
168
  return {
159
169
  notifyAsTimedOut: parsed.status === "timed_out",
160
170
  session,
@@ -215,11 +225,47 @@ const App = ({ config }) => {
215
225
  const interval = setInterval(() => {
216
226
  void checkPausedSessionStatuses();
217
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);
218
263
  return () => {
219
264
  isCancelled = true;
220
265
  clearInterval(interval);
266
+ clearInterval(staleInterval);
221
267
  };
222
- }, [activeSessionIndex, sessionDir, sessionQueue, state.mode]);
268
+ }, [activeSessionIndex, sessionDir, sessionQueue, state.mode, config?.staleThreshold, config?.notifyOnStale, lastInteractions, staleToastShown]);
223
269
  // Handle progress updates from StepperView
224
270
  const handleProgressUpdate = (answered, total) => {
225
271
  const percent = calculateProgress(answered, total);
@@ -230,6 +276,8 @@ const App = ({ config }) => {
230
276
  ...prev,
231
277
  [sessionId]: ui,
232
278
  }));
279
+ // Track interaction for stale grace time
280
+ setLastInteractions((prev) => new Map(prev).set(sessionId, Date.now()));
233
281
  }, []);
234
282
  const handleFlowStateChange = useCallback((flowState) => {
235
283
  setIsInReviewOrRejection(flowState.showReview || flowState.showRejectionConfirm);
@@ -248,6 +296,13 @@ const App = ({ config }) => {
248
296
  }
249
297
  setActiveSessionIndex(clampedIndex);
250
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
+ });
251
306
  }, [activeSessionIndex, sessionQueue, state.mode]);
252
307
  const activeSession = state.mode === "PROCESSING" ? sessionQueue[activeSessionIndex] : undefined;
253
308
  const canUseDirectJump = !activeSession ||
@@ -341,7 +396,7 @@ const App = ({ config }) => {
341
396
  mainContent = React.createElement(WaitingScreen, { queueCount: sessionQueue.length });
342
397
  }
343
398
  else {
344
- 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 ?? "") }));
345
400
  }
346
401
  }
347
402
  // Render with header, toast overlay, and main content
@@ -354,14 +409,22 @@ const App = ({ config }) => {
354
409
  ? Math.max(0, sessionQueue.length - 1)
355
410
  : sessionQueue.length }),
356
411
  mainContent,
357
- state.mode === "PROCESSING" && sessionQueue.length >= 2 && (React.createElement(SessionDots, { sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates })),
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 })),
358
417
  toast && (React.createElement(Box, { marginTop: 1, justifyContent: "center" },
359
418
  React.createElement(Toast, { message: toast.message, onDismiss: () => setToast(null), type: toast.type, title: toast.title, duration: 5000 }))),
360
419
  showSessionLog && (React.createElement(Box, { marginTop: 1 },
361
420
  React.createElement(Text, { dimColor: true },
362
421
  "[AUQ] Session directory: ",
363
422
  sessionDir))),
364
- state.mode === "PROCESSING" && (React.createElement(SessionPicker, { isOpen: showSessionPicker, sessions: sessionQueue, activeIndex: activeSessionIndex, sessionUIStates: sessionUIStates, onSelectIndex: (idx) => {
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) => {
365
428
  switchToSession(idx);
366
429
  setShowSessionPicker(false);
367
430
  }, onClose: () => setShowSessionPicker(false) })),
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auq-mcp-server",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "auq": "dist/bin/auq.js"
@@ -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
+ });