authorgravatar for tris203@gmail.comTristan Knight <tris203@gmail.com> 2026-07-20 09:43:07+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 08:43:07+00:00
log3235658c080bc12fcd1ffaa275aced98d225f2f6
tree79c1727ebfda14a9d6fa7afaf4936c0b53f0233d
parent749baec353d5675f1acac81f22aa15da573e71c7
signature Signed by PGP key B5690EEEBB952194

fix: delegate OpenCode session titles to provider (#3720)


2 files changed, 95 insertions(+), 4 deletions(-)

apps/server/src/provider/Layers/OpenCodeAdapter.test.ts+45-1
...@@ -54,6 +54,7 @@ const runtimeMock = {...@@ -54,6 +54,7 @@ const runtimeMock = {
54 state: {54 state: {
55 startCalls: [] as string[],55 startCalls: [] as string[],
56 sessionCreateUrls: [] as string[],56 sessionCreateUrls: [] as string[],
57 sessionCreateInputs: [] as Array<Record<string, unknown>>,
57 authHeaders: [] as Array<string | null>,58 authHeaders: [] as Array<string | null>,
58 abortCalls: [] as string[],59 abortCalls: [] as string[],
59 closeCalls: [] as string[],60 closeCalls: [] as string[],
...@@ -67,6 +68,7 @@ const runtimeMock = {...@@ -67,6 +68,7 @@ const runtimeMock = {
67 reset() {68 reset() {
68 this.state.startCalls.length = 0;69 this.state.startCalls.length = 0;
69 this.state.sessionCreateUrls.length = 0;70 this.state.sessionCreateUrls.length = 0;
71 this.state.sessionCreateInputs.length = 0;
70 this.state.authHeaders.length = 0;72 this.state.authHeaders.length = 0;
71 this.state.abortCalls.length = 0;73 this.state.abortCalls.length = 0;
72 this.state.closeCalls.length = 0;74 this.state.closeCalls.length = 0;
...@@ -122,8 +124,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {...@@ -122,8 +124,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
122 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>124 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
123 ({125 ({
124 session: {126 session: {
125 create: async () => {127 create: async (input: Record<string, unknown>) => {
126 runtimeMock.state.sessionCreateUrls.push(baseUrl);128 runtimeMock.state.sessionCreateUrls.push(baseUrl);
129 runtimeMock.state.sessionCreateInputs.push(input);
127 runtimeMock.state.authHeaders.push(130 runtimeMock.state.authHeaders.push(
128 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,131 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
129 );132 );
...@@ -765,6 +768,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {...@@ -765,6 +768,47 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
765 }),768 }),
766 );769 );
767770
771 it.effect("lets OpenCode own session title generation and emits title metadata updates", () =>
772 Effect.gen(function* () {
773 const adapter = yield* OpenCodeAdapter;
774 const threadId = asThreadId("thread-opencode-title-sync");
775 runtimeMock.state.subscribedEvents = [
776 {
777 type: "session.updated",
778 properties: {
779 info: {
780 id: "http://127.0.0.1:9999/session",
781 title: "Investigate OpenCode title sync",
782 },
783 },
784 },
785 ];
786
787 const eventsFiber = yield* adapter.streamEvents.pipe(
788 Stream.filter((event) => event.threadId === threadId),
789 Stream.take(3),
790 Stream.runCollect,
791 Effect.forkChild,
792 );
793
794 yield* adapter.startSession({
795 provider: ProviderDriverKind.make("opencode"),
796 threadId,
797 runtimeMode: "full-access",
798 });
799
800 const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second")));
801 NodeAssert.equal(runtimeMock.state.sessionCreateInputs.length, 1);
802 NodeAssert.equal("title" in (runtimeMock.state.sessionCreateInputs[0] ?? {}), false);
803
804 const metadataUpdated = events.find((event) => event.type === "thread.metadata.updated");
805 NodeAssert.ok(metadataUpdated);
806 if (metadataUpdated.type === "thread.metadata.updated") {
807 NodeAssert.equal(metadataUpdated.payload.name, "Investigate OpenCode title sync");
808 }
809 }),
810 );
811
768 it.effect("writes provider-native observability records using the session thread id", () =>812 it.effect("writes provider-native observability records using the session thread id", () =>
769 Effect.gen(function* () {813 Effect.gen(function* () {
770 const nativeEvents: Array<{814 const nativeEvents: Array<{
apps/server/src/provider/Layers/OpenCodeAdapter.ts+50-3
...@@ -65,6 +65,35 @@ type OpenCodeSubscribedEvent =...@@ -65,6 +65,35 @@ type OpenCodeSubscribedEvent =
65 ? TEvent65 ? TEvent
66 : never;66 : never;
6767
68function trimText(value: string | undefined | null): string | undefined {
69 const trimmed = value?.trim();
70 return trimmed && trimmed.length > 0 ? trimmed : undefined;
71}
72
73function openCodeEventSessionId(event: OpenCodeSubscribedEvent): string | undefined {
74 const properties = "properties" in event ? event.properties : undefined;
75 if (!properties || typeof properties !== "object") {
76 return undefined;
77 }
78
79 const sessionID = (properties as { readonly sessionID?: unknown }).sessionID;
80 const sessionIDFromProperties = typeof sessionID === "string" ? sessionID : undefined;
81 if (sessionIDFromProperties) {
82 return sessionIDFromProperties;
83 }
84
85 const info = (properties as { readonly info?: { readonly id?: unknown } }).info;
86 return info && typeof info.id === "string" ? info.id : undefined;
87}
88
89function openCodeEventSessionTitle(event: OpenCodeSubscribedEvent): string | undefined {
90 if (event.type !== "session.updated") {
91 return undefined;
92 }
93
94 return trimText(event.properties.info.title);
95}
96
68interface OpenCodeSessionContext {97interface OpenCodeSessionContext {
69 session: ProviderSession;98 session: ProviderSession;
70 readonly client: OpencodeClient;99 readonly client: OpencodeClient;
...@@ -643,8 +672,7 @@ export function makeOpenCodeAdapter(...@@ -643,8 +672,7 @@ export function makeOpenCodeAdapter(
643 context: OpenCodeSessionContext,672 context: OpenCodeSessionContext,
644 event: OpenCodeSubscribedEvent,673 event: OpenCodeSubscribedEvent,
645 ) {674 ) {
646 const payloadSessionId =675 const payloadSessionId = openCodeEventSessionId(event);
647 "properties" in event ? (event.properties as { sessionID?: unknown }).sessionID : undefined;
648 if (payloadSessionId !== context.openCodeSessionId) {676 if (payloadSessionId !== context.openCodeSessionId) {
649 return;677 return;
650 }678 }
...@@ -663,6 +691,26 @@ export function makeOpenCodeAdapter(...@@ -663,6 +691,26 @@ export function makeOpenCodeAdapter(
663 });691 });
664692
665 switch (event.type) {693 switch (event.type) {
694 case "session.updated": {
695 const title = openCodeEventSessionTitle(event);
696 if (title) {
697 yield* emit({
698 ...(yield* buildEventBase({
699 threadId: context.session.threadId,
700 raw: event,
701 })),
702 type: "thread.metadata.updated",
703 payload: {
704 name: title,
705 metadata: {
706 sessionID: context.openCodeSessionId,
707 },
708 },
709 });
710 }
711 break;
712 }
713
666 case "message.updated": {714 case "message.updated": {
667 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);715 context.messageRoleById.set(event.properties.info.id, event.properties.info.role);
668 if (event.properties.info.role === "assistant") {716 if (event.properties.info.role === "assistant") {
...@@ -1069,7 +1117,6 @@ export function makeOpenCodeAdapter(...@@ -1069,7 +1117,6 @@ export function makeOpenCodeAdapter(
1069 }1117 }
1070 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>1118 const openCodeSession = yield* runOpenCodeSdk("session.create", () =>
1071 client.session.create({1119 client.session.create({
1072 title: `T3 Code ${input.threadId}`,
1073 permission: buildOpenCodePermissionRules(input.runtimeMode),1120 permission: buildOpenCodePermissionRules(input.runtimeMode),
1074 }),1121 }),
1075 );1122 );