auq-mcp-server 2.6.3 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -2
- package/dist/bin/auq.js +36 -3
- package/dist/bin/tui-app.js +30 -15
- package/dist/package.json +7 -2
- package/dist/src/__tests__/schema-validation.test.js +61 -1
- package/dist/src/cli/commands/__tests__/fetch-answers.test.js +310 -0
- package/dist/src/cli/commands/__tests__/history.test.js +211 -0
- package/dist/src/cli/commands/answer.js +11 -0
- package/dist/src/cli/commands/config.js +48 -0
- package/dist/src/cli/commands/fetch-answers.js +205 -0
- package/dist/src/cli/commands/history.js +375 -0
- package/dist/src/config/__tests__/ConfigLoader.test.js +38 -0
- package/dist/src/config/defaults.js +1 -0
- package/dist/src/config/types.js +1 -0
- package/dist/src/core/ask-user-questions.js +63 -0
- package/dist/src/i18n/locales/en.js +2 -2
- package/dist/src/server.js +59 -2
- package/dist/src/session/ResponseFormatter.js +79 -2
- package/dist/src/session/SessionManager.js +36 -0
- package/dist/src/session/__tests__/ResponseFormatter.test.js +86 -0
- package/dist/src/session/__tests__/SessionManager.test.js +129 -0
- package/dist/src/shared/schemas.js +8 -0
- package/dist/src/tui/ThemeProvider.js +3 -3
- package/dist/src/tui/components/Header.js +2 -1
- package/dist/src/tui/components/OptionsList.js +1 -1
- package/dist/src/tui/components/SessionPicker.js +1 -1
- package/dist/src/tui/components/StepperView.js +1 -1
- package/dist/src/tui/components/__tests__/ConfirmationDialog.test.js +1 -1
- package/dist/src/tui/components/__tests__/Footer.test.js +1 -1
- package/dist/src/tui/components/__tests__/MarkdownPrompt.test.js +1 -1
- package/dist/src/tui/components/__tests__/ReviewScreen.test.js +1 -1
- package/dist/src/tui/components/__tests__/SessionDots.test.js +1 -1
- package/dist/src/tui/components/__tests__/SessionPicker.test.js +1 -1
- package/dist/src/tui/components/__tests__/StepperView.abandoned.test.js +1 -1
- package/dist/src/tui/components/__tests__/StepperView.keyboard.test.js +1 -1
- package/dist/src/tui/components/__tests__/StepperView.state.test.js +1 -1
- package/dist/src/tui/components/__tests__/WaitingScreen.test.js +1 -1
- package/dist/src/tui/shared/session-events.js +4 -0
- package/dist/src/tui/shared/themes/catppuccin-latte.js +130 -0
- package/dist/src/tui/shared/themes/catppuccin-mocha.js +131 -0
- package/dist/src/tui/shared/themes/dark.js +131 -0
- package/dist/src/tui/shared/themes/dracula.js +131 -0
- package/dist/src/tui/shared/themes/github-dark.js +129 -0
- package/dist/src/tui/shared/themes/github-light.js +129 -0
- package/dist/src/tui/shared/themes/gruvbox-dark.js +130 -0
- package/dist/src/tui/shared/themes/gruvbox-light.js +130 -0
- package/dist/src/tui/shared/themes/index.js +70 -0
- package/dist/src/tui/shared/themes/light.js +130 -0
- package/dist/src/tui/shared/themes/loader.js +111 -0
- package/dist/src/tui/shared/themes/monokai.js +132 -0
- package/dist/src/tui/shared/themes/nord.js +130 -0
- package/dist/src/tui/shared/themes/one-dark.js +131 -0
- package/dist/src/tui/shared/themes/rose-pine.js +131 -0
- package/dist/src/tui/shared/themes/solarized-dark.js +130 -0
- package/dist/src/tui/shared/themes/solarized-light.js +130 -0
- package/dist/src/tui/shared/themes/tokyo-night.js +131 -0
- package/dist/src/tui/shared/themes/types.js +1 -0
- package/dist/src/tui/shared/types.js +1 -0
- package/dist/src/tui/shared/utils/config.js +80 -0
- package/dist/src/tui/shared/utils/detectTheme.js +33 -0
- package/dist/src/tui/shared/utils/index.js +6 -0
- package/dist/src/tui/shared/utils/recommended.js +52 -0
- package/dist/src/tui/shared/utils/relativeTime.js +24 -0
- package/dist/src/tui/shared/utils/sessionSwitching.js +56 -0
- package/dist/src/tui/shared/utils/staleDetection.js +51 -0
- package/dist/src/tui/themes/catppuccin-latte.js +2 -127
- package/dist/src/tui/themes/catppuccin-mocha.js +2 -127
- package/dist/src/tui/themes/dark.js +2 -128
- package/dist/src/tui/themes/dracula.js +2 -127
- package/dist/src/tui/themes/github-dark.js +2 -126
- package/dist/src/tui/themes/github-light.js +2 -126
- package/dist/src/tui/themes/gruvbox-dark.js +2 -127
- package/dist/src/tui/themes/gruvbox-light.js +2 -127
- package/dist/src/tui/themes/index.js +2 -70
- package/dist/src/tui/themes/light.js +2 -127
- package/dist/src/tui/themes/loader.js +2 -111
- package/dist/src/tui/themes/monokai.js +2 -128
- package/dist/src/tui/themes/nord.js +2 -127
- package/dist/src/tui/themes/one-dark.js +2 -127
- package/dist/src/tui/themes/rose-pine.js +2 -128
- package/dist/src/tui/themes/solarized-dark.js +2 -127
- package/dist/src/tui/themes/solarized-light.js +2 -127
- package/dist/src/tui/themes/tokyo-night.js +2 -127
- package/dist/src/tui/themes/types.js +2 -1
- package/dist/src/tui/types.js +1 -1
- package/dist/src/tui/utils/__tests__/recommended.test.js +1 -1
- package/dist/src/tui/utils/__tests__/relativeTime.test.js +1 -1
- package/dist/src/tui/utils/__tests__/sessionSwitching.test.js +1 -1
- package/dist/src/tui/utils/__tests__/staleDetection.test.js +1 -1
- package/dist/src/tui/utils/config.js +1 -80
- package/dist/src/tui/utils/detectTheme.js +1 -22
- package/dist/src/tui/utils/recommended.js +1 -52
- package/dist/src/tui/utils/relativeTime.js +1 -24
- package/dist/src/tui/utils/sessionSwitching.js +1 -56
- package/dist/src/tui/utils/staleDetection.js +1 -51
- package/package.json +7 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SessionManager } from "../session/index.js";
|
|
2
|
+
import { ResponseFormatter } from "../session/ResponseFormatter.js";
|
|
2
3
|
import { getSessionDirectory } from "../session/utils.js";
|
|
3
4
|
import { AskUserQuestionsParametersSchema, QuestionSchema, QuestionsSchema, } from "../shared/schemas.js";
|
|
4
5
|
// Re-export schemas for backward compatibility
|
|
@@ -27,8 +28,70 @@ export const createAskUserQuestionsCore = (options = {}) => {
|
|
|
27
28
|
const parsedQuestions = QuestionsSchema.parse(questions);
|
|
28
29
|
return sessionManager.startSession(normalizeQuestions(parsedQuestions), callId, workingDirectory, signal);
|
|
29
30
|
};
|
|
31
|
+
const askNonBlocking = async (questions, callId, workingDirectory) => {
|
|
32
|
+
await ensureInitialized();
|
|
33
|
+
const parsedQuestions = QuestionsSchema.parse(questions);
|
|
34
|
+
const sessionId = await sessionManager.createSession(normalizeQuestions(parsedQuestions), workingDirectory);
|
|
35
|
+
return { sessionId, questionCount: parsedQuestions.length };
|
|
36
|
+
};
|
|
37
|
+
const getAnsweredQuestions = async (sessionId, blocking, signal) => {
|
|
38
|
+
await ensureInitialized();
|
|
39
|
+
// Resolve short ID to full UUID if needed
|
|
40
|
+
let resolvedSessionId = sessionId;
|
|
41
|
+
if (sessionId.length < 36) {
|
|
42
|
+
const allIds = await sessionManager.getAllSessionIds();
|
|
43
|
+
const match = allIds.find((id) => id.startsWith(sessionId));
|
|
44
|
+
if (!match) {
|
|
45
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
46
|
+
}
|
|
47
|
+
resolvedSessionId = match;
|
|
48
|
+
}
|
|
49
|
+
const sessionStatus = await sessionManager.getSessionStatus(resolvedSessionId);
|
|
50
|
+
if (!sessionStatus) {
|
|
51
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
52
|
+
}
|
|
53
|
+
const shortId = resolvedSessionId.slice(0, 8);
|
|
54
|
+
const handleCompleted = async () => {
|
|
55
|
+
const answers = await sessionManager.getSessionAnswers(resolvedSessionId);
|
|
56
|
+
const request = await sessionManager.getSessionRequest(resolvedSessionId);
|
|
57
|
+
if (!answers || !request) {
|
|
58
|
+
throw new Error(`Session data incomplete: ${resolvedSessionId}`);
|
|
59
|
+
}
|
|
60
|
+
const formatted = ResponseFormatter.formatUserResponse(answers, request.questions);
|
|
61
|
+
const count = request.questions.length;
|
|
62
|
+
const header = `[Session: ${shortId} | Questions: ${count}]`;
|
|
63
|
+
const formattedResponse = `${header}\n\n${formatted}`;
|
|
64
|
+
await sessionManager.markSessionAsRead(resolvedSessionId);
|
|
65
|
+
return { formattedResponse, sessionId: resolvedSessionId, status: "completed" };
|
|
66
|
+
};
|
|
67
|
+
switch (sessionStatus.status) {
|
|
68
|
+
case "completed": {
|
|
69
|
+
return handleCompleted();
|
|
70
|
+
}
|
|
71
|
+
case "pending":
|
|
72
|
+
case "in-progress": {
|
|
73
|
+
if (blocking) {
|
|
74
|
+
await sessionManager.waitForAnswers(resolvedSessionId, 0, undefined, signal);
|
|
75
|
+
return handleCompleted();
|
|
76
|
+
}
|
|
77
|
+
const pendingResponse = `[Session: ${shortId} | Status: pending]\n\nNo answers yet.`;
|
|
78
|
+
return { formattedResponse: pendingResponse, sessionId: resolvedSessionId, status: "pending" };
|
|
79
|
+
}
|
|
80
|
+
case "rejected": {
|
|
81
|
+
const reason = sessionStatus.rejectionReason;
|
|
82
|
+
const rejectedResponse = `[Session: ${shortId} | Status: rejected]\n\nUser rejected this question set.${reason ? ` Reason: "${reason}"` : ""}`;
|
|
83
|
+
return { formattedResponse: rejectedResponse, sessionId: resolvedSessionId, status: "rejected" };
|
|
84
|
+
}
|
|
85
|
+
default: {
|
|
86
|
+
const defaultResponse = `[Session: ${shortId} | Status: ${sessionStatus.status}]\n\nSession is no longer active.`;
|
|
87
|
+
return { formattedResponse: defaultResponse, sessionId: resolvedSessionId, status: sessionStatus.status };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
30
91
|
return {
|
|
31
92
|
ask,
|
|
93
|
+
askNonBlocking,
|
|
94
|
+
getAnsweredQuestions,
|
|
32
95
|
cleanupExpiredSessions: () => sessionManager.cleanupExpiredSessions(),
|
|
33
96
|
ensureInitialized,
|
|
34
97
|
markAbandoned: (sessionId) => sessionManager.updateSessionStatus(sessionId, "abandoned"),
|
|
@@ -48,10 +48,10 @@ export const en = {
|
|
|
48
48
|
customAnswerLabel: "Custom answer",
|
|
49
49
|
customAnswerHint: "(Tab to submit)",
|
|
50
50
|
otherCustom: "Other (custom)",
|
|
51
|
-
placeholder: "Type your answer
|
|
51
|
+
placeholder: "Type your answer...",
|
|
52
52
|
singleLinePlaceholder: "Type here...",
|
|
53
53
|
multiLinePlaceholder: "Type your answer...",
|
|
54
|
-
elaboratePlaceholder: "Tell the AI what you need
|
|
54
|
+
elaboratePlaceholder: "Tell the AI what you need...",
|
|
55
55
|
},
|
|
56
56
|
question: {
|
|
57
57
|
multipleChoice: "Multiple Choice",
|
package/dist/src/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FastMCP } from "fastmcp";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { AskUserQuestionsParametersSchema, createAskUserQuestionsCore, } from "./core/ask-user-questions.js";
|
|
4
|
-
import { TOOL_DESCRIPTION } from "./shared/schemas.js";
|
|
4
|
+
import { GetAnsweredQuestionsArgsSchema, GET_ANSWERED_QUESTIONS_DESCRIPTION, TOOL_DESCRIPTION } from "./shared/schemas.js";
|
|
5
5
|
const askUserQuestionsCore = createAskUserQuestionsCore();
|
|
6
6
|
// Track active requests with their AbortControllers for disconnect handling
|
|
7
7
|
const activeRequests = new Map();
|
|
@@ -52,6 +52,17 @@ server.addTool({
|
|
|
52
52
|
const workingDirectory = ctx
|
|
53
53
|
.workingDirectory;
|
|
54
54
|
try {
|
|
55
|
+
// Handle non-blocking mode
|
|
56
|
+
if (args.nonBlocking) {
|
|
57
|
+
const { sessionId, questionCount } = await askUserQuestionsCore.askNonBlocking(args.questions, callId, workingDirectory);
|
|
58
|
+
const shortId = sessionId.slice(0, 8);
|
|
59
|
+
const responseText = `[Session: ${shortId} | Questions: ${questionCount} | Status: pending]\n\n` +
|
|
60
|
+
`Questions submitted successfully.\n` +
|
|
61
|
+
`Use get_answered_questions(session_id="${shortId}") or \`auq fetch-answers ${shortId}\` to retrieve answers.`;
|
|
62
|
+
return {
|
|
63
|
+
content: [{ text: responseText, type: "text" }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
55
66
|
const { formattedResponse, sessionId } = await askUserQuestionsCore.ask(args.questions, callId, workingDirectory, controller.signal);
|
|
56
67
|
// Update entry with sessionId for disconnect handler
|
|
57
68
|
const entry = activeRequests.get(callId);
|
|
@@ -59,11 +70,16 @@ server.addTool({
|
|
|
59
70
|
entry.sessionId = sessionId;
|
|
60
71
|
}
|
|
61
72
|
log.info("Session completed successfully", { sessionId, callId });
|
|
73
|
+
// Prepend metadata header to blocking responses
|
|
74
|
+
const shortId = sessionId.slice(0, 8);
|
|
75
|
+
const count = args.questions.length;
|
|
76
|
+
const header = `[Session: ${shortId} | Questions: ${count}]`;
|
|
77
|
+
const responseWithHeader = `${header}\n\n${formattedResponse}`;
|
|
62
78
|
// Return formatted response to AI model
|
|
63
79
|
return {
|
|
64
80
|
content: [
|
|
65
81
|
{
|
|
66
|
-
text:
|
|
82
|
+
text: responseWithHeader,
|
|
67
83
|
type: "text",
|
|
68
84
|
},
|
|
69
85
|
],
|
|
@@ -102,6 +118,47 @@ server.addTool({
|
|
|
102
118
|
},
|
|
103
119
|
parameters: AskUserQuestionsParametersSchema,
|
|
104
120
|
});
|
|
121
|
+
// Add the get_answered_questions tool
|
|
122
|
+
server.addTool({
|
|
123
|
+
name: "get_answered_questions",
|
|
124
|
+
annotations: {
|
|
125
|
+
title: "Get Answered Questions",
|
|
126
|
+
openWorldHint: false,
|
|
127
|
+
readOnlyHint: true,
|
|
128
|
+
idempotentHint: true,
|
|
129
|
+
},
|
|
130
|
+
description: GET_ANSWERED_QUESTIONS_DESCRIPTION,
|
|
131
|
+
parameters: GetAnsweredQuestionsArgsSchema,
|
|
132
|
+
execute: async (args, ctx) => {
|
|
133
|
+
const { log } = ctx;
|
|
134
|
+
const callId = randomUUID();
|
|
135
|
+
const controller = new AbortController();
|
|
136
|
+
activeRequests.set(callId, { controller });
|
|
137
|
+
try {
|
|
138
|
+
await askUserQuestionsCore.ensureInitialized();
|
|
139
|
+
const { formattedResponse, sessionId, status } = await askUserQuestionsCore.getAnsweredQuestions(args.session_id, args.blocking, controller.signal);
|
|
140
|
+
log.info("Fetched answered questions", { sessionId, status, callId });
|
|
141
|
+
return {
|
|
142
|
+
content: [{ text: formattedResponse, type: "text" }],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error instanceof Error && error.message === "ABORTED") {
|
|
147
|
+
log.warn("Fetch aborted: AI client disconnected", { callId });
|
|
148
|
+
return {
|
|
149
|
+
content: [{ text: "Fetch aborted: AI client disconnected", type: "text" }],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
log.error("Fetch answered questions failed", { error: String(error) });
|
|
153
|
+
return {
|
|
154
|
+
content: [{ text: `Error fetching answers: ${error}`, type: "text" }],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
activeRequests.delete(callId);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
});
|
|
105
162
|
// Handle AI client disconnections gracefully
|
|
106
163
|
// Note: FastMCP disconnect event support depends on the version.
|
|
107
164
|
// If the event is not available, stale detection handles orphaned sessions as fallback.
|
|
@@ -22,7 +22,7 @@ export class ResponseFormatter {
|
|
|
22
22
|
* @param questions - Original questions asked to the user
|
|
23
23
|
* @returns Formatted text response ready to send to AI model
|
|
24
24
|
*/
|
|
25
|
-
static formatUserResponse(answers, questions) {
|
|
25
|
+
static formatUserResponse(answers, questions, sessionId) {
|
|
26
26
|
// Validate that we have matching questions and answers
|
|
27
27
|
if (answers.answers.length === 0) {
|
|
28
28
|
throw new Error("No answers provided in session");
|
|
@@ -31,7 +31,12 @@ export class ResponseFormatter {
|
|
|
31
31
|
throw new Error("No questions provided");
|
|
32
32
|
}
|
|
33
33
|
// Start with header
|
|
34
|
-
const lines = [
|
|
34
|
+
const lines = [];
|
|
35
|
+
if (sessionId) {
|
|
36
|
+
const shortId = sessionId.slice(0, 8);
|
|
37
|
+
lines.push(`[Session: ${shortId} | Questions: ${questions.length}]`, "");
|
|
38
|
+
}
|
|
39
|
+
lines.push("Here are the user's answers:", "");
|
|
35
40
|
// Format each question and its answer(s)
|
|
36
41
|
// Note: A question can have multiple answers (e.g., regular answer + elaboration request)
|
|
37
42
|
const formattedQuestions = [];
|
|
@@ -136,6 +141,78 @@ export class ResponseFormatter {
|
|
|
136
141
|
static formatRephraseRequest(questionIndex, title) {
|
|
137
142
|
return `[REPHRASE_REQUEST] Please rephrase question '${title}' in a different way\nQuestion index: ${questionIndex}`;
|
|
138
143
|
}
|
|
144
|
+
/**
|
|
145
|
+
* Get the short form of a session ID (first 8 characters)
|
|
146
|
+
*
|
|
147
|
+
* @param sessionId - Full session ID
|
|
148
|
+
* @returns First 8 characters of the session ID
|
|
149
|
+
*/
|
|
150
|
+
static getShortId(sessionId) {
|
|
151
|
+
return sessionId.slice(0, 8);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Format a non-blocking submission confirmation message
|
|
155
|
+
*
|
|
156
|
+
* @param sessionId - Full session ID
|
|
157
|
+
* @param questionCount - Number of questions submitted
|
|
158
|
+
* @returns Formatted confirmation string
|
|
159
|
+
*/
|
|
160
|
+
static formatNonBlockingSubmission(sessionId, questionCount) {
|
|
161
|
+
const shortId = sessionId.slice(0, 8);
|
|
162
|
+
return [
|
|
163
|
+
`[Session: ${shortId} | Questions: ${questionCount} | Status: pending]`,
|
|
164
|
+
"",
|
|
165
|
+
"Questions submitted successfully.",
|
|
166
|
+
`Use get_answered_questions(session_id="${shortId}") or \`auq fetch-answers ${shortId}\` to retrieve answers.`,
|
|
167
|
+
].join("\n");
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Format a pending status message for a session
|
|
171
|
+
*
|
|
172
|
+
* @param sessionId - Full session ID
|
|
173
|
+
* @param remainingTime - Optional remaining time string (e.g., "4m 45s")
|
|
174
|
+
* @returns Formatted pending status string
|
|
175
|
+
*/
|
|
176
|
+
static formatPendingStatus(sessionId, remainingTime) {
|
|
177
|
+
const shortId = sessionId.slice(0, 8);
|
|
178
|
+
const header = remainingTime
|
|
179
|
+
? `[Session: ${shortId} | Status: pending | Remaining: ${remainingTime}]`
|
|
180
|
+
: `[Session: ${shortId} | Status: pending]`;
|
|
181
|
+
return [header, "", "No answers yet."].join("\n");
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Format a rejected status message for a session
|
|
185
|
+
*
|
|
186
|
+
* @param sessionId - Full session ID
|
|
187
|
+
* @param reason - Optional rejection reason
|
|
188
|
+
* @returns Formatted rejected status string
|
|
189
|
+
*/
|
|
190
|
+
static formatRejectedStatus(sessionId, reason) {
|
|
191
|
+
const shortId = sessionId.slice(0, 8);
|
|
192
|
+
const lines = [`[Session: ${shortId} | Status: rejected]`, ""];
|
|
193
|
+
if (reason) {
|
|
194
|
+
lines.push(`User rejected this question set. Reason: "${reason}"`);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
lines.push("User rejected this question set.");
|
|
198
|
+
}
|
|
199
|
+
return lines.join("\n");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Format a generic session status message (fallback for other statuses)
|
|
203
|
+
*
|
|
204
|
+
* @param sessionId - Full session ID
|
|
205
|
+
* @param status - Session status string (e.g., "abandoned", "timed_out")
|
|
206
|
+
* @returns Formatted status string
|
|
207
|
+
*/
|
|
208
|
+
static formatSessionStatus(sessionId, status) {
|
|
209
|
+
const shortId = sessionId.slice(0, 8);
|
|
210
|
+
return [
|
|
211
|
+
`[Session: ${shortId} | Status: ${status}]`,
|
|
212
|
+
"",
|
|
213
|
+
`Session status: ${status}`,
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
139
216
|
/**
|
|
140
217
|
* Format a single question and its answer(s)
|
|
141
218
|
*
|
|
@@ -164,6 +164,42 @@ export class SessionManager {
|
|
|
164
164
|
}
|
|
165
165
|
return pendingSessions;
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Get all unread sessions (completed sessions where lastReadAt is not set)
|
|
169
|
+
*/
|
|
170
|
+
async getUnreadSessions() {
|
|
171
|
+
const sessionIds = await this.getAllSessionIds();
|
|
172
|
+
const unreadSessions = [];
|
|
173
|
+
for (const sessionId of sessionIds) {
|
|
174
|
+
try {
|
|
175
|
+
const status = await this.getSessionStatus(sessionId);
|
|
176
|
+
if (!status || status.status !== "completed")
|
|
177
|
+
continue;
|
|
178
|
+
const answers = await this.getSessionAnswers(sessionId);
|
|
179
|
+
if (!answers || answers.lastReadAt)
|
|
180
|
+
continue;
|
|
181
|
+
unreadSessions.push({ sessionId, createdAt: status.createdAt });
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Sort by createdAt descending (newest first)
|
|
188
|
+
unreadSessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
189
|
+
return unreadSessions.map((s) => s.sessionId);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Mark a session as read by updating lastReadAt in answers.json
|
|
193
|
+
*/
|
|
194
|
+
async markSessionAsRead(sessionId) {
|
|
195
|
+
const answers = await this.readSessionFile(sessionId, SESSION_FILES.ANSWERS);
|
|
196
|
+
if (!answers) {
|
|
197
|
+
throw new Error(`Session ${sessionId} has no answers to mark as read`);
|
|
198
|
+
}
|
|
199
|
+
answers.lastReadAt = new Date().toISOString();
|
|
200
|
+
await this.writeSessionFile(sessionId, SESSION_FILES.ANSWERS, answers);
|
|
201
|
+
return answers;
|
|
202
|
+
}
|
|
167
203
|
/**
|
|
168
204
|
* Get session count
|
|
169
205
|
*/
|
|
@@ -924,4 +924,90 @@ describe("ResponseFormatter", () => {
|
|
|
924
924
|
"Question index: 0");
|
|
925
925
|
});
|
|
926
926
|
});
|
|
927
|
+
describe("metadata header", () => {
|
|
928
|
+
const validQuestions = [
|
|
929
|
+
{
|
|
930
|
+
options: [
|
|
931
|
+
{ description: "Type-safe JavaScript", label: "TypeScript" },
|
|
932
|
+
{ description: "Dynamic web language", label: "JavaScript" },
|
|
933
|
+
],
|
|
934
|
+
prompt: "Which language do you prefer?",
|
|
935
|
+
title: "Language",
|
|
936
|
+
},
|
|
937
|
+
];
|
|
938
|
+
const validAnswers = {
|
|
939
|
+
answers: [
|
|
940
|
+
{
|
|
941
|
+
questionIndex: 0,
|
|
942
|
+
selectedOption: "TypeScript",
|
|
943
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
944
|
+
},
|
|
945
|
+
],
|
|
946
|
+
sessionId: "a3f2e8b1-1234-4567-89ab-cdef01234567",
|
|
947
|
+
timestamp: "2025-01-01T00:00:00Z",
|
|
948
|
+
};
|
|
949
|
+
it("should prepend metadata header when sessionId is provided", () => {
|
|
950
|
+
const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions, "a3f2e8b1-1234-4567-89ab-cdef01234567");
|
|
951
|
+
expect(result).toMatch(/^\[Session: a3f2e8b1 \| Questions: \d+\]/);
|
|
952
|
+
expect(result).toContain("Here are the user's answers:");
|
|
953
|
+
});
|
|
954
|
+
it("should not include header when sessionId is omitted", () => {
|
|
955
|
+
const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions);
|
|
956
|
+
expect(result).not.toContain("[Session:");
|
|
957
|
+
expect(result.startsWith("Here are the user's answers:")).toBe(true);
|
|
958
|
+
});
|
|
959
|
+
it("should use short ID (first 8 chars)", () => {
|
|
960
|
+
const result = ResponseFormatter.formatUserResponse(validAnswers, validQuestions, "abcdef12-3456-4789-abcd-ef0123456789");
|
|
961
|
+
expect(result).toContain("[Session: abcdef12");
|
|
962
|
+
expect(result).not.toContain("abcdef12-3456");
|
|
963
|
+
});
|
|
964
|
+
});
|
|
965
|
+
describe("formatNonBlockingSubmission", () => {
|
|
966
|
+
it("should format non-blocking submission message", () => {
|
|
967
|
+
const result = ResponseFormatter.formatNonBlockingSubmission("a3f2e8b1-1234-4567-89ab-cdef01234567", 3);
|
|
968
|
+
expect(result).toContain("[Session: a3f2e8b1 | Questions: 3 | Status: pending]");
|
|
969
|
+
expect(result).toContain("Questions submitted successfully.");
|
|
970
|
+
expect(result).toContain("get_answered_questions");
|
|
971
|
+
expect(result).toContain("auq fetch-answers");
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
describe("formatPendingStatus", () => {
|
|
975
|
+
it("should format pending status without remaining time", () => {
|
|
976
|
+
const result = ResponseFormatter.formatPendingStatus("a3f2e8b1-1234-4567-89ab-cdef01234567");
|
|
977
|
+
expect(result).toContain("[Session: a3f2e8b1 | Status: pending]");
|
|
978
|
+
expect(result).toContain("No answers yet.");
|
|
979
|
+
});
|
|
980
|
+
it("should include remaining time when provided", () => {
|
|
981
|
+
const result = ResponseFormatter.formatPendingStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "4m 45s");
|
|
982
|
+
expect(result).toContain("Remaining: 4m 45s");
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
describe("formatRejectedStatus", () => {
|
|
986
|
+
it("should format rejected with reason", () => {
|
|
987
|
+
const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "not applicable");
|
|
988
|
+
expect(result).toContain("[Session: a3f2e8b1 | Status: rejected]");
|
|
989
|
+
expect(result).toContain('Reason: "not applicable"');
|
|
990
|
+
});
|
|
991
|
+
it("should format rejected without reason", () => {
|
|
992
|
+
const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567");
|
|
993
|
+
expect(result).toContain("[Session: a3f2e8b1 | Status: rejected]");
|
|
994
|
+
expect(result).toContain("User rejected this question set.");
|
|
995
|
+
expect(result).not.toContain("Reason:");
|
|
996
|
+
});
|
|
997
|
+
it("should handle null reason", () => {
|
|
998
|
+
const result = ResponseFormatter.formatRejectedStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", null);
|
|
999
|
+
expect(result).not.toContain("Reason:");
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
describe("formatSessionStatus", () => {
|
|
1003
|
+
it("should format generic session status", () => {
|
|
1004
|
+
const result = ResponseFormatter.formatSessionStatus("a3f2e8b1-1234-4567-89ab-cdef01234567", "abandoned");
|
|
1005
|
+
expect(result).toContain("[Session: a3f2e8b1 | Status: abandoned]");
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
describe("getShortId", () => {
|
|
1009
|
+
it("should return first 8 characters", () => {
|
|
1010
|
+
expect(ResponseFormatter.getShortId("a3f2e8b1-1234-4567-89ab-cdef01234567")).toBe("a3f2e8b1");
|
|
1011
|
+
});
|
|
1012
|
+
});
|
|
927
1013
|
});
|
|
@@ -655,4 +655,133 @@ describe("SessionManager", () => {
|
|
|
655
655
|
expect(pending).toEqual([]);
|
|
656
656
|
});
|
|
657
657
|
});
|
|
658
|
+
describe("read tracking", () => {
|
|
659
|
+
const testQuestions = [
|
|
660
|
+
{
|
|
661
|
+
options: [
|
|
662
|
+
{ description: "Dynamic web language", label: "JavaScript" },
|
|
663
|
+
{ description: "Typed superset of JavaScript", label: "TypeScript" },
|
|
664
|
+
],
|
|
665
|
+
prompt: "Which programming language do you prefer?",
|
|
666
|
+
title: "Language",
|
|
667
|
+
},
|
|
668
|
+
];
|
|
669
|
+
it("should mark session as read and persist lastReadAt", async () => {
|
|
670
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
671
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
672
|
+
sessionId,
|
|
673
|
+
timestamp: new Date().toISOString(),
|
|
674
|
+
answers: [
|
|
675
|
+
{
|
|
676
|
+
questionIndex: 0,
|
|
677
|
+
timestamp: new Date().toISOString(),
|
|
678
|
+
selectedOption: testQuestions[0].options[0].label,
|
|
679
|
+
},
|
|
680
|
+
],
|
|
681
|
+
});
|
|
682
|
+
const result = await sessionManager.markSessionAsRead(sessionId);
|
|
683
|
+
expect(result.lastReadAt).toBeDefined();
|
|
684
|
+
expect(new Date(result.lastReadAt).getTime()).toBeGreaterThan(0);
|
|
685
|
+
// Verify persistence
|
|
686
|
+
const answers = await sessionManager.getSessionAnswers(sessionId);
|
|
687
|
+
expect(answers?.lastReadAt).toBe(result.lastReadAt);
|
|
688
|
+
});
|
|
689
|
+
it("should update lastReadAt on repeated calls", async () => {
|
|
690
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
691
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
692
|
+
sessionId,
|
|
693
|
+
timestamp: new Date().toISOString(),
|
|
694
|
+
answers: [
|
|
695
|
+
{
|
|
696
|
+
questionIndex: 0,
|
|
697
|
+
timestamp: new Date().toISOString(),
|
|
698
|
+
selectedOption: testQuestions[0].options[0].label,
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
});
|
|
702
|
+
const first = await sessionManager.markSessionAsRead(sessionId);
|
|
703
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
704
|
+
const second = await sessionManager.markSessionAsRead(sessionId);
|
|
705
|
+
expect(second.lastReadAt).toBeDefined();
|
|
706
|
+
expect(first.lastReadAt).not.toBe(second.lastReadAt);
|
|
707
|
+
});
|
|
708
|
+
it("should throw when marking session without answers", async () => {
|
|
709
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
710
|
+
await expect(sessionManager.markSessionAsRead(sessionId)).rejects.toThrow("answers.json");
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
describe("getUnreadSessions", () => {
|
|
714
|
+
const testQuestions = [
|
|
715
|
+
{
|
|
716
|
+
options: [
|
|
717
|
+
{ description: "Dynamic web language", label: "JavaScript" },
|
|
718
|
+
{ description: "Typed superset of JavaScript", label: "TypeScript" },
|
|
719
|
+
],
|
|
720
|
+
prompt: "Which programming language do you prefer?",
|
|
721
|
+
title: "Language",
|
|
722
|
+
},
|
|
723
|
+
];
|
|
724
|
+
async function createCompletedSession(manager) {
|
|
725
|
+
const sessionId = await manager.createSession(testQuestions);
|
|
726
|
+
await manager.saveSessionAnswers(sessionId, {
|
|
727
|
+
sessionId,
|
|
728
|
+
timestamp: new Date().toISOString(),
|
|
729
|
+
answers: [
|
|
730
|
+
{
|
|
731
|
+
questionIndex: 0,
|
|
732
|
+
timestamp: new Date().toISOString(),
|
|
733
|
+
selectedOption: testQuestions[0].options[0].label,
|
|
734
|
+
},
|
|
735
|
+
],
|
|
736
|
+
});
|
|
737
|
+
await manager.updateSessionStatus(sessionId, "completed");
|
|
738
|
+
return sessionId;
|
|
739
|
+
}
|
|
740
|
+
it("should return completed unread sessions", async () => {
|
|
741
|
+
const sessionId = await createCompletedSession(sessionManager);
|
|
742
|
+
const unread = await sessionManager.getUnreadSessions();
|
|
743
|
+
expect(unread).toContain(sessionId);
|
|
744
|
+
});
|
|
745
|
+
it("should exclude read sessions", async () => {
|
|
746
|
+
const sessionId = await createCompletedSession(sessionManager);
|
|
747
|
+
await sessionManager.markSessionAsRead(sessionId);
|
|
748
|
+
const unread = await sessionManager.getUnreadSessions();
|
|
749
|
+
expect(unread).not.toContain(sessionId);
|
|
750
|
+
});
|
|
751
|
+
it("should exclude pending sessions", async () => {
|
|
752
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
753
|
+
const unread = await sessionManager.getUnreadSessions();
|
|
754
|
+
expect(unread).not.toContain(sessionId);
|
|
755
|
+
});
|
|
756
|
+
it("should exclude rejected sessions", async () => {
|
|
757
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
758
|
+
await sessionManager.rejectSession(sessionId, "test");
|
|
759
|
+
const unread = await sessionManager.getUnreadSessions();
|
|
760
|
+
expect(unread).not.toContain(sessionId);
|
|
761
|
+
});
|
|
762
|
+
it("should sort newest first", async () => {
|
|
763
|
+
const id1 = await createCompletedSession(sessionManager);
|
|
764
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
765
|
+
const id2 = await createCompletedSession(sessionManager);
|
|
766
|
+
const unread = await sessionManager.getUnreadSessions();
|
|
767
|
+
expect(unread[0]).toBe(id2); // newest first
|
|
768
|
+
expect(unread[1]).toBe(id1);
|
|
769
|
+
});
|
|
770
|
+
it("should not mark as read when saving answers", async () => {
|
|
771
|
+
const sessionId = await sessionManager.createSession(testQuestions);
|
|
772
|
+
await sessionManager.saveSessionAnswers(sessionId, {
|
|
773
|
+
sessionId,
|
|
774
|
+
timestamp: new Date().toISOString(),
|
|
775
|
+
answers: [
|
|
776
|
+
{
|
|
777
|
+
questionIndex: 0,
|
|
778
|
+
timestamp: new Date().toISOString(),
|
|
779
|
+
selectedOption: testQuestions[0].options[0].label,
|
|
780
|
+
},
|
|
781
|
+
],
|
|
782
|
+
});
|
|
783
|
+
const answers = await sessionManager.getSessionAnswers(sessionId);
|
|
784
|
+
expect(answers?.lastReadAt).toBeUndefined();
|
|
785
|
+
});
|
|
786
|
+
});
|
|
658
787
|
});
|
|
@@ -85,6 +85,7 @@ export function createAskUserQuestionsParametersSchema(maxQuestions = DEFAULT_LI
|
|
|
85
85
|
questions: questionsSchema.describe(`Questions to ask the user (1-${maxQuestions} questions). ` +
|
|
86
86
|
`Each question must include: prompt (full question text), title (short label, max 12 chars), ` +
|
|
87
87
|
`options (2-${maxOptions} choices with labels and descriptions), and multiSelect (boolean).`),
|
|
88
|
+
nonBlocking: z.boolean().default(false).describe("Set to true to submit questions without waiting for answers. The tool will return immediately with a session ID that can be used with get_answered_questions to fetch answers later. Default: false (blocking mode)."),
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
// Default schemas for backward compatibility (using DEFAULT_LIMITS)
|
|
@@ -106,3 +107,10 @@ export const TOOL_DESCRIPTION = "Use this tool when you need to ask the user que
|
|
|
106
107
|
'Recommend an option unless absolutely necessary, make it the first option in the list and add "(Recommended)" at the end of the label\n' +
|
|
107
108
|
'For multiSelect questions, you MAY mark multiple options as "(Recommended)" if several choices are advisable\n' +
|
|
108
109
|
'Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?"';
|
|
110
|
+
export const GetAnsweredQuestionsArgsSchema = z.object({
|
|
111
|
+
session_id: z.string().describe("The session ID returned from a non-blocking ask_user_questions call. Accepts both full UUID and short (first 8 chars) ID."),
|
|
112
|
+
blocking: z.boolean().default(false).describe("If true, wait until the user answers before returning. If false (default), return immediately with current status."),
|
|
113
|
+
});
|
|
114
|
+
export const GET_ANSWERED_QUESTIONS_DESCRIPTION = "Fetch answers for a previously submitted non-blocking question set. " +
|
|
115
|
+
"Use this after calling ask_user_questions with nonBlocking: true. " +
|
|
116
|
+
"Returns the user's answers if available, or current session status if still pending.";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
|
2
2
|
import { useInput } from "ink";
|
|
3
3
|
import { ThemeContext } from "./ThemeContext.js";
|
|
4
|
-
import { getTheme, listThemes, darkTheme, hasTheme } from "./themes/index.js";
|
|
5
|
-
import { detectSystemTheme } from "./utils/detectTheme.js";
|
|
6
|
-
import { getSavedTheme, saveTheme } from "./utils/config.js";
|
|
4
|
+
import { getTheme, listThemes, darkTheme, hasTheme } from "./shared/themes/index.js";
|
|
5
|
+
import { detectSystemTheme } from "./shared/utils/detectTheme.js";
|
|
6
|
+
import { getSavedTheme, saveTheme } from "./shared/utils/config.js";
|
|
7
7
|
import { KEYS } from "./constants/keybindings.js";
|
|
8
8
|
function resolveTheme(mode) {
|
|
9
9
|
if (mode === "system") {
|
|
@@ -8,7 +8,7 @@ import { UpdateBadge } from "./UpdateBadge.js";
|
|
|
8
8
|
* Header component - displays app logo and status
|
|
9
9
|
* Shows at the top of the TUI with gradient branding and live-updating pending queue count
|
|
10
10
|
*/
|
|
11
|
-
export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
|
|
11
|
+
export const Header = ({ pendingCount, isCheckingUpdate, updateInfo, onUpdateBadgeActivate }) => {
|
|
12
12
|
const { theme } = useTheme();
|
|
13
13
|
const [flash, setFlash] = useState(false);
|
|
14
14
|
const [prevCount, setPrevCount] = useState(pendingCount);
|
|
@@ -38,6 +38,7 @@ export const Header = ({ pendingCount, updateInfo, onUpdateBadgeActivate }) => {
|
|
|
38
38
|
version),
|
|
39
39
|
updateInfo && (React.createElement(UpdateBadge, { updateType: updateInfo.updateType, latestVersion: updateInfo.latestVersion })),
|
|
40
40
|
React.createElement(Text, { dimColor: true }, " "),
|
|
41
|
+
isCheckingUpdate && (React.createElement(Text, { dimColor: true }, " checking... ")),
|
|
41
42
|
React.createElement(Text, { backgroundColor: theme.components.header.pillBg, bold: flash, color: flash
|
|
42
43
|
? theme.components.header.queueFlash
|
|
43
44
|
: pendingCount > 0
|
|
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react";
|
|
|
3
3
|
import { t } from "../../i18n/index.js";
|
|
4
4
|
import { useConfig } from "../ConfigContext.js";
|
|
5
5
|
import { useTheme } from "../ThemeContext.js";
|
|
6
|
-
import { isRecommendedOption } from "../utils/recommended.js";
|
|
6
|
+
import { isRecommendedOption } from "../shared/utils/recommended.js";
|
|
7
7
|
import { fitToVisualWidth, getVisualWidth, padToVisualWidth, } from "../utils/visualWidth.js";
|
|
8
8
|
import { MultiLineTextInput } from "./MultiLineTextInput.js";
|
|
9
9
|
// isRecommendedOption is imported from ../utils/recommended.js
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
2
2
|
import React, { useEffect, useState } from "react";
|
|
3
3
|
import { useTheme } from "../ThemeContext.js";
|
|
4
|
-
import { formatRelativeTime } from "../utils/relativeTime.js";
|
|
4
|
+
import { formatRelativeTime } from "../shared/utils/relativeTime.js";
|
|
5
5
|
import { KEYS } from "../constants/keybindings.js";
|
|
6
6
|
/* ------------------------------------------------------------------ */
|
|
7
7
|
/* Helpers */
|
|
@@ -6,7 +6,7 @@ import { SessionManager } from "../../session/SessionManager.js";
|
|
|
6
6
|
import { getSessionDirectory } from "../../session/utils.js";
|
|
7
7
|
import { useTheme } from "../ThemeContext.js";
|
|
8
8
|
import { useConfig } from "../ConfigContext.js";
|
|
9
|
-
import { isRecommendedOption } from "../utils/recommended.js";
|
|
9
|
+
import { isRecommendedOption } from "../shared/utils/recommended.js";
|
|
10
10
|
import { KEYS } from "../constants/keybindings.js";
|
|
11
11
|
import { ConfirmationDialog } from "./ConfirmationDialog.js";
|
|
12
12
|
import { QuestionDisplay } from "./QuestionDisplay.js";
|
|
@@ -17,7 +17,7 @@ vi.mock("ink", async () => {
|
|
|
17
17
|
};
|
|
18
18
|
});
|
|
19
19
|
import { ThemeContext } from "../../ThemeContext.js";
|
|
20
|
-
import { darkTheme } from "../../themes/dark.js";
|
|
20
|
+
import { darkTheme } from "../../shared/themes/dark.js";
|
|
21
21
|
import { ConfirmationDialog } from "../ConfirmationDialog.js";
|
|
22
22
|
const mockThemeValue = {
|
|
23
23
|
theme: darkTheme,
|
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
import { cleanup, render } from "ink-testing-library";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import { ThemeContext } from "../../ThemeContext.js";
|
|
5
|
-
import { darkTheme } from "../../themes/dark.js";
|
|
5
|
+
import { darkTheme } from "../../shared/themes/dark.js";
|
|
6
6
|
import { KEY_LABELS } from "../../constants/keybindings.js";
|
|
7
7
|
import { Footer } from "../Footer.js";
|
|
8
8
|
const mockThemeValue = {
|