Skip to content

Commit 457201e

Browse files
committed
feat(acp-ai-provider): Add session persistence for multi-turn conversations
This introduces session persistence to the ACP AI Provider, enabling multi-turn conversations and the ability to resume previous sessions.
1 parent dcdec56 commit 457201e

File tree

6 files changed

+141
-101
lines changed

6 files changed

+141
-101
lines changed

packages/acp-ai-provider/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,37 @@ const result = await generateText({
108108
});
109109
```
110110

111+
### Session Persistence
112+
113+
Keep sessions alive for multi-turn conversations:
114+
115+
```typescript
116+
const provider = createACPProvider({
117+
command: "gemini",
118+
args: ["--experimental-acp"],
119+
session: { cwd: process.cwd(), mcpServers: [] },
120+
persistSession: true, // Keep session alive
121+
});
122+
123+
const model = provider.languageModel();
124+
await generateText({ model, prompt: "Hi, my name is Alice" });
125+
await generateText({ model, prompt: "What's my name?" }); // Agent remembers
126+
127+
provider.cleanup(); // Clean up when done
128+
```
129+
130+
Resume a previous session:
131+
132+
```typescript
133+
const provider = createACPProvider({
134+
command: "gemini",
135+
args: ["--experimental-acp"],
136+
session: { cwd: process.cwd(), mcpServers: [] },
137+
existingSessionId: "previous-session-id",
138+
persistSession: true,
139+
});
140+
```
141+
111142
## FAQ
112143

113144
### How to stream tool calls

packages/acp-ai-provider/deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mcpc/acp-ai-provider",
3-
"version": "0.1.21",
3+
"version": "0.1.22",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/mcpc-tech/mcpc.git"

packages/acp-ai-provider/src/language-model.ts

Lines changed: 75 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
type ContentBlock,
1313
type InitializeRequest,
1414
ndJsonStream,
15-
type NewSessionRequest,
1615
PROTOCOL_VERSION,
1716
type ReadTextFileRequest,
1817
type ReadTextFileResponse,
@@ -210,78 +209,58 @@ export class ACPLanguageModel implements LanguageModelV2 {
210209
}
211210

212211
/**
213-
* Converts AI SDK prompt messages into an array of ACP ContentBlock objects.
212+
* Converts AI SDK prompt messages into ACP ContentBlock objects.
213+
* When session exists, only extracts the last user message (history is in session).
214+
* Prefixes text with role since ACP ContentBlock has no role field.
214215
*/
215216
private getPromptContent(
216217
options: LanguageModelV2CallOptions,
217218
): ContentBlock[] {
218-
const contentBlocks: ContentBlock[] = [];
219+
// With persistent session, only send the latest user message
220+
const messages = this.sessionId
221+
? options.prompt.filter((m) => m.role === "user").slice(-1)
222+
: options.prompt;
219223

220-
for (const msg of options.prompt) {
221-
let prefix = "";
222-
// Note: ACP doesn't have a "system" role, so we prefix it.
223-
if (msg.role === "system") {
224-
prefix = "System: ";
225-
} else if (msg.role === "user") {
226-
prefix = "User: ";
227-
} else if (msg.role === "assistant") {
228-
prefix = "Assistant: ";
229-
}
224+
const contentBlocks: ContentBlock[] = [];
230225

231-
// Note: ACP doesn't have a "tool" role. Tool results are handled
232-
// by the agent itself, not by sending a message.
233-
if (
234-
msg.role === "system" ||
235-
msg.role === "user" ||
236-
msg.role === "assistant"
237-
) {
238-
if (Array.isArray(msg.content)) {
239-
for (const part of msg.content) {
240-
switch (part.type) {
241-
case "text": {
242-
contentBlocks.push({
243-
type: "text" as const,
244-
text: `${prefix}${part.text}`,
245-
});
246-
prefix = ""; // Only prefix the first part
247-
break;
248-
}
249-
case "file": {
250-
const type: ContentBlock["type"] | null = (() => {
251-
if (part.mediaType.startsWith("image/")) {
252-
return "image";
253-
}
254-
if (part.mediaType.startsWith("audio/")) {
255-
return "audio";
256-
}
257-
return null;
258-
})();
259-
260-
if (
261-
type === null ||
262-
// Ensure data is string before processing
263-
typeof part.data !== "string"
264-
) {
265-
break;
266-
}
267-
contentBlocks.push({
268-
type,
269-
mimeType: part.mediaType,
270-
data: extractBase64Data(part.data),
271-
});
272-
273-
break;
274-
}
226+
for (const msg of messages) {
227+
// Skip tool role - ACP handles tool results internally
228+
if (msg.role === "tool") continue;
229+
230+
// Prefix to identify role since ACP has no role field
231+
const prefix = msg.role === "system"
232+
? "System: "
233+
: msg.role === "assistant"
234+
? "Assistant: "
235+
: "";
236+
237+
if (Array.isArray(msg.content)) {
238+
let isFirst = true;
239+
for (const part of msg.content) {
240+
if (part.type === "text") {
241+
const text = isFirst ? `${prefix}${part.text}` : part.text;
242+
contentBlocks.push({ type: "text" as const, text });
243+
isFirst = false;
244+
} else if (part.type === "file" && typeof part.data === "string") {
245+
const type = part.mediaType.startsWith("image/")
246+
? "image"
247+
: part.mediaType.startsWith("audio/")
248+
? "audio"
249+
: null;
250+
if (type) {
251+
contentBlocks.push({
252+
type,
253+
mimeType: part.mediaType,
254+
data: extractBase64Data(part.data),
255+
});
275256
}
276-
277-
// Other parts (like images) are ignored in current implementation
278257
}
279-
} else if (typeof msg.content === "string") {
280-
contentBlocks.push({
281-
type: "text" as const,
282-
text: `${prefix}${msg.content}`,
283-
});
284258
}
259+
} else if (typeof msg.content === "string") {
260+
contentBlocks.push({
261+
type: "text" as const,
262+
text: `${prefix}${msg.content}`,
263+
});
285264
}
286265
}
287266

@@ -352,25 +331,46 @@ export class ACPLanguageModel implements LanguageModelV2 {
352331
);
353332
}
354333

355-
const sessionConfig: NewSessionRequest = {
356-
...this.config.session,
357-
cwd: this.config.session.cwd ?? sessionCwd,
358-
mcpServers: this.config.session.mcpServers ?? [],
359-
};
334+
if (this.config.existingSessionId) {
335+
await this.connection.loadSession({
336+
sessionId: this.config.existingSessionId,
337+
cwd: this.config.session?.cwd ?? sessionCwd,
338+
mcpServers: this.config.session?.mcpServers ?? [],
339+
});
340+
this.sessionId = this.config.existingSessionId;
341+
} else {
342+
const session = await this.connection.newSession({
343+
...this.config.session,
344+
cwd: this.config.session?.cwd ?? sessionCwd,
345+
mcpServers: this.config.session?.mcpServers ?? [],
346+
});
347+
this.sessionId = session.sessionId;
348+
}
349+
}
360350

361-
const session = await this.connection.newSession(sessionConfig);
351+
/**
352+
* Clears connection state. Skips if persistSession is enabled.
353+
*/
354+
private cleanup(): void {
355+
if (this.config.persistSession) return;
356+
this.forceCleanup();
357+
}
362358

363-
this.sessionId = session.sessionId;
359+
/**
360+
* Returns the current session ID.
361+
*/
362+
getSessionId(): string | null {
363+
return this.sessionId;
364364
}
365365

366366
/**
367-
* Kills the agent process and clears connection state.
367+
* Forces cleanup regardless of persistSession setting.
368368
*/
369-
private cleanup(): void {
369+
forceCleanup(): void {
370370
if (this.agentProcess) {
371371
this.agentProcess.kill();
372-
this.agentProcess!.stdin?.end();
373-
this.agentProcess!.stdout?.destroy();
372+
this.agentProcess.stdin?.end();
373+
this.agentProcess.stdout?.destroy();
374374
this.agentProcess = null;
375375
}
376376
this.connection = null;

packages/acp-ai-provider/src/provider.ts

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,41 +45,38 @@ export class ACPProvider {
4545
get tools(): Record<string, ReturnType<typeof tool>> | undefined {
4646
return this.model?.tools;
4747
}
48+
49+
/**
50+
* Returns the current session ID if one is active.
51+
* Useful when `persistSession` is enabled and you need to reference the session later.
52+
*/
53+
getSessionId(): string | null {
54+
return this.model?.getSessionId() ?? null;
55+
}
56+
57+
/**
58+
* Forces cleanup of the connection and session.
59+
* Call this when you're done with the provider instance, especially when using `persistSession`.
60+
*/
61+
cleanup(): void {
62+
this.model?.forceCleanup();
63+
}
4864
}
4965

5066
/**
5167
* Create an ACP provider instance
5268
*
5369
* @example
5470
* ```typescript
55-
* import { createACPProvider } from "@mcpc/acp-client-ai-provider";
56-
* import { generateText } from "ai";
57-
*
58-
* // See ACPProviderSettings in types.ts for all required fields
5971
* const provider = createACPProvider({
60-
* // Process configuration
61-
* command: "gemini", // Required: Command to execute the ACP agent
62-
* args: ["--experimental-acp"], // Optional: Arguments to pass to the command
63-
* env: {}, // Optional: Environment variables for the agent process
64-
*
65-
* // ACP protocol configuration
66-
* session: { // Required: Session configuration (NewSessionRequest)
67-
* cwd: process.cwd(),
68-
* mcpServers: [],
69-
* },
70-
* // initialize: { // Optional: Initialize configuration (InitializeRequest)
71-
* // protocolVersion: 1,
72-
* // clientCapabilities: {
73-
* // fs: { readTextFile: false, writeTextFile: false },
74-
* // terminal: false,
75-
* // },
76-
* // },
72+
* command: "gemini",
73+
* args: ["--experimental-acp"],
74+
* session: { cwd: process.cwd(), mcpServers: [] },
7775
* });
7876
*
79-
* // Use with AI SDK
8077
* const result = await generateText({
8178
* model: provider.languageModel(),
82-
* prompt: "Hello, world!"
79+
* prompt: "Hello!"
8380
* });
8481
* ```
8582
*/

packages/acp-ai-provider/src/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export interface ACPProviderSettings {
3333
env?: Record<string, string>;
3434

3535
/**
36-
* Session configuration (ACP protocol) - Required
36+
* Session configuration (ACP protocol) - Required when creating a new session
37+
* Can be partial when using existingSessionId (only cwd and mcpServers are used)
3738
*/
3839
session: ACPSessionConfig;
3940

@@ -48,4 +49,14 @@ export interface ACPProviderSettings {
4849
* Set to undefined to use the first available method, and you can see a warning of all available methods.
4950
*/
5051
authMethodId?: string;
52+
53+
/**
54+
* Load an existing session instead of creating a new one.
55+
*/
56+
existingSessionId?: string;
57+
58+
/**
59+
* Keep connection alive after each call for session reuse.
60+
*/
61+
persistSession?: boolean;
5162
}

packages/core/src/executors/workflow/workflow-executor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export class WorkflowExecutor {
2020
private predefinedSteps?: MCPCStep[],
2121
private ensureStepActions?: string[],
2222
private toolNameToIdMapping?: Map<string, string>,
23-
) {}
23+
) {
24+
}
2425

2526
// Helper method to validate required actions are present in workflow steps
2627
private validateRequiredActions(steps: MCPCStep[]): {

0 commit comments

Comments
 (0)