From bd768a469b68bfd89c5d45e9b16c4ab0bdbe7e8c Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:47:17 +0900 Subject: [PATCH] feat(hooks): add team-mode status injector hook with tests --- .../team-mode-status-injector/hook.test.ts | 97 ++++++++++++++ src/hooks/team-mode-status-injector/hook.ts | 126 ++++++++++++++++++ src/hooks/team-mode-status-injector/index.ts | 1 + 3 files changed, 224 insertions(+) create mode 100644 src/hooks/team-mode-status-injector/hook.test.ts create mode 100644 src/hooks/team-mode-status-injector/hook.ts create mode 100644 src/hooks/team-mode-status-injector/index.ts diff --git a/src/hooks/team-mode-status-injector/hook.test.ts b/src/hooks/team-mode-status-injector/hook.test.ts new file mode 100644 index 000000000..41ac3341f --- /dev/null +++ b/src/hooks/team-mode-status-injector/hook.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test" + +import { TeamModeConfigSchema } from "../../config/schema/team-mode" +import { createTeamModeStatusInjector } from "./hook" + +function createOutput(sessionID: string): { + messages: Array<{ + info: { role: string; sessionID: string } + parts: Array<{ type: string; text?: string; synthetic?: boolean }> + }> +} { + return { + messages: [ + { + info: { + role: "user", + sessionID, + }, + parts: [{ type: "text", text: "original message" }], + }, + ], + } +} + +describe("createTeamModeStatusInjector", () => { + it("injects a one-time team mode enabled message before the latest user message", async () => { + // given + const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true })) + const output = createOutput("session-team-mode") + + // when + await hook["experimental.chat.messages.transform"]?.( + { sessionID: "session-team-mode" }, + output, + ) + + // then + expect(output.messages).toHaveLength(2) + expect(output.messages[0]).toEqual({ + info: { + role: "user", + sessionID: "session-team-mode", + }, + parts: [ + { + type: "text", + text: expect.stringContaining("Team mode is ENABLED for this session."), + synthetic: true, + }, + ], + }) + expect(output.messages[1]?.parts[0]?.text).toBe("original message") + }) + + it("does not inject again when the team mode status was already added", async () => { + // given + const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: true })) + const firstOutput = createOutput("session-team-mode") + const secondOutput = createOutput("session-team-mode") + + // when + await hook["experimental.chat.messages.transform"]?.( + { sessionID: "session-team-mode" }, + firstOutput, + ) + secondOutput.messages = structuredClone(firstOutput.messages) + await hook["experimental.chat.messages.transform"]?.( + { sessionID: "session-team-mode" }, + secondOutput, + ) + + // then + expect(firstOutput.messages).toHaveLength(2) + expect(secondOutput.messages).toHaveLength(2) + expect( + secondOutput.messages.filter((message) => + message.parts.some((part) => part.text?.includes("")), + ), + ).toHaveLength(1) + }) + + it("does nothing when team mode is disabled", async () => { + // given + const hook = createTeamModeStatusInjector(TeamModeConfigSchema.parse({ enabled: false })) + const output = createOutput("session-team-mode") + + // when + await hook["experimental.chat.messages.transform"]?.( + { sessionID: "session-team-mode" }, + output, + ) + + // then + expect(output.messages).toHaveLength(1) + expect(output.messages[0]?.parts[0]?.text).toBe("original message") + }) +}) diff --git a/src/hooks/team-mode-status-injector/hook.ts b/src/hooks/team-mode-status-injector/hook.ts new file mode 100644 index 000000000..57877ddd9 --- /dev/null +++ b/src/hooks/team-mode-status-injector/hook.ts @@ -0,0 +1,126 @@ +import type { TeamModeConfig } from "../../config/schema/team-mode" + +type TransformPart = { + type: string + text?: string + synthetic?: boolean + [key: string]: unknown +} + +type TransformMessageInfo = { + role: string + sessionID?: string + [key: string]: unknown +} + +type MessageWithParts = { + info: TransformMessageInfo + parts: TransformPart[] +} + +type TeamModeStatusInjectorInput = { + sessionID?: string + [key: string]: unknown +} + +type TeamModeStatusInjectorOutput = { + messages: MessageWithParts[] +} + +export type TeamModeStatusInjectorHook = { + "experimental.chat.messages.transform"?: ( + input: TeamModeStatusInjectorInput, + output: TeamModeStatusInjectorOutput, + ) => Promise +} + +const TEAM_MODE_STATUS_MARKER = "" + +function resolveSessionID( + input: TeamModeStatusInjectorInput, + messages: MessageWithParts[], +): string | undefined { + if (typeof input.sessionID === "string" && input.sessionID.length > 0) { + return input.sessionID + } + + for (let index = messages.length - 1; index >= 0; index -= 1) { + const sessionID = messages[index]?.info.sessionID + if (typeof sessionID === "string" && sessionID.length > 0) { + return sessionID + } + } + + return undefined +} + +function findLastUserMessageIndex(messages: MessageWithParts[]): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.info.role === "user") { + return index + } + } + + return -1 +} + +function hasInjectedTeamModeStatus(messages: MessageWithParts[]): boolean { + return messages.some((message) => + message.parts.some( + (part) => part.synthetic === true && part.type === "text" && part.text?.includes(TEAM_MODE_STATUS_MARKER), + ), + ) +} + +function buildTeamModeStatusContent(): string { + return `${TEAM_MODE_STATUS_MARKER} +Team mode is ENABLED for this session. +If the team_* tools are present, that is authoritative proof that team mode is active. +Do not inspect ~/.config/opencode or project config files to verify team mode. +If you need usage guidance, load the team-mode skill. Otherwise use the team_* tools directly. +` +} + +function createInjectedMessage(sessionID: string): MessageWithParts { + return { + info: { + role: "user", + sessionID, + }, + parts: [{ type: "text", text: buildTeamModeStatusContent(), synthetic: true }], + } +} + +export function createTeamModeStatusInjector( + config: TeamModeConfig, +): TeamModeStatusInjectorHook { + return { + "experimental.chat.messages.transform": async ( + input, + output, + ): Promise => { + if (!config.enabled || output.messages.length === 0) { + return + } + + if (hasInjectedTeamModeStatus(output.messages)) { + return + } + + const sessionID = resolveSessionID(input, output.messages) + if (sessionID === undefined) { + return + } + + const lastUserMessageIndex = findLastUserMessageIndex(output.messages) + const injectedMessage = createInjectedMessage(sessionID) + + if (lastUserMessageIndex === -1) { + output.messages.unshift(injectedMessage) + return + } + + output.messages.splice(lastUserMessageIndex, 0, injectedMessage) + }, + } +} diff --git a/src/hooks/team-mode-status-injector/index.ts b/src/hooks/team-mode-status-injector/index.ts new file mode 100644 index 000000000..599d71545 --- /dev/null +++ b/src/hooks/team-mode-status-injector/index.ts @@ -0,0 +1 @@ +export { createTeamModeStatusInjector } from "./hook"