Skip to content

Commit a9f2737

Browse files
authored
acp: replay conversation history in session/load (anomalyco#5385)
1 parent 9c126c5 commit a9f2737

2 files changed

Lines changed: 269 additions & 2 deletions

File tree

packages/opencode/src/acp/agent.ts

Lines changed: 238 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { Config } from "@/config/config"
2828
import { Todo } from "@/session/todo"
2929
import { z } from "zod"
3030
import { LoadAPIKeyError } from "ai"
31-
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
31+
import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
3232

3333
export namespace ACP {
3434
const log = Log.create({ service: "acp-agent" })
@@ -386,7 +386,7 @@ export namespace ACP {
386386

387387
log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
388388

389-
const load = await this.loadSession({
389+
const load = await this.loadSessionMode({
390390
cwd: directory,
391391
mcpServers: params.mcpServers,
392392
sessionId,
@@ -412,6 +412,242 @@ export namespace ACP {
412412
}
413413

414414
async loadSession(params: LoadSessionRequest) {
415+
const directory = params.cwd
416+
const sessionId = params.sessionId
417+
418+
try {
419+
const model = await defaultModel(this.config, directory)
420+
421+
// Store ACP session state
422+
const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
423+
424+
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
425+
426+
const mode = await this.loadSessionMode({
427+
cwd: directory,
428+
mcpServers: params.mcpServers,
429+
sessionId,
430+
})
431+
432+
this.setupEventSubscriptions(state)
433+
434+
// Replay session history
435+
const messages = await this.sdk.session
436+
.messages(
437+
{
438+
sessionID: sessionId,
439+
directory,
440+
},
441+
{ throwOnError: true },
442+
)
443+
.then((x) => x.data)
444+
.catch((err) => {
445+
log.error("unexpected error when fetching message", { error: err })
446+
return undefined
447+
})
448+
449+
for (const msg of messages ?? []) {
450+
log.debug("replay message", msg)
451+
await this.processMessage(msg)
452+
}
453+
454+
return mode
455+
} catch (e) {
456+
const error = MessageV2.fromError(e, {
457+
providerID: this.config.defaultModel?.providerID ?? "unknown",
458+
})
459+
if (LoadAPIKeyError.isInstance(error)) {
460+
throw RequestError.authRequired()
461+
}
462+
throw e
463+
}
464+
}
465+
466+
private async processMessage(message: SessionMessageResponse) {
467+
log.debug("process message", message)
468+
if (message.info.role !== "assistant" && message.info.role !== "user") return
469+
const sessionId = message.info.sessionID
470+
471+
for (const part of message.parts) {
472+
if (part.type === "tool") {
473+
switch (part.state.status) {
474+
case "pending":
475+
await this.connection
476+
.sessionUpdate({
477+
sessionId,
478+
update: {
479+
sessionUpdate: "tool_call",
480+
toolCallId: part.callID,
481+
title: part.tool,
482+
kind: toToolKind(part.tool),
483+
status: "pending",
484+
locations: [],
485+
rawInput: {},
486+
},
487+
})
488+
.catch((err) => {
489+
log.error("failed to send tool pending to ACP", { error: err })
490+
})
491+
break
492+
case "running":
493+
await this.connection
494+
.sessionUpdate({
495+
sessionId,
496+
update: {
497+
sessionUpdate: "tool_call_update",
498+
toolCallId: part.callID,
499+
status: "in_progress",
500+
locations: toLocations(part.tool, part.state.input),
501+
rawInput: part.state.input,
502+
},
503+
})
504+
.catch((err) => {
505+
log.error("failed to send tool in_progress to ACP", { error: err })
506+
})
507+
break
508+
case "completed":
509+
const kind = toToolKind(part.tool)
510+
const content: ToolCallContent[] = [
511+
{
512+
type: "content",
513+
content: {
514+
type: "text",
515+
text: part.state.output,
516+
},
517+
},
518+
]
519+
520+
if (kind === "edit") {
521+
const input = part.state.input
522+
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
523+
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
524+
const newText =
525+
typeof input["newString"] === "string"
526+
? input["newString"]
527+
: typeof input["content"] === "string"
528+
? input["content"]
529+
: ""
530+
content.push({
531+
type: "diff",
532+
path: filePath,
533+
oldText,
534+
newText,
535+
})
536+
}
537+
538+
if (part.tool === "todowrite") {
539+
const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
540+
if (parsedTodos.success) {
541+
await this.connection
542+
.sessionUpdate({
543+
sessionId,
544+
update: {
545+
sessionUpdate: "plan",
546+
entries: parsedTodos.data.map((todo) => {
547+
const status: PlanEntry["status"] =
548+
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
549+
return {
550+
priority: "medium",
551+
status,
552+
content: todo.content,
553+
}
554+
}),
555+
},
556+
})
557+
.catch((err) => {
558+
log.error("failed to send session update for todo", { error: err })
559+
})
560+
} else {
561+
log.error("failed to parse todo output", { error: parsedTodos.error })
562+
}
563+
}
564+
565+
await this.connection
566+
.sessionUpdate({
567+
sessionId,
568+
update: {
569+
sessionUpdate: "tool_call_update",
570+
toolCallId: part.callID,
571+
status: "completed",
572+
kind,
573+
content,
574+
title: part.state.title,
575+
rawOutput: {
576+
output: part.state.output,
577+
metadata: part.state.metadata,
578+
},
579+
},
580+
})
581+
.catch((err) => {
582+
log.error("failed to send tool completed to ACP", { error: err })
583+
})
584+
break
585+
case "error":
586+
await this.connection
587+
.sessionUpdate({
588+
sessionId,
589+
update: {
590+
sessionUpdate: "tool_call_update",
591+
toolCallId: part.callID,
592+
status: "failed",
593+
content: [
594+
{
595+
type: "content",
596+
content: {
597+
type: "text",
598+
text: part.state.error,
599+
},
600+
},
601+
],
602+
rawOutput: {
603+
error: part.state.error,
604+
},
605+
},
606+
})
607+
.catch((err) => {
608+
log.error("failed to send tool error to ACP", { error: err })
609+
})
610+
break
611+
}
612+
} else if (part.type === "text") {
613+
if (part.text) {
614+
await this.connection
615+
.sessionUpdate({
616+
sessionId,
617+
update: {
618+
sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
619+
content: {
620+
type: "text",
621+
text: part.text,
622+
},
623+
},
624+
})
625+
.catch((err) => {
626+
log.error("failed to send text to ACP", { error: err })
627+
})
628+
}
629+
} else if (part.type === "reasoning") {
630+
if (part.text) {
631+
await this.connection
632+
.sessionUpdate({
633+
sessionId,
634+
update: {
635+
sessionUpdate: "agent_thought_chunk",
636+
content: {
637+
type: "text",
638+
text: part.text,
639+
},
640+
},
641+
})
642+
.catch((err) => {
643+
log.error("failed to send reasoning to ACP", { error: err })
644+
})
645+
}
646+
}
647+
}
648+
}
649+
650+
private async loadSessionMode(params: LoadSessionRequest) {
415651
const directory = params.cwd
416652
const model = await defaultModel(this.config, directory)
417653
const sessionId = params.sessionId

packages/opencode/src/acp/session.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,37 @@ export class ACPSessionManager {
4040
return state
4141
}
4242

43+
async load(
44+
sessionId: string,
45+
cwd: string,
46+
mcpServers: McpServer[],
47+
model?: ACPSessionState["model"],
48+
): Promise<ACPSessionState> {
49+
const session = await this.sdk.session
50+
.get(
51+
{
52+
sessionID: sessionId,
53+
directory: cwd,
54+
},
55+
{ throwOnError: true },
56+
)
57+
.then((x) => x.data!)
58+
59+
const resolvedModel = model
60+
61+
const state: ACPSessionState = {
62+
id: sessionId,
63+
cwd,
64+
mcpServers,
65+
createdAt: new Date(session.time.created),
66+
model: resolvedModel,
67+
}
68+
log.info("loading_session", { state })
69+
70+
this.sessions.set(sessionId, state)
71+
return state
72+
}
73+
4374
get(sessionId: string): ACPSessionState {
4475
const session = this.sessions.get(sessionId)
4576
if (!session) {

0 commit comments

Comments
 (0)