diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 9392ae4047d6..1268f2d567e8 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( @@ -1347,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 [ { @@ -1430,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", @@ -1939,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 => 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