diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 205f54da704423a0ebed92626299802437f54a13..18041d9331aadf468e00852f0818aab3d1416900 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -79,8 +79,11 @@ const mcpRules = [ { action: "t3-code-thread_opencode2-adapter_*", resource: "*", effect: "allow" }, ]; const t3Rules = [{ action: "*", resource: "*", effect: "allow" }, ...mcpRules]; +/** The thread's title, which the recorded session already carries. */ +const TITLE = "Prime check"; const sessionInfo = (overrides: Record = {}) => ({ id: SESSION, + title: TITLE, permissions: t3Rules, projectID: "global", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, @@ -226,7 +229,7 @@ const turnInput = ( modelSelection: ModelSelection = bigPickle, runtimeMode: "full-access" | "approval-required" = "full-access", ) => ({ - appThread: {} as OrchestrationV2AppThread, + appThread: { title: TITLE } as OrchestrationV2AppThread, threadId, runId: RunId.make("run:opencode2-adapter"), runOrdinal: 1, @@ -433,6 +436,66 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); + it.effect("creates the session with the thread's title, so OpenCode generates none", () => + Effect.gen(function* () { + const runtime = yield* openCode2ReplayRuntimeWithInstructions([ + ...opening, + out("session.create", { + location: { directory: WORK }, + model: { providerID: "opencode", id: "big-pickle" }, + permissions: t3Rules, + title: "Check whether 391 is prime", + }), + replyData("session.create", sessionInfo({ title: "Check whether 391 is prime" })), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const thread = yield* runtime.ensureThread({ + threadId, + title: " Check whether 391 is prime ", + modelSelection: bigPickle, + runtimePolicy: policy(), + }); + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...turnInput(thread), + appThread: { title: "Check whether 391 is prime" } as OrchestrationV2AppThread, + }); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + }).pipe(Effect.scoped), + ); + + it.effect("renames the session before the first turn after the thread was renamed", () => + Effect.gen(function* () { + const { runtime, thread } = yield* resumed([ + out("session.update", { sessionID: SESSION, title: "Prime factors of 391" }), + reply("session.update", null), + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + // Renamed once: the next turn under the same title sends no update. + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]); + const renamed = { + ...turnInput(thread), + appThread: { title: "Prime factors of 391" } as OrchestrationV2AppThread, + }; + const first = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(renamed); + assert.equal((yield* Fiber.join(first))?.status, "completed"); + const second = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn({ + ...renamed, + runId: RunId.make("run:opencode2-adapter:2"), + attemptId: RunAttemptId.make("attempt:opencode2-adapter:2"), + }); + assert.equal((yield* Fiber.join(second))?.status, "completed"); + }).pipe(Effect.scoped), + ); + it.effect("ends a turn on the provider thread it started on", () => Effect.gen(function* () { const { runtime, thread } = yield* resumed([ @@ -631,6 +694,7 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { ...turnInput(thread), appThread: { id: threadId, + title: TITLE, lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, } as OrchestrationV2AppThread, }); diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts index 3b9fa218d052172847a9c38808de417e623abf30..3f873bfb6ea36c0951875f5223b3543e4db2545c 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.integration.test.ts @@ -151,9 +151,10 @@ const planRules = (name: string) => [ ...mcpRules(name), ]; -const sessionInfo = (directory: string, permissions: ReadonlyArray) => ({ +const sessionInfo = (directory: string, permissions: ReadonlyArray, title: string) => ({ data: { id: SESSION, + title, projectID: "global", model: { id: "big-pickle", providerID: "opencode", variant: "default" }, cost: 0, @@ -235,8 +236,9 @@ const createdSession = ( ], }), ...(narrows ? [out("agent.list", ""), reply("agent.list", agentList(directory))] : []), - out("session.create", { location: { directory }, model: "", permissions }), - reply("session.create", sessionInfo(directory, permissions)), + // The thread's title, which is its name here. + out("session.create", { location: { directory }, model: "", permissions, title: name }), + reply("session.create", sessionInfo(directory, permissions, name)), ]; /** A recording with its scrubbed `` directory replaced by `directory`. */ @@ -348,7 +350,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), // The next turn resumes the session at its new selection. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), + reply("session.get", sessionInfo(cwd, t3Rules(name), name)), out("session.switchModel", { sessionID: SESSION, model: { providerID: "opencode", id: "mimo-v2.6-flash-free" }, @@ -401,7 +403,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), ...directoryModels(after), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(before, t3Rules(name))), + reply("session.get", sessionInfo(before, t3Rules(name), name)), // The worktree change detached the thread, so its session is loaded afresh. ...noOpenRequests, out("session.move", { sessionID: SESSION, directory: after }), @@ -450,10 +452,14 @@ describe("OpenCode 2 through the orchestrator", () => { out("session.get", { sessionID: SESSION }), reply( "session.get", - sessionInfo(before, [ - { action: "*", resource: "*", effect: "allow" }, - { action: "subagent", resource: "*", effect: "deny" }, - ]), + sessionInfo( + before, + [ + { action: "*", resource: "*", effect: "allow" }, + { action: "subagent", resource: "*", effect: "deny" }, + ], + name, + ), ), ...noOpenRequests, out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), @@ -531,7 +537,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("FIRST"), // A mode change detaches nothing: the same session is resumed with the new rules. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, t3Rules(name))), + reply("session.get", sessionInfo(cwd, t3Rules(name), name)), out("agent.list", ""), reply("agent.list", agentList(cwd)), out("session.update", { sessionID: SESSION, permissions: autoEditRules(name) }), @@ -539,7 +545,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...answeredPrompt("SECOND"), // Back to Full access: the narrowing rules go. out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, autoEditRules(name))), + reply("session.get", sessionInfo(cwd, autoEditRules(name), name)), out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), ...answeredPrompt("THIRD"), @@ -577,7 +583,7 @@ describe("OpenCode 2 through the orchestrator", () => { ...instructionsWritten, ...answeredPrompt("PLANNED"), out("session.get", { sessionID: SESSION }), - reply("session.get", sessionInfo(cwd, planRules(name))), + reply("session.get", sessionInfo(cwd, planRules(name), name)), out("session.update", { sessionID: SESSION, permissions: t3Rules(name) }), reply("session.update", null), out("session.switchAgent", { sessionID: SESSION, agent: "build" }), diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index e2c905b1e17a970053449f497c3ea58abe84433f..684bde2c066952ebc68edb040613159344f9403c 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -651,6 +651,7 @@ export const layer: Layer.Layer< return yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, @@ -696,6 +697,7 @@ export const layer: Layer.Layer< const replacement = yield* loadFromProvider( session.ensureThread({ threadId: projection.thread.id, + title: projection.thread.title, modelSelection: run.modelSelection, runtimePolicy: resolvedRuntimePolicy, providerSessionId, diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson index a327f5ffc9ed92868ab03fd59d30916c3618256e..57cbd14853460f6cd819bb47609ccff3c90bc3d0 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_fork/opencode_transcript.ndjson @@ -50,6 +50,8 @@ {"type": "expect_outbound", "label": "session.update", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "permissions": [{"action": "*", "resource": "*", "effect": "allow"}, {"action": "t3-code-*", "resource": "*", "effect": "deny"}, {"action": "t3-code-thread_opencode2-fork_target_*", "resource": "*", "effect": "allow"}]}}} {"type": "emit_inbound", "label": "session.update.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "emit_inbound", "label": "session.forked", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7b247d001fXzFroPg5P0bps", "created": 1790657111165, "type": "session.forked", "location": {"directory": ""}, "data": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "parentID": "ses_f1484ea8affe5aTo8CcgbGIszj", "boundary": {"type": "before", "messageID": "msg_0eb7b1f9f001g95Bduh3bi8syS"}, "instructions": {"core/environment": "db4a793a05fec32fdfa985a3432125a86d6cb42debc8b9ee8f6a99fbfcf2f93b", "core/date": "bf08b8486572ab3eef677a3c9ee12468e411ae41a8f4d2fb6cf02a6ca73f1986", "core/codemode": "616a7ad8b8e40e8a71eab238dfbbe9eaf0f4a9e6fdad853226d075bfe602e1e6", "core/skill-guidance": "0d001919496102654717308c72635523bf7c3cd99c81e058cc79f2947104f9a5"}, "instructionEntries": []}, "durable": {"aggregateID": "ses_f1484db83ffeLGtrRCFimo1H0e", "seq": 0, "version": 2}}}} +{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "title": ""}}} +{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.instructions.entry.put.2", "frame": {"type": "session.instructions.entry.put", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "key": "t3-code", "value": ""}}} {"type": "emit_inbound", "label": "session.instructions.entry.put.response.2", "frame": {"type": "sdk.response", "operation": "session.instructions.entry.put", "data": null}} {"type": "expect_outbound", "label": "session.prompt.3", "frame": {"type": "session.prompt", "input": {"sessionID": "ses_f1484db83ffeLGtrRCFimo1H0e", "id": "msg_0eb7b2493001nZTYCniIjU5Y65", "text": ""}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson index 11b79b6d31f1d9e23f0925d8391cc0258bdd1890..ee2519419915aa88a22de4f1b5088d272fc45838 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/opencode2_switch/opencode_transcript.ndjson @@ -59,6 +59,8 @@ {"type": "emit_inbound", "label": "session.switchAgent.response.2", "frame": {"type": "sdk.response", "operation": "session.switchAgent", "data": null}} {"type": "emit_inbound", "label": "session.agent.selected.2", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef80001lOHUbFIwlg7iap", "created": 1790657359744, "type": "session.agent.selected", "location": {"directory": ""}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "agent": "build", "previous": "plan"}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 16, "version": 1}}}} {"type": "emit_inbound", "label": "session.inbox.enqueued.3", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef830020JKd2UmFNPZ8vu", "created": 1790657359747, "type": "session.inbox.enqueued", "location": {"directory": ""}, "data": {"inboxID": "msg_0eb7eef83001M1o8rpSkbKzlf5", "sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "item": {"type": "synthetic", "payload": {"text": "\nYou are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. Any Plan mode instructions from earlier in this conversation are no longer active.\n"}, "delivery": "steer"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 17, "version": 1}}}} +{"type": "expect_outbound", "label": "session.update.title", "frame": {"type": "session.update", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "title": ""}}} +{"type": "emit_inbound", "label": "session.update.title.response", "frame": {"type": "sdk.response", "operation": "session.update", "data": null}} {"type": "expect_outbound", "label": "session.switchModel", "frame": {"type": "session.switchModel", "input": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"providerID": "opencode", "id": "nemotron-3.5-lightning-free"}}}} {"type": "emit_inbound", "label": "session.switchModel.response", "frame": {"type": "sdk.response", "operation": "session.switchModel", "data": null}} {"type": "emit_inbound", "label": "session.model.selected", "frame": {"type": "sdk.event", "event": {"id": "evt_0eb7eef8d001Yy5fmSDr6nQ8OS", "created": 1790657359757, "type": "session.model.selected", "location": {"directory": ""}, "data": {"sessionID": "ses_f14812197ffeCbw641wM2r3ZmP", "model": {"id": "nemotron-3.5-lightning-free", "providerID": "opencode"}, "previous": {"id": "big-pickle", "providerID": "opencode", "variant": "default"}}, "durable": {"aggregateID": "ses_f14812197ffeCbw641wM2r3ZmP", "seq": 18, "version": 1}}}} diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 086300e21878eac4471d4b18f435aae853b587d7..ac5cd5fe8ab32d5e9b728ed7fe6a5a6c8e6f53b3 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -394,6 +394,8 @@ export interface ProviderAdapterV2OpenSessionInput { export interface ProviderAdapterV2EnsureThreadInput { readonly threadId: ThreadId; + /** The app thread's title, for providers that name their native sessions. */ + readonly title?: string; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly providerSessionId?: ProviderSessionId; diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 306836614d550492932adf9236398e16a1effda7..86311cdafdd04c66587663089ae3293dafbf1cfc 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -345,6 +345,8 @@ interface ThreadState { * rules stay in force, and a changed mode switches it before prompting. */ agent: string; + /** The native session's title as T3 last read or wrote it. */ + title: string | undefined; /** The native session's rules as T3 last read or wrote them, and the policy they are for. */ rules: ReadonlyArray | undefined; policy: RulesPolicy; @@ -934,6 +936,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr unsettled: false, directory, agent: "build", + title: undefined, rules: undefined, policy: input.runtimePolicy, grants: [], @@ -3012,12 +3015,22 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr state.policy = policy; }); + /** Names the session after its thread when they differ. */ + const writeTitle = Effect.fnUntraced(function* (state: ThreadState, title: string) { + const next = title.trim(); + // An empty title asks OpenCode to generate one. + if (next === "" || next === state.title) return; + yield* client.session.update({ sessionID: Session.ID.make(state.sessionId), title: next }); + state.title = next; + }); + const register = ( providerThread: OrchestrationV2ProviderThread, native: { readonly id: string; readonly model?: ModelRef | undefined; readonly agent?: string | undefined; + readonly title?: string | undefined; readonly permissions?: ReadonlyArray | undefined; }, directory: string, @@ -3029,12 +3042,14 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr existing.model = native.model; existing.directory = directory; existing.agent = native.agent ?? existing.agent; + existing.title = native.title; existing.rules = native.permissions; return existing; } const state = newThreadState(native.id, providerThread, directory, undefined); state.model = native.model; state.agent = native.agent ?? state.agent; + state.title = native.title; state.rules = native.permissions; threads.set(native.id, state); return state; @@ -3615,10 +3630,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr policy, threadInput.threadId, ); + const title = threadInput.title?.trim() || undefined; + // A session created with a title is never titled by OpenCode's own model. const created = yield* client.session.create({ location: Location.PublicRef.make({ directory: AbsolutePath.make(directory) }), model, permissions, + ...(title === undefined ? {} : { title }), }); const createdAt = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { @@ -3642,7 +3660,13 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr }; const state = register( providerThread, - { id: created.id, model: created.model, agent: created.agent, permissions }, + { + id: created.id, + model: created.model, + agent: created.agent, + title: title ?? created.title, + permissions, + }, directory, ); state.policy = policy; @@ -3801,6 +3825,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr // and its subagents still running hold the rules they started with. // Those run on whether or not this turn starts, so theirs are best effort. yield* writeRules(state, turnInput.runtimePolicy); + // A thread renamed since the last turn, or titled after its first prompt. + yield* writeTitle(state, turnInput.appThread.title).pipe( + Effect.timeout(REQUEST_REPLY_TIMEOUT), + Effect.ignore({ log: true }), + ); for (const call of runningCalls(state)) { if (call.child === undefined) continue; yield* writeRules(call.child, turnInput.runtimePolicy).pipe(