Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 83 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
codexBackgroundCommandDetail,
codexFileChangeApprovalPrompt,
codexProviderTurnTokenUsage,
codexSkillMentionText,
codexThreadRuntimeParams,
type CodexAppServerClientFactoryShape,
makeCodexAdapterV2,
Expand Down Expand Up @@ -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<readonly [string, string]> = [
["€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(
Expand Down Expand Up @@ -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<CodexReplay.CodexAppServerReplayEntry> {
return [
{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<ProviderAdapterV2Event>) =>
events.filter(
(event): event is Extract<ProviderAdapterV2Event, { type: "message.updated" }> =>
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -2682,7 +2692,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
Effect.gen(function* () {
const inputItems: Array<CodexSchema.V2TurnStartParams__UserInput> = [];
const text = providerMessageTextWithAttachmentPaths({
text: turnInput.message.text,
text: codexSkillMentionText(turnInput.message.text),
attachments: turnInput.message.attachments,
attachmentsDir: serverConfig.attachmentsDir,
});
Expand Down
16 changes: 13 additions & 3 deletions packages/shared/src/composerInlineTokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading