feat(keyword-detector): add team mode keyword detection

Detects user invocations of team-mode work across English and Korean
('team mode', '팀 모드', '팀으로') and injects a concise English directive
instructing the LLM to orchestrate via team_* tools (team_create ->
team_task_create + team_send_message), forbidding delegate_task
substitution and fallbacks.

The Korean variants use a Hangul-syllable negative lookbehind (가-힣) so
that '스팀으로 게임 켜줘' does not falsely match '팀으로' and '스팀모드' does
not falsely match '팀모드'.

Follows the existing folder pattern (mode/default.ts + mode/index.ts)
shared by ultrawork/, search/, and analyze/. The hook orchestration in
hook.ts handles the new keyword type generically through the shared
KEYWORD_DETECTORS array, so existing guards (non-OMO agent skip,
non-main session filter, system-reminder strip, code-block strip) all
apply automatically.

Adds 7 regression tests covering English/Korean trigger forms, the
Hangul-prefix false-positive guard, the bare-'team' negative case, and
non-main-session filtering.
This commit is contained in:
YeonGyu-Kim
2026-04-28 14:19:44 +09:00
parent 2ffe5afe2e
commit 25d9437513
6 changed files with 214 additions and 6 deletions
+7 -4
View File
@@ -4,7 +4,7 @@
## OVERVIEW
8 files + 3 mode subdirs (~1665 LOC). Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze) and injects mode-specific system prompts.
Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze, team) and injects mode-specific system prompts.
## KEYWORDS
@@ -13,6 +13,7 @@
| `ultrawork` / `ulw` | `/\b(ultrawork|ulw)\b/i` | Full orchestration mode — parallel agents, deep exploration, relentless execution |
| Search mode | `SEARCH_PATTERN` (from `search/`) | Web/doc search focus prompt injection |
| Analyze mode | `ANALYZE_PATTERN` (from `analyze/`) | Deep analysis mode prompt injection |
| Team mode | `TEAM_PATTERN` (from `team/`) | Forces orchestration via `team_*` tools when user invokes `team mode` / `팀 모드` / `팀으로`; instructs user to enable `team_mode.enabled` if tools are absent |
## STRUCTURE
@@ -31,10 +32,12 @@ keyword-detector/
│ ├── index.ts
│ ├── pattern.ts # SEARCH_PATTERN regex
│ └── message.ts # SEARCH_MESSAGE
── analyze/
── analyze/
│ ├── index.ts
│ └── default.ts # ANALYZE_PATTERN + ANALYZE_MESSAGE
└── team/
├── index.ts
── pattern.ts # ANALYZE_PATTERN regex
└── message.ts # ANALYZE_MESSAGE
── default.ts # TEAM_PATTERN + TEAM_MESSAGE
```
## DETECTION LOGIC
+6
View File
@@ -4,9 +4,11 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
export { isPlannerAgent, isNonOmoAgent, getUltraworkMessage } from "./ultrawork"
export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze"
export { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
import { getUltraworkMessage } from "./ultrawork"
import { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search"
import { TEAM_PATTERN, TEAM_MESSAGE } from "./team"
export type KeywordDetector = {
pattern: RegExp
@@ -41,4 +43,8 @@ SYNTHESIZE findings before proceeding.
MANDATORY delegate_task params: ALWAYS include load_skills and run_in_background when calling delegate_task. Evaluate available skills before dispatch - pass task-appropriate skills when relevant, pass [] ONLY when no skill matches the task domain.
Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`,
},
{
pattern: TEAM_PATTERN,
message: TEAM_MESSAGE,
},
]
+2 -2
View File
@@ -5,7 +5,7 @@ import {
} from "./constants"
export interface DetectedKeyword {
type: "ultrawork" | "search" | "analyze"
type: "ultrawork" | "search" | "analyze" | "team"
message: string
}
@@ -33,7 +33,7 @@ export function detectKeywords(text: string, agentName?: string, modelID?: strin
export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] {
const textWithoutCode = removeCodeBlocks(text)
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
const types: Array<DetectedKeyword["type"]> = ["ultrawork", "search", "analyze", "team"]
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
matches: pattern.test(textWithoutCode),
type: types[index],
+181
View File
@@ -860,3 +860,184 @@ describe("keyword-detector non-OMO agent skipping", () => {
expect(textPart!.text).not.toContain("[search-mode]")
})
})
describe("keyword-detector team mode", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
_resetForTesting()
logCalls = []
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
logCalls.push({ msg, data })
})
})
afterEach(() => {
logSpy?.mockRestore()
getMainSessionSpy?.mockRestore()
_resetForTesting()
})
function createMockPluginInput() {
return {
client: {
tui: {
showToast: async () => {},
},
},
} as unknown as PluginInput
}
test("should inject team-mode message when user types 'team mode'", async () => {
// given - main session typing English 'team mode'
const collector = new ContextCollector()
const sessionID = "team-en-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "let's use team mode for this task" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended with team_* tool guidance
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("team_create")
expect(textPart!.text).toContain("team_task_create")
expect(textPart!.text).toContain("team_send_message")
expect(textPart!.text).toContain("NEVER substitute with delegate_task")
expect(textPart!.text).toContain("for this task")
})
test("should inject team-mode message when user types '팀 모드' (Korean with space)", async () => {
// given - main session typing Korean '팀 모드'
const collector = new ContextCollector()
const sessionID = "team-ko-spaced-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "이거 팀 모드로 해줘" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("팀 모드로 해줘")
})
test("should inject team-mode message when user types '팀으로'", async () => {
// given - main session typing Korean '팀으로'
const collector = new ContextCollector()
const sessionID = "team-ko-eulo-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "팀으로 일하자" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode message should be prepended
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("[team-mode]")
expect(textPart!.text).toContain("팀으로 일하자")
})
test("should NOT trigger team-mode on '스팀으로' (false-positive guard)", async () => {
// given - text contains '팀으로' as substring of another Korean word ('스팀으로')
const collector = new ContextCollector()
const sessionID = "false-positive-eulo-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "스팀으로 게임 켜줘" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered, text unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("스팀으로 게임 켜줘")
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should NOT trigger team-mode on '스팀모드' (Hangul-prefix false-positive guard)", async () => {
// given - text contains '팀모드' as substring of another Korean word ('스팀모드')
const collector = new ContextCollector()
const sessionID = "false-positive-mode-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "스팀모드 활성화" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("스팀모드 활성화")
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should NOT trigger team-mode on bare 'team' without 'mode'", async () => {
// given - text contains 'team' but not 'team mode'
const collector = new ContextCollector()
const sessionID = "bare-team-session"
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "join the team and start working" }],
}
// when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// then - team-mode should NOT be triggered
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).not.toContain("[team-mode]")
})
test("should filter team-mode keyword in non-main session (only ultrawork allowed there)", async () => {
// given - main session set, different (subagent) session triggers team mode
const mainSessionID = "main-team-mode"
const subagentSessionID = "subagent-team-mode"
setMainSession(mainSessionID)
const hook = createKeywordDetectorHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "team mode please" }],
}
// when - subagent session triggers team mode keyword
await hook["chat.message"]({ sessionID: subagentSessionID }, output)
// then - team-mode message should NOT be injected in subagent session
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("team mode please")
expect(textPart!.text).not.toContain("[team-mode]")
})
})
@@ -0,0 +1,17 @@
/**
* Team mode keyword detector.
*
* Triggers when the user explicitly invokes team-mode work:
* - English: team mode, team-mode, team_mode, teammode (case-insensitive)
* - Korean: 팀 모드, 팀모드, 팀으로
*
* The Korean variants use a negative lookbehind on Hangul syllables (가-힣)
* to prevent false positives like "스팀으로" matching "팀으로", or
* "스팀모드" matching "팀모드".
*/
export const TEAM_PATTERN =
/\bteam[\s_-]?mode\b|(?<![가-힣])(?:팀\s*모드|팀으로)/i
export const TEAM_MESSAGE = `[team-mode]
Team mode reference detected. If user wants team-mode work, MUST orchestrate via team_* tools (team_create -> team_task_create + team_send_message). NEVER substitute with delegate_task - it is not equivalent. If team_* tools are unavailable (team_mode disabled in config), instruct user to set team_mode.enabled=true and restart opencode.`
+1
View File
@@ -0,0 +1 @@
export { TEAM_PATTERN, TEAM_MESSAGE } from "./default"