Skip to content

Commit 499dba7

Browse files
authored
Merge pull request #7472 from continuedev/nate/cn-serve-session
Nate/cn-serve-session
2 parents 9f68638 + 4e1d2bd commit 499dba7

File tree

11 files changed

+121
-127
lines changed

11 files changed

+121
-127
lines changed

extensions/cli/src/commands/serve.helpers.ts

Lines changed: 36 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import type {
2-
ChatHistoryItem,
3-
Session,
4-
ToolCallState,
5-
ToolStatus,
6-
} from "core/index.js";
1+
import type { Session, ToolCallState, ToolStatus } from "core/index.js";
72

83
import { streamChatResponse } from "../stream/streamChatResponse.js";
94
import { StreamCallbacks } from "../stream/streamChatResponse.types.js";
@@ -27,85 +22,45 @@ export async function streamChatResponseWithInterruption(
2722
// Set up periodic interruption checks
2823
const interruptionChecker = setInterval(checkInterruption, 100);
2924

30-
let currentStreamingItem: ChatHistoryItem | null = null;
31-
3225
// Create callbacks to capture tool events
3326
const callbacks: StreamCallbacks = {
34-
onContent: (content: string) => {
35-
if (!currentStreamingItem) {
36-
currentStreamingItem = {
37-
message: { role: "assistant", content: "" },
38-
contextItems: [],
39-
};
40-
state.session.history.push(currentStreamingItem);
41-
}
42-
currentStreamingItem.message.content =
43-
(currentStreamingItem.message.content as string) + content;
27+
onContent: (_: string) => {
28+
// onContent is empty - doesn't update history during streaming
29+
// This is just for real-time display purposes
4430
},
45-
onContentComplete: (content: string) => {
46-
if (currentStreamingItem) {
47-
currentStreamingItem.message.content = content;
48-
currentStreamingItem = null;
49-
} else {
50-
// Add complete assistant message
51-
state.session.history.push({
52-
message: { role: "assistant", content: content },
53-
contextItems: [],
54-
});
55-
}
31+
onContentComplete: (_: string) => {
32+
// Note: streamChatResponse already adds messages to history via handleToolCalls
33+
// so we don't need to add them here - this callback is just for notification
34+
// that content streaming is complete
5635
},
57-
onToolStart: (toolName: string, toolArgs?: any) => {
58-
// If there was streaming content, finalize it first
59-
if (currentStreamingItem && currentStreamingItem.message.content) {
60-
currentStreamingItem = null;
61-
}
62-
63-
// Always create a new assistant message for each tool call
64-
const toolCallId = `tool_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
65-
const toolCallState: ToolCallState = {
66-
toolCallId: toolCallId,
67-
toolCall: {
68-
id: toolCallId,
69-
type: "function",
70-
function: {
71-
name: toolName,
72-
arguments: JSON.stringify(toolArgs || {}),
73-
},
74-
},
75-
status: "calling",
76-
parsedArgs: toolArgs,
77-
};
78-
79-
state.session.history.push({
80-
message: { role: "assistant", content: "" },
81-
contextItems: [],
82-
toolCallStates: [toolCallState],
83-
});
36+
onToolStart: (__: string, _?: any) => {
37+
// Note: handleToolCalls already adds the tool call message to history
38+
// This callback is just for notification/UI updates
39+
// The tool call state is already created and added by handleToolCalls
8440
},
8541
onToolResult: (result: string, toolName: string, status: ToolStatus) => {
86-
// Find and update the corresponding tool call state
42+
// Update only the tool call state status
43+
// The actual result is already added as a separate message by handleToolCalls
8744
for (let i = state.session.history.length - 1; i >= 0; i--) {
8845
const item = state.session.history[i];
8946
if (item.toolCallStates) {
9047
const toolState = item.toolCallStates.find(
9148
(ts: ToolCallState) =>
92-
ts.toolCall.function.name === toolName && ts.status === "calling",
49+
ts.toolCall.function.name === toolName &&
50+
(ts.status === "calling" || ts.status === "generated"),
9351
);
9452
if (toolState) {
53+
// Only update the status, not the output
9554
toolState.status = status;
96-
toolState.output = [
97-
{
98-
content: result,
99-
name: `Tool Result: ${toolName}`,
100-
description: "Tool execution result",
101-
},
102-
];
103-
return;
55+
break;
10456
}
10557
}
10658
}
10759
},
10860
onToolError: (error: string, toolName?: string) => {
61+
// Only update the tool call state to errored status when tool name is provided
62+
// The error message is already added as a separate tool result message
63+
// by handleToolCalls/preprocessStreamedToolCalls/executeStreamedToolCalls
10964
if (toolName) {
11065
// Find and update the corresponding tool call state
11166
for (let i = state.session.history.length - 1; i >= 0; i--) {
@@ -114,39 +69,30 @@ export async function streamChatResponseWithInterruption(
11469
const toolState = item.toolCallStates.find(
11570
(ts: ToolCallState) =>
11671
ts.toolCall.function.name === toolName &&
117-
ts.status === "calling",
72+
(ts.status === "calling" || ts.status === "generated"),
11873
);
11974
if (toolState) {
75+
// Only update the status, not the output
12076
toolState.status = "errored";
121-
toolState.output = [
122-
{
123-
content: error,
124-
name: `Tool Error: ${toolName}`,
125-
description: "Tool execution error",
126-
},
127-
];
128-
return;
77+
break;
12978
}
13079
}
13180
}
13281
}
133-
// Generic error if tool not found
134-
state.session.history.push({
135-
message: { role: "system", content: error },
136-
contextItems: [],
137-
});
13882
},
13983
onToolPermissionRequest: (
14084
toolName: string,
14185
toolArgs: any,
14286
requestId: string,
87+
toolCallPreview?: any[],
14388
) => {
14489
// Set pending permission state
14590
state.pendingPermission = {
14691
toolName,
14792
toolArgs,
14893
requestId,
14994
timestamp: Date.now(),
95+
toolCallPreview,
15096
};
15197

15298
// Add a system message indicating permission is needed
@@ -160,6 +106,15 @@ export async function streamChatResponseWithInterruption(
160106

161107
// Don't wait here - the streamChatResponse will handle waiting
162108
},
109+
onSystemMessage: (message: string) => {
110+
state.session.history.push({
111+
message: {
112+
role: "system",
113+
content: message,
114+
},
115+
contextItems: [],
116+
});
117+
},
163118
};
164119

165120
try {
@@ -173,10 +128,6 @@ export async function streamChatResponseWithInterruption(
173128
return response || "";
174129
} finally {
175130
clearInterval(interruptionChecker);
176-
// Ensure any streaming message is finalized
177-
if (currentStreamingItem !== null) {
178-
currentStreamingItem = null;
179-
}
180131
}
181132
}
182133

@@ -185,6 +136,7 @@ export interface PendingPermission {
185136
toolArgs: any;
186137
requestId: string;
187138
timestamp: number;
139+
toolCallPreview?: any[];
188140
}
189141

190142
export interface ServerState {

extensions/cli/src/commands/serve.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { promisify } from "util";
33

44
import chalk from "chalk";
55
import type { ChatHistoryItem } from "core/index.js";
6-
import { convertFromUnifiedHistory } from "core/util/messageConversion.js";
76
import express, { Request, Response } from "express";
87

98
import { getAssistantSlug } from "../auth/workos.js";
@@ -148,7 +147,7 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
148147
app.get("/state", (_req: Request, res: Response) => {
149148
state.lastActivity = Date.now();
150149
res.json({
151-
chatHistory: convertFromUnifiedHistory(state.session.history), // Convert back to legacy format for API
150+
session: state.session, // Return session directly instead of converting
152151
isProcessing: state.isProcessing,
153152
messageQueueLength: state.messageQueue.length,
154153
pendingPermission: state.pendingPermission,

extensions/cli/src/e2e/auth.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe("E2E: Authentication", () => {
3030
authPath,
3131
JSON.stringify({
3232
userId: "test-user",
33-
userEmail: "[email protected]",
33+
userEmail: "[email protected]",
3434
accessToken: "test-token",
3535
refreshToken: "test-refresh-token",
3636
expiresAt: Date.now() + 3600000,
@@ -39,12 +39,12 @@ describe("E2E: Authentication", () => {
3939
);
4040

4141
// Run logout
42-
const result = await runCLI(context, {
42+
const result = await runCLI(context, {
4343
args: ["logout"],
4444
env: {
4545
HOME: context.testDir,
46-
CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue")
47-
}
46+
CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue"),
47+
},
4848
});
4949

5050
expect(result.exitCode).toBe(0);
@@ -59,12 +59,12 @@ describe("E2E: Authentication", () => {
5959
});
6060

6161
it("should handle logout when not logged in", async () => {
62-
const result = await runCLI(context, {
62+
const result = await runCLI(context, {
6363
args: ["logout"],
6464
env: {
6565
HOME: context.testDir,
66-
CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue")
67-
}
66+
CONTINUE_GLOBAL_DIR: path.join(context.testDir, ".continue"),
67+
},
6868
});
6969

7070
expect(result.exitCode).toBe(0);

extensions/cli/src/e2e/resume-flag.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ describe("E2E: Resume Flag", () => {
4646

4747
// Verify that a session file was created
4848
const sessionDir = path.join(context.testDir, ".continue", "sessions");
49-
49+
5050
// Ensure session directory exists
5151
try {
5252
await fs.mkdir(sessionDir, { recursive: true });
5353
} catch (error) {
5454
// Directory already exists, continue
5555
}
56-
56+
5757
const sessionFiles = (await fs.readdir(sessionDir)).filter(
58-
f => f.endsWith('.json') && f !== 'sessions.json'
58+
(f) => f.endsWith(".json") && f !== "sessions.json",
5959
);
6060
expect(sessionFiles).toHaveLength(1);
6161

@@ -150,16 +150,16 @@ describe("E2E: Resume Flag", () => {
150150

151151
// Verify the first session was saved correctly
152152
const sessionDir = path.join(context.testDir, ".continue", "sessions");
153-
153+
154154
// Ensure session directory exists
155155
try {
156156
await fs.mkdir(sessionDir, { recursive: true });
157157
} catch (error) {
158158
// Directory already exists, continue
159159
}
160-
160+
161161
let sessionFiles = (await fs.readdir(sessionDir)).filter(
162-
f => f.endsWith('.json') && f !== 'sessions.json'
162+
(f) => f.endsWith(".json") && f !== "sessions.json",
163163
);
164164
expect(sessionFiles).toHaveLength(1);
165165

@@ -193,7 +193,7 @@ describe("E2E: Resume Flag", () => {
193193

194194
// Check that the session file contains both messages
195195
sessionFiles = (await fs.readdir(sessionDir)).filter(
196-
f => f.endsWith('.json') && f !== 'sessions.json'
196+
(f) => f.endsWith(".json") && f !== "sessions.json",
197197
);
198198
sessionFile = sessionFiles[0];
199199
sessionPath = path.join(sessionDir, sessionFile);

extensions/cli/src/slashCommands.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,28 @@ describe("slashCommands", () => {
9696
});
9797

9898
describe("handleSlashCommands", () => {
99+
it("should handle /help command", async () => {
100+
const result = await handleSlashCommands("/help", mockAssistant);
101+
102+
expect(result).toBeDefined();
103+
expect(result?.output).toContain("Keyboard Shortcuts:");
104+
expect(result?.output).toContain("Navigation:");
105+
expect(result?.output).toContain("↑/↓");
106+
expect(result?.output).toContain("Tab");
107+
expect(result?.output).toContain("Enter");
108+
expect(result?.output).toContain("Shift+Enter");
109+
expect(result?.output).toContain("Controls:");
110+
expect(result?.output).toContain("Ctrl+C");
111+
expect(result?.output).toContain("Ctrl+D");
112+
expect(result?.output).toContain("Ctrl+L");
113+
expect(result?.output).toContain("Shift+Tab");
114+
expect(result?.output).toContain("Esc");
115+
expect(result?.output).toContain("Special Characters:");
116+
expect(result?.output).toContain("@");
117+
expect(result?.output).toContain("/");
118+
expect(result?.exit).toBeUndefined();
119+
});
120+
99121
it("should handle /info command when not authenticated", async () => {
100122
const { isAuthenticated } = await import("./auth/workos.js");
101123
const { services } = await import("./services/index.js");

extensions/cli/src/slashCommands.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,30 @@ type CommandHandler = (
1818
assistant: AssistantConfig,
1919
) => Promise<SlashCommandResult> | SlashCommandResult;
2020

21-
async function handleHelp(args: string[], assistant: AssistantConfig) {
22-
const allCommands = getAllSlashCommands(assistant);
21+
async function handleHelp(_args: string[], _assistant: AssistantConfig) {
2322
const helpMessage = [
24-
"Slash commands:",
25-
...allCommands
26-
.sort((a, b) => a.name.localeCompare(b.name))
27-
.map(
28-
(cmd) =>
29-
`- ${chalk.white(`/${cmd.name}:`)} ${chalk.gray(cmd.description)}`,
30-
),
23+
chalk.bold("Keyboard Shortcuts:"),
24+
"",
25+
chalk.white("Navigation:"),
26+
` ${chalk.cyan("↑/↓")} Navigate command/file suggestions or history`,
27+
` ${chalk.cyan("Tab")} Complete command or file selection`,
28+
` ${chalk.cyan("Enter")} Submit message`,
29+
` ${chalk.cyan("Shift+Enter")} New line`,
30+
` ${chalk.cyan("\\")} Line continuation (at end of line)`,
31+
"",
32+
chalk.white("Controls:"),
33+
` ${chalk.cyan("Ctrl+C")} Clear input`,
34+
` ${chalk.cyan("Ctrl+D")} Exit application`,
35+
` ${chalk.cyan("Ctrl+L")} Clear screen`,
36+
` ${chalk.cyan("Shift+Tab")} Cycle permission modes (normal/plan/auto)`,
37+
` ${chalk.cyan("Esc")} Cancel streaming or close suggestions`,
38+
"",
39+
chalk.white("Special Characters:"),
40+
` ${chalk.cyan("@")} Search and attach files for context`,
41+
` ${chalk.cyan("/")} Access slash commands`,
42+
"",
43+
chalk.white("Available Commands:"),
44+
` Type ${chalk.cyan("/")} to see available slash commands`,
3145
].join("\n");
3246
posthogService.capture("useSlashCommand", { name: "help" });
3347
return { output: helpMessage };

extensions/cli/src/ui/ToolResultSummary.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,9 @@ const ToolResultSummary: React.FC<ToolResultSummaryProps> = ({
8585
<Text color="gray"> Terminal output:</Text>
8686
</Box>
8787
<Box paddingLeft={2}>
88-
<Text color={isStderr ? "red" : "white"}>{actualOutput}</Text>
88+
<Text color={isStderr ? "red" : "white"}>
89+
{actualOutput.trimEnd()}
90+
</Text>
8991
</Box>
9092
</Box>
9193
);
@@ -99,7 +101,9 @@ const ToolResultSummary: React.FC<ToolResultSummaryProps> = ({
99101
<Text color="gray"> Terminal output:</Text>
100102
</Box>
101103
<Box paddingLeft={2}>
102-
<Text color={isStderr ? "red" : "white"}>{firstLines}</Text>
104+
<Text color={isStderr ? "red" : "white"}>
105+
{firstLines.trimEnd()}
106+
</Text>
103107
</Box>
104108
<Box paddingLeft={2}>
105109
<Text color="gray">

0 commit comments

Comments
 (0)