From 9692f2abbb82fddd3f38b833dc16478d52618aec Mon Sep 17 00:00:00 2001 From: wqymi Date: Tue, 14 Jul 2026 23:47:27 +0800 Subject: [PATCH 1/2] feat(orchestrator): auto-derive topic for session reuse Add automatic topic routing for the session tool so same-theme child sessions are auto-reused without the LLM having to pass --topic manually. Changes: - Add deriveTopic() helper that extracts stable topic keys from: * PR numbers in task text (e.g., #1234, PR 1234, pull/1234) * Directory basename (normalized to lowercase-hyphenated) - Auto-derive topic in create branch when op.topic is not set - Support __fresh__ sentinel to opt out of auto-routing - Export deriveTopic and FRESH_SENTINEL for testing - Add comprehensive tests for deriveTopic The derived topic is prefixed with 'auto:' to avoid collision with explicit topics. PR numbers take precedence over directory signals. Same inputs always produce the same topic key for stable reuse. --- packages/opencode/src/tool/session.ts | 52 ++++++++++++++++++ packages/opencode/test/derive-topic.test.ts | 55 +++++++++++++++++++ .../opencode/test/session/session.test.ts | 54 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 packages/opencode/test/derive-topic.test.ts diff --git a/packages/opencode/src/tool/session.ts b/packages/opencode/src/tool/session.ts index 84369ed20..520b6e5bd 100644 --- a/packages/opencode/src/tool/session.ts +++ b/packages/opencode/src/tool/session.ts @@ -195,6 +195,44 @@ function tagTitle(topic: string, title: string): string { return `[topic:${topic}] ${base}` } +// Sentinel value: pass topic="__fresh__" to explicitly opt out of auto-routing +// and force a fresh session even when dir/PR would match an existing topic. +const FRESH_SENTINEL = "__fresh__" + +// deriveTopic: stable topic derivation from create-operation context. +// Same inputs always produce the same topic key, enabling automatic session +// reuse when the LLM doesn't pass --topic. Falls back to no auto-route if +// no stable signal is available. +// +// Signals (in priority order): +// 1. PR number in task text (e.g. "#1234", "PR 1234", "pull/1234") +// 2. Directory basename (normalized: lowercased, non-alphanum → hyphens) +// +// The derived key is prefixed with "auto:" so explicit topics (no prefix) +// always take precedence in the find-or-reuse lookup — a caller can't +// accidentally collide with an auto-derived topic. +function deriveTopic(op: { task: string; dir?: string }): string | undefined { + // Signal 1: PR number in the task description + // Matches: "PR 1234", "#1234", "pull/1234" + // Does NOT match: "issue #1234" (word chars before #), "version 1.2.3" + const prMatch = op.task.match(/(?:^|[\s])PR\s+(\d+)|(? 0) { + return `auto:dir-${basename}` + } + } + + // No stable signal — return undefined so a fresh session is always created. + return undefined +} + const createOperation = z.strictObject({ action: z.literal("create"), @@ -613,6 +651,17 @@ export const SessionTool = Tool.define( if (op.action === "create") { const actor = yield* requireActor() + // Auto-topic routing: when the caller doesn't pass --topic, derive a + // stable topic from the operation context (PR number or dir basename). + // This makes same-theme child sessions auto-reuse without the LLM + // having to manually pass --topic every time. The explicit FRESH_SENTINEL + // ("__fresh__") opts out and forces a fresh session. + if (!op.topic) { + op.topic = deriveTopic(op) + } else if (op.topic === FRESH_SENTINEL) { + op.topic = undefined + } + // --topic find-or-reuse: before spawning, look for a standing peer child // already tagged with this topic. If found, RELAY the task into it // (enqueue+wake — the same idle-peer path `session send` uses) instead of @@ -1121,3 +1170,6 @@ export const SessionTool = Tool.define( } satisfies Tool.DefWithoutID }), ) + +// Exported for testing only — not part of the public API. +export { deriveTopic, FRESH_SENTINEL } diff --git a/packages/opencode/test/derive-topic.test.ts b/packages/opencode/test/derive-topic.test.ts new file mode 100644 index 000000000..c434e59d7 --- /dev/null +++ b/packages/opencode/test/derive-topic.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { deriveTopic, FRESH_SENTINEL } from "../src/tool/session" + +describe("deriveTopic", () => { + test("derives topic from PR number in task", () => { + expect(deriveTopic({ task: "Fix bug in #1234" })).toBe("auto:pr-1234") + expect(deriveTopic({ task: "PR 5678: implement feature" })).toBe("auto:pr-5678") + expect(deriveTopic({ task: "review pull/9012 changes" })).toBe("auto:pr-9012") + expect(deriveTopic({ task: "Fix #42" })).toBe("auto:pr-42") + }) + + test("derives topic from directory basename", () => { + expect(deriveTopic({ task: "fix bug", dir: "/Users/dev/projects/my-app" })).toBe("auto:dir-my-app") + expect(deriveTopic({ task: "fix bug", dir: "/path/to/My_Project" })).toBe("auto:dir-my-project") + expect(deriveTopic({ task: "fix bug", dir: "/path/to/project_name_here" })).toBe("auto:dir-project-name-here") + }) + + test("PR number takes precedence over directory", () => { + expect(deriveTopic({ task: "Fix #1234", dir: "/path/to/my-app" })).toBe("auto:pr-1234") + }) + + test("returns undefined when no stable signal available", () => { + expect(deriveTopic({ task: "fix some bug" })).toBeUndefined() + expect(deriveTopic({ task: "implement feature" })).toBeUndefined() + }) + + test("is stable — same inputs produce same output", () => { + const input = { task: "Fix #1234", dir: "/path/to/my-app" } + const result1 = deriveTopic(input) + const result2 = deriveTopic(input) + expect(result1).toBe(result2) + expect(result1).toBe("auto:pr-1234") + }) + + test("explicit topic takes precedence over derived", () => { + // When op.topic is already set, deriveTopic is not called. + // This test documents that FRESH_SENTINEL is recognized. + expect(FRESH_SENTINEL).toBe("__fresh__") + }) + + test("handles edge cases in PR pattern", () => { + // PR at start of string + expect(deriveTopic({ task: "#100 fix typo" })).toBe("auto:pr-100") + // PR with hash prefix + expect(deriveTopic({ task: "merge #2000 into main" })).toBe("auto:pr-2000") + // No match for non-PR patterns + expect(deriveTopic({ task: "version 1.2.3" })).toBeUndefined() + // "issue #123" is a valid issue/PR reference + expect(deriveTopic({ task: "issue #123" })).toBe("auto:pr-123") + // "PR #123" should match + expect(deriveTopic({ task: "PR #123 is ready" })).toBe("auto:pr-123") + // Attached hash (no space) should NOT match + expect(deriveTopic({ task: "fix issue#123 bug" })).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index f63ad9bee..c98f43293 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -8,6 +8,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" import { tmpdir } from "../fixture/fixture" +import { deriveTopic, FRESH_SENTINEL } from "../../src/tool/session" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -179,3 +180,56 @@ describe("Session", () => { expect(missing).toBe(true) }) }) + +describe("deriveTopic", () => { + test("derives topic from PR number in task", () => { + expect(deriveTopic({ task: "Fix bug in #1234" })).toBe("auto:pr-1234") + expect(deriveTopic({ task: "PR 5678: implement feature" })).toBe("auto:pr-5678") + expect(deriveTopic({ task: "review pull/9012 changes" })).toBe("auto:pr-9012") + expect(deriveTopic({ task: "Fix #42" })).toBe("auto:pr-42") + }) + + test("derives topic from directory basename", () => { + expect(deriveTopic({ task: "fix bug", dir: "/Users/dev/projects/my-app" })).toBe("auto:dir-my-app") + expect(deriveTopic({ task: "fix bug", dir: "/path/to/My_Project" })).toBe("auto:dir-my-project") + expect(deriveTopic({ task: "fix bug", dir: "/path/to/project_name_here" })).toBe("auto:dir-project-name-here") + }) + + test("PR number takes precedence over directory", () => { + expect(deriveTopic({ task: "Fix #1234", dir: "/path/to/my-app" })).toBe("auto:pr-1234") + }) + + test("returns undefined when no stable signal available", () => { + expect(deriveTopic({ task: "fix some bug" })).toBeUndefined() + expect(deriveTopic({ task: "implement feature" })).toBeUndefined() + }) + + test("is stable — same inputs produce same output", () => { + const input = { task: "Fix #1234", dir: "/path/to/my-app" } + const result1 = deriveTopic(input) + const result2 = deriveTopic(input) + expect(result1).toBe(result2) + expect(result1).toBe("auto:pr-1234") + }) + + test("explicit topic takes precedence over derived", () => { + // When op.topic is already set, deriveTopic is not called. + // This test documents that FRESH_SENTINEL is recognized. + expect(FRESH_SENTINEL).toBe("__fresh__") + }) + + test("handles edge cases in PR pattern", () => { + // PR at start of string + expect(deriveTopic({ task: "#100 fix typo" })).toBe("auto:pr-100") + // PR with hash prefix + expect(deriveTopic({ task: "merge #2000 into main" })).toBe("auto:pr-2000") + // No match for non-PR patterns + expect(deriveTopic({ task: "version 1.2.3" })).toBeUndefined() + // "issue #123" is a valid issue/PR reference + expect(deriveTopic({ task: "issue #123" })).toBe("auto:pr-123") + // "PR #123" should match + expect(deriveTopic({ task: "PR #123 is ready" })).toBe("auto:pr-123") + // Attached hash (no space) should NOT match + expect(deriveTopic({ task: "fix issue#123 bug" })).toBeUndefined() + }) +}) From 8afbefd6e2d4c8a4bf582d0216c39443ec06fac7 Mon Sep 17 00:00:00 2001 From: wqymi Date: Wed, 15 Jul 2026 00:43:42 +0800 Subject: [PATCH 2/2] fix(orchestrator): remove fragile string-matching topic auto-derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation guessed topics via regex on task text (PR numbers #1234/PR 1234/pull/1234) and directory basename. This is fragile and wrong: task text mentioning '#1234' misroutes; same-dir different-theme tasks wrongly merge; genuinely-related tasks without a PR number split. Remove deriveTopic's regex/basename logic entirely. Topic is now ONLY set when the caller explicitly passes it (op.topic). When absent, a fresh session is always created — no silent misrouting. Design choice (A): topic stays OPTIONAL in schema, but the harness no longer guesses. If you want reuse, pass an explicit --topic/topic; otherwise you get a fresh session. This is deterministic and caller-controlled. Changes: - Remove deriveTopic function (regex + basename logic) - Remove FRESH_SENTINEL constant - Remove auto-routing fallback in create branch - Remove exports for testing - Delete test/derive-topic.test.ts - Update topic schema description to clarify contract - Keep find-or-reuse machinery (if op.topic) unchanged --- packages/opencode/src/tool/session.ts | 54 +----------------- packages/opencode/test/derive-topic.test.ts | 55 ------------------- .../opencode/test/session/session.test.ts | 54 ------------------ 3 files changed, 1 insertion(+), 162 deletions(-) delete mode 100644 packages/opencode/test/derive-topic.test.ts diff --git a/packages/opencode/src/tool/session.ts b/packages/opencode/src/tool/session.ts index 520b6e5bd..cd5981681 100644 --- a/packages/opencode/src/tool/session.ts +++ b/packages/opencode/src/tool/session.ts @@ -195,44 +195,6 @@ function tagTitle(topic: string, title: string): string { return `[topic:${topic}] ${base}` } -// Sentinel value: pass topic="__fresh__" to explicitly opt out of auto-routing -// and force a fresh session even when dir/PR would match an existing topic. -const FRESH_SENTINEL = "__fresh__" - -// deriveTopic: stable topic derivation from create-operation context. -// Same inputs always produce the same topic key, enabling automatic session -// reuse when the LLM doesn't pass --topic. Falls back to no auto-route if -// no stable signal is available. -// -// Signals (in priority order): -// 1. PR number in task text (e.g. "#1234", "PR 1234", "pull/1234") -// 2. Directory basename (normalized: lowercased, non-alphanum → hyphens) -// -// The derived key is prefixed with "auto:" so explicit topics (no prefix) -// always take precedence in the find-or-reuse lookup — a caller can't -// accidentally collide with an auto-derived topic. -function deriveTopic(op: { task: string; dir?: string }): string | undefined { - // Signal 1: PR number in the task description - // Matches: "PR 1234", "#1234", "pull/1234" - // Does NOT match: "issue #1234" (word chars before #), "version 1.2.3" - const prMatch = op.task.match(/(?:^|[\s])PR\s+(\d+)|(? 0) { - return `auto:dir-${basename}` - } - } - - // No stable signal — return undefined so a fresh session is always created. - return undefined -} - const createOperation = z.strictObject({ action: z.literal("create"), @@ -252,7 +214,7 @@ const createOperation = z.strictObject({ .min(1) .optional() .describe( - "Reuse a standing per-theme child: if a peer child already carries this topic, RELAY the task into it (enqueue+wake) instead of spawning; otherwise create a new child tagged with this topic. Avoids over-spawning sessions for the same theme.", + "Semantic key for session reuse: if a peer child already carries this exact topic, RELAY the task into it (enqueue+wake) instead of spawning a new child. When absent, a fresh session is always created — no auto-derivation or guessing. Use the same stable topic string across calls to consolidate same-theme work into one standing child.", ), }) @@ -651,17 +613,6 @@ export const SessionTool = Tool.define( if (op.action === "create") { const actor = yield* requireActor() - // Auto-topic routing: when the caller doesn't pass --topic, derive a - // stable topic from the operation context (PR number or dir basename). - // This makes same-theme child sessions auto-reuse without the LLM - // having to manually pass --topic every time. The explicit FRESH_SENTINEL - // ("__fresh__") opts out and forces a fresh session. - if (!op.topic) { - op.topic = deriveTopic(op) - } else if (op.topic === FRESH_SENTINEL) { - op.topic = undefined - } - // --topic find-or-reuse: before spawning, look for a standing peer child // already tagged with this topic. If found, RELAY the task into it // (enqueue+wake — the same idle-peer path `session send` uses) instead of @@ -1170,6 +1121,3 @@ export const SessionTool = Tool.define( } satisfies Tool.DefWithoutID }), ) - -// Exported for testing only — not part of the public API. -export { deriveTopic, FRESH_SENTINEL } diff --git a/packages/opencode/test/derive-topic.test.ts b/packages/opencode/test/derive-topic.test.ts deleted file mode 100644 index c434e59d7..000000000 --- a/packages/opencode/test/derive-topic.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { deriveTopic, FRESH_SENTINEL } from "../src/tool/session" - -describe("deriveTopic", () => { - test("derives topic from PR number in task", () => { - expect(deriveTopic({ task: "Fix bug in #1234" })).toBe("auto:pr-1234") - expect(deriveTopic({ task: "PR 5678: implement feature" })).toBe("auto:pr-5678") - expect(deriveTopic({ task: "review pull/9012 changes" })).toBe("auto:pr-9012") - expect(deriveTopic({ task: "Fix #42" })).toBe("auto:pr-42") - }) - - test("derives topic from directory basename", () => { - expect(deriveTopic({ task: "fix bug", dir: "/Users/dev/projects/my-app" })).toBe("auto:dir-my-app") - expect(deriveTopic({ task: "fix bug", dir: "/path/to/My_Project" })).toBe("auto:dir-my-project") - expect(deriveTopic({ task: "fix bug", dir: "/path/to/project_name_here" })).toBe("auto:dir-project-name-here") - }) - - test("PR number takes precedence over directory", () => { - expect(deriveTopic({ task: "Fix #1234", dir: "/path/to/my-app" })).toBe("auto:pr-1234") - }) - - test("returns undefined when no stable signal available", () => { - expect(deriveTopic({ task: "fix some bug" })).toBeUndefined() - expect(deriveTopic({ task: "implement feature" })).toBeUndefined() - }) - - test("is stable — same inputs produce same output", () => { - const input = { task: "Fix #1234", dir: "/path/to/my-app" } - const result1 = deriveTopic(input) - const result2 = deriveTopic(input) - expect(result1).toBe(result2) - expect(result1).toBe("auto:pr-1234") - }) - - test("explicit topic takes precedence over derived", () => { - // When op.topic is already set, deriveTopic is not called. - // This test documents that FRESH_SENTINEL is recognized. - expect(FRESH_SENTINEL).toBe("__fresh__") - }) - - test("handles edge cases in PR pattern", () => { - // PR at start of string - expect(deriveTopic({ task: "#100 fix typo" })).toBe("auto:pr-100") - // PR with hash prefix - expect(deriveTopic({ task: "merge #2000 into main" })).toBe("auto:pr-2000") - // No match for non-PR patterns - expect(deriveTopic({ task: "version 1.2.3" })).toBeUndefined() - // "issue #123" is a valid issue/PR reference - expect(deriveTopic({ task: "issue #123" })).toBe("auto:pr-123") - // "PR #123" should match - expect(deriveTopic({ task: "PR #123 is ready" })).toBe("auto:pr-123") - // Attached hash (no space) should NOT match - expect(deriveTopic({ task: "fix issue#123 bug" })).toBeUndefined() - }) -}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c98f43293..f63ad9bee 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -8,7 +8,6 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" import { tmpdir } from "../fixture/fixture" -import { deriveTopic, FRESH_SENTINEL } from "../../src/tool/session" const projectRoot = path.join(__dirname, "../..") void Log.init({ print: false }) @@ -180,56 +179,3 @@ describe("Session", () => { expect(missing).toBe(true) }) }) - -describe("deriveTopic", () => { - test("derives topic from PR number in task", () => { - expect(deriveTopic({ task: "Fix bug in #1234" })).toBe("auto:pr-1234") - expect(deriveTopic({ task: "PR 5678: implement feature" })).toBe("auto:pr-5678") - expect(deriveTopic({ task: "review pull/9012 changes" })).toBe("auto:pr-9012") - expect(deriveTopic({ task: "Fix #42" })).toBe("auto:pr-42") - }) - - test("derives topic from directory basename", () => { - expect(deriveTopic({ task: "fix bug", dir: "/Users/dev/projects/my-app" })).toBe("auto:dir-my-app") - expect(deriveTopic({ task: "fix bug", dir: "/path/to/My_Project" })).toBe("auto:dir-my-project") - expect(deriveTopic({ task: "fix bug", dir: "/path/to/project_name_here" })).toBe("auto:dir-project-name-here") - }) - - test("PR number takes precedence over directory", () => { - expect(deriveTopic({ task: "Fix #1234", dir: "/path/to/my-app" })).toBe("auto:pr-1234") - }) - - test("returns undefined when no stable signal available", () => { - expect(deriveTopic({ task: "fix some bug" })).toBeUndefined() - expect(deriveTopic({ task: "implement feature" })).toBeUndefined() - }) - - test("is stable — same inputs produce same output", () => { - const input = { task: "Fix #1234", dir: "/path/to/my-app" } - const result1 = deriveTopic(input) - const result2 = deriveTopic(input) - expect(result1).toBe(result2) - expect(result1).toBe("auto:pr-1234") - }) - - test("explicit topic takes precedence over derived", () => { - // When op.topic is already set, deriveTopic is not called. - // This test documents that FRESH_SENTINEL is recognized. - expect(FRESH_SENTINEL).toBe("__fresh__") - }) - - test("handles edge cases in PR pattern", () => { - // PR at start of string - expect(deriveTopic({ task: "#100 fix typo" })).toBe("auto:pr-100") - // PR with hash prefix - expect(deriveTopic({ task: "merge #2000 into main" })).toBe("auto:pr-2000") - // No match for non-PR patterns - expect(deriveTopic({ task: "version 1.2.3" })).toBeUndefined() - // "issue #123" is a valid issue/PR reference - expect(deriveTopic({ task: "issue #123" })).toBe("auto:pr-123") - // "PR #123" should match - expect(deriveTopic({ task: "PR #123 is ready" })).toBe("auto:pr-123") - // Attached hash (no space) should NOT match - expect(deriveTopic({ task: "fix issue#123 bug" })).toBeUndefined() - }) -})