From f2e540e6e8ac560f4698dcf5396d735e8a291251 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:09:42 -0700 Subject: [PATCH 1/2] fix(server): Codex V2 runs skills typed with any currency sigil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composers accept any currency symbol as the skill sigil (`€review`), and V1 Codex rewrote it to `$` before sending. The V2 Codex adapter sent the text unchanged, so Codex, which only parses `$name`, never invoked the skill. Export the prompt-side skill mention pattern from composerInlineTokens, derive the composer chip regex from the same source, and canonicalize the sigil in the V2 Codex input builder for both turn/start and turn/steer. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/CodexAdapterV2.test.ts | 21 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 12 ++++++++++- packages/shared/src/composerInlineTokens.ts | 16 +++++++++++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9392ae4047d6..e47ffddbec1b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -67,6 +67,7 @@ import { codexBackgroundCommandDetail, codexFileChangeApprovalPrompt, codexProviderTurnTokenUsage, + codexSkillMentionText, codexThreadRuntimeParams, type CodexAppServerClientFactoryShape, makeCodexAdapterV2, @@ -1215,6 +1216,26 @@ describe("CodexAdapterV2 fork boundary", () => { ); }); +describe("CodexAdapterV2 skill mentions", () => { + it("sends currency-sigil skill mentions as the $ mention Codex parses", () => { + const cases: ReadonlyArray = [ + ["€review do it", "$review do it"], + ["£ship", "$ship"], + ["please ¥review this diff", "please $review this diff"], + ["first line\n₹ship it", "first line\n$ship it"], + ["𑿝review then €2spec", "$review then $2spec"], + ["$review", "$review"], + ["costs €20", "costs €20"], + ["€5k", "€5k"], + ["budget €100M or €1e6", "budget €100M or €1e6"], + ["5€review", "5€review"], + ]; + for (const [text, expected] of cases) { + assert.equal(codexSkillMentionText(text), expected, text); + } + }); +}); + describe("CodexAdapterV2 background command detail", () => { it("summarizes command, exit code, and output tail", () => { assert.equal( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index bdce415abe81..51589d91dac3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -24,6 +24,7 @@ import { isOrchestrationV2WorkActive, ProviderDriverKind, } from "@t3tools/contracts"; +import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { computerUseToolTitle } from "@t3tools/shared/toolActivity"; import { getModelSelectionStringOptionValue, modelSelectionsEqual } from "@t3tools/shared/model"; @@ -404,6 +405,15 @@ function codexItemStatus(status: "inProgress" | "completed" | "failed" | "declin } } +/** + * The composers let a skill be typed with any currency sigil (`€review`), but + * Codex only parses `$name` as a skill mention. Rewrite the sigil so the skill + * runs; currency amounts like `€20` do not match and stay prose. + */ +export function codexSkillMentionText(text: string): string { + return text.replace(SKILL_MENTION_PATTERN, "$1$$$2"); +} + const BACKGROUND_COMMAND_DETAIL_COMMAND_MAX_LENGTH = 200; const BACKGROUND_COMMAND_DETAIL_OUTPUT_TAIL_MAX_LENGTH = 1_000; @@ -2682,7 +2692,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi Effect.gen(function* () { const inputItems: Array = []; const text = providerMessageTextWithAttachmentPaths({ - text: turnInput.message.text, + text: codexSkillMentionText(turnInput.message.text), attachments: turnInput.message.attachments, attachmentsDir: serverConfig.attachmentsDir, }); diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index e7f57f260aea..0e3a48f35d27 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -23,10 +23,20 @@ export interface CollectComposerInlineTokensOptions { * numeric expressions like "$20", "$20k", "$100M", and "$1e6" must stay prose: * the composer chips any matched `$name` token, known or not. Tokens beginning * with digits must not match numbers with currency/exponent suffixes, and must - * contain at least one letter. + * contain at least one letter. Any currency symbol is accepted as the sigil. */ -const SKILL_TOKEN_REGEX = - /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s)/gu; +const SKILL_MENTION_SOURCE = + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)/u + .source; +// While typing, a token only becomes a chip once a delimiter follows it, so a +// half-typed name at the end of the text stays plain. +const SKILL_TOKEN_REGEX = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s)`, "gu"); +/** + * Skill mentions in a sent prompt, which may also end at the end of the text. + * Group 1 is the leading delimiter and group 2 the skill name. The pattern is + * global, so use it with `matchAll` or `replace`, not `test` or `exec`. + */ +export const SKILL_MENTION_PATTERN = new RegExp(`${SKILL_MENTION_SOURCE}(?=\\s|$)`, "gu"); const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in From 008162ebf0edf8fb1932b8d9c233bf04ead5ce42 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:19:14 -0700 Subject: [PATCH 2/2] test(server): replay currency-sigil skill mentions through Codex startTurn and steerTurn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table test only called codexSkillMentionText, so dropping the call from toCodexInput left every test green. Replay a scripted turn that sends `€review do it` and steers with `then £ship it`, and expect the outbound turn/start and turn/steer frames to carry `$` mentions. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Adapters/CodexAdapterV2.test.ts | 63 ++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index e47ffddbec1b..1268f2d567e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1368,6 +1368,8 @@ function codexReplayPreamble(input: { readonly nativeThreadId: string; readonly nativeTurnId: string; readonly prompt: string; + /** Text the adapter should send, when it differs from what the user typed. */ + readonly sentPrompt?: string; }): Array { return [ { @@ -1451,7 +1453,7 @@ function codexReplayPreamble(input: { method: "turn/start", params: { threadId: input.nativeThreadId, - input: [{ type: "text", text: input.prompt }], + input: [{ type: "text", text: input.sentPrompt ?? input.prompt }], cwd: "/workspace", model: "gpt-5.4", approvalPolicy: "never", @@ -1960,6 +1962,65 @@ describe("CodexAdapterV2 post-settle continuation", () => { }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), ); + it.effect("sends currency-sigil skill mentions to Codex as $ mentions", () => + Effect.gen(function* () { + const nativeThreadId = "skill-sigil-thread"; + const nativeTurnId = "skill-sigil-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "skill-sigil-canonicalized", + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "€review do it", + sentPrompt: "$review do it", + }), + { + type: "expect_outbound", + label: "turn/steer", + frame: { + id: 4, + method: "turn/steer", + params: { + expectedTurnId: nativeTurnId, + input: [{ type: "text", text: "then $ship it" }], + threadId: nativeThreadId, + }, + }, + }, + { + type: "emit_inbound", + label: "turn/steer", + frame: { id: 4, result: { turnId: nativeTurnId } }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + const turnInput = makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("skill-sigil-attempt"), + text: "€review do it", + }); + yield* harness.runtime.startTurn(turnInput); + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: turnInput.runId, + providerThread: harness.providerThread, + providerTurnId: (yield* IdAllocatorV2).derive.providerTurn({ + driver: CODEX_DRIVER_KIND, + nativeTurnId, + }), + message: { + ...turnInput.message, + messageId: MessageId.make("message-skill-sigil-steer"), + text: "then £ship it", + }, + }); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + const assistantMessages = (events: ReadonlyArray) => events.filter( (event): event is Extract =>