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
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ describe("AcpAdapterV2", () => {
endpoint: "http://127.0.0.1:43123/mcp",
authorizationHeader: "Bearer instruction-transition-token",
browserToolsAvailable: false,
capabilities: new Set(["issues"]),
});
yield* Effect.addFinalizer(() =>
Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)),
Expand Down Expand Up @@ -879,6 +880,7 @@ describe("AcpAdapterV2", () => {
assert.include(firstDefault.prompt, "T3 Code interaction mode: Default");
assert.include(firstDefault.prompt, "T3 Code collaborative browser");
assert.include(firstDefault.prompt, "T3 Code orchestration");
assert.include(firstDefault.prompt, "<issue_linking>");
assert.notInclude(
firstDefault.methods,
"session/set_config_option",
Expand All @@ -893,6 +895,7 @@ describe("AcpAdapterV2", () => {
const firstPlan = yield* runTurn(3, planPolicy, "Plan this change.");
assert.include(firstPlan.prompt, "T3 Code interaction mode: Plan");
assert.include(firstPlan.methods, "session/set_config_option");
assert.include(firstPlan.prompt, "<issue_linking>");
assert.include(
(yield* runTurn(4, planPolicy, "Continue planning.")).prompt,
"Continue planning.",
Expand All @@ -904,6 +907,10 @@ describe("AcpAdapterV2", () => {
"session/set_config_option",
"Build should restore the native mode that T3 temporarily replaced for Plan",
);
const mcpSession = McpProviderSession.readMcpProviderSession(threadId)!;
McpProviderSession.setMcpProviderSession({ ...mcpSession, capabilities: new Set() });
const withoutIssues = yield* runTurn(6, defaultPolicy, "Continue without issue tools.");
assert.notInclude(withoutIssues.prompt, "<issue_linking>");
}).pipe(Effect.provide(testLayer), Effect.scoped),
);

Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6789,6 +6789,12 @@ export function makeAcpAdapterV2(
type: "text",
text: buildRuntimeInstructions({
harness: flavor.runtimeHarness ?? driver,
issueToolsAvailable:
instructionState.hasT3Mcp &&
(McpProviderSession.readMcpProviderSession(turnInput.threadId)?.capabilities?.has(
"issues",
) ??
false),
model: turnInput.modelSelection.model,
}),
});
Expand Down
28 changes: 28 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,33 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
}
};

it.each([
{ mcpAttached: true, issueToolsAvailable: true },
{ mcpAttached: true, issueToolsAvailable: false },
{ mcpAttached: false, issueToolsAvailable: true },
])(
"gates issue instructions on attached issue tools: %j",
({ mcpAttached, issueToolsAvailable }) => {
const options = ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-sonnet-4-6",
},
nativeThreadId: "native-thread-claude-issue",
resume: false,
cwd: "/workspace",
...(mcpAttached ? { mcpServers: T3_MCP_SERVERS } : {}),
issueToolsAvailable,
});
const systemPrompt = options.systemPrompt;
assert.isObject(systemPrompt);
assert.equal(
(systemPrompt as { readonly append?: string }).append?.includes("<issue_linking>"),
mcpAttached && issueToolsAvailable,
);
},
);

it("leaves an absent allowlist absent when no MCP session exists", () => {
const overrides = ClaudeAdapterV2.claudeMcpQueryOverrides({
threadId: ThreadId.make("thread-claude-no-mcp-no-allowlist"),
Expand Down Expand Up @@ -719,6 +746,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => {
assert.equal(systemPrompt.type, "preset");
assert.equal(systemPrompt.preset, "claude_code");
assert.include(systemPrompt.append ?? "", "Use `delegate_task`");
assert.notInclude(systemPrompt.append ?? "", "<issue_linking>");
const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options);
assert.equal(logged.hasMcpServers, true);
assert.notInclude(JSON.stringify(logged), "secret-claude-token");
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ export function makeClaudeQueryOptions(input: {
readonly sdkSettings?: string | ClaudeSdkSettings;
readonly environment?: NodeJS.ProcessEnv;
readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
readonly issueToolsAvailable?: boolean;
readonly tools?: ClaudeAgentSdkQueryTools;
readonly allowedTools?: ReadonlyArray<string>;
readonly disallowedTools?: ReadonlyArray<string>;
Expand Down Expand Up @@ -909,8 +910,11 @@ export function makeClaudeQueryOptions(input: {
type: "preset" as const,
preset: "claude_code" as const,
append:
buildRuntimeInstructions({ harness: "Claude Code" }) +
(input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
buildRuntimeInstructions({
harness: "Claude Code",
issueToolsAvailable:
input.mcpServers?.["t3-code"] !== undefined && input.issueToolsAvailable,
}) + (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
},
...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
};
Expand Down Expand Up @@ -6999,6 +7003,12 @@ export function makeClaudeAdapterV2(
environment: adapterOptions.environment,
tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
...mcpOverrides,
issueToolsAvailable:
sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) !== "readOnly" &&
(McpProviderSession.readMcpProviderSession(turnInput.threadId)?.capabilities?.has(
"issues",
) ??
false),
permissionMode: queryPolicy.permissionMode,
...(queryPolicy.allowDangerouslySkipPermissions === undefined
? {}
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,26 @@ describe("CodexAdapterV2 runtime policy", () => {
}),
);

it.effect.each([true, false])(
"gates issue instructions on issue access: %s",
(issueToolsAvailable) =>
Effect.gen(function* () {
const params = yield* CodexAdapterV2.buildCodexTurnStartParams({
nativeThreadId: "native-issue-instructions",
codexInput: [{ type: "text", text: "Work on issue #42" }],
runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null },
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
hasT3Mcp: true,
browserToolsAvailable: false,
issueToolsAvailable,
});
assert.equal(
params.additionalContext?.t3_code_runtime?.value.includes("<issue_linking>"),
issueToolsAvailable,
);
}),
);

it.effect("adds T3 plan-mode developer instructions when the T3 MCP server is attached", () =>
Effect.gen(function* () {
const params = yield* CodexAdapterV2.buildCodexTurnStartParams({
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ export function buildCodexTurnStartParams(input: {
readonly hasT3Mcp?: boolean;
readonly browserToolsAvailable?: boolean;
readonly deviceToolsAvailable?: boolean;
readonly issueToolsAvailable?: boolean;
/** ChatGPT token sharing does not accept service tiers. */
readonly omitServiceTier?: boolean;
}) {
Expand Down Expand Up @@ -736,6 +737,7 @@ export function buildCodexTurnStartParams(input: {
{
browser: input.browserToolsAvailable ?? true,
device: input.deviceToolsAvailable ?? false,
issues: input.issueToolsAvailable ?? false,
},
)
: undefined;
Expand Down Expand Up @@ -5569,6 +5571,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
hasT3Mcp: mcpSession !== undefined,
browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true,
deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false,
issueToolsAvailable: mcpSession?.capabilities?.has("issues") ?? false,
omitServiceTier: adapterOptions.resolveRuntime !== undefined,
});
yield* Ref.update(pendingRootTurns, (current) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe("CursorAdapterV2", () => {
}).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
);

it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () =>
it.effect.each([true, false])("gates issue instructions: %s", (issueToolsAvailable) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -197,6 +197,19 @@ describe("CursorAdapterV2", () => {
let sendCalls = 0;
const instanceId = ProviderInstanceId.make("cursor");
const threadId = ThreadId.make("cursor-transport-error-thread");
McpProviderSession.setMcpProviderSession({
environmentId: EnvironmentId.make("environment-cursor-issue-instructions"),
threadId,
providerSessionId: "mcp-session-cursor-issue-instructions",
providerInstanceId: instanceId,
endpoint: "http://127.0.0.1:43123/mcp",
authorizationHeader: "Bearer fake-issue-instruction-token",
browserToolsAvailable: false,
capabilities: new Set(issueToolsAvailable ? ["issues"] : []),
});
yield* Effect.addFinalizer(() =>
Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)),
);
const modelSelection = { instanceId, model: "composer-2.5" };
const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
Expand Down Expand Up @@ -334,6 +347,7 @@ describe("CursorAdapterV2", () => {
Stream.filter((event) => event.type === "turn.terminal"),
Stream.runHead,
);
assert.equal(sentMessages[0]?.includes("<issue_linking>"), issueToolsAvailable);
assert.equal(sentMessages[1], "/compress");
}).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))),
);
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2175,7 +2175,14 @@ export function makeCursorAdapterV2(
detail: "Cursor turn requires non-empty text or attachments.",
});
}
const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`;
const text = `${userText}\n\n${buildRuntimeInstructions({
harness: "Cursor",
model: turnInput.modelSelection.model,
issueToolsAvailable:
McpProviderSession.readMcpProviderSession(turnInput.threadId)?.capabilities?.has(
"issues",
) ?? false,
})}`;
return images.length === 0
? text
: ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3265,7 +3265,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
if (added) state.mcp = wanted;
}
const instructions = [
buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }),
buildRuntimeInstructions({
harness: "OpenCode",
model: turnInput.modelSelection.model,
issueToolsAvailable:
state.mcp !== undefined && (mcpSession?.capabilities?.has("issues") ?? false),
}),
t3OrchestrationSystemPrompt(state.mcp !== undefined),
]
.filter((part) => part !== undefined && part.length > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3260,6 +3260,8 @@ export function makeOpenCodeAdapterV2(
orchestrationSystemPrompt,
buildRuntimeInstructions({
harness: "OpenCode",
issueToolsAvailable:
hasT3Mcp && (mcpSession?.capabilities?.has("issues") ?? false),
model: turnInput.modelSelection.model,
}),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,54 @@ describe("Pi upstream output-budget workaround", () => {
);
});
});

describe("Pi issue instructions", () => {
it.each([true, false])(
"gates instructions on link_issue in the live tool list: %s",
async (available) => {
type StartHook = (event: { systemPrompt: string }) => { systemPrompt: string };
const handlers = new Map<string, StartHook>();
const registered: string[] = [];
const source = NodeModule.stripTypeScriptTypes(
PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace(
"export default async function",
"async function",
),
);
await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, {
process: { env: { T3_MCP_URL: "http://mcp.test", T3_MCP_BEARER_TOKEN: "fake-token" } },
AbortSignal,
Type: { Object: () => ({}) },
fetch: async (_url: string, input: { body: string }) => {
const request = JSON.parse(input.body) as { id?: number; method: string };
const names = available ? ["link_issue", "list_thread_issues"] : ["list_thread_issues"];
return new Response(
JSON.stringify({
id: request.id,
result:
request.method === "tools/list" ? { tools: names.map((name) => ({ name })) } : {},
}),
{ headers: { "content-type": "application/json" } },
);
},
pi: {
on: (name: string, handler: StartHook) => handlers.set(name, handler),
registerTool: (tool: { name: string }) => registered.push(tool.name),
},
});
assert.include(registered, "mcp__t3-code__list_thread_issues");
const prompt = handlers.get("before_agent_start")!({
systemPrompt: "Pi system prompt",
}).systemPrompt;
assert.isTrue(prompt.startsWith("Pi system prompt\n\n"));
assert.equal(prompt.includes("<issue_linking>"), available);
if (available) {
assert.include(registered, "mcp__t3-code__link_issue");
assert.include(
prompt,
"call link_issue immediately after creating an issue for this thread",
);
}
},
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Do not import t3code modules from the string body. The Pi process resolves
* `@earendil-works/pi-coding-agent` and `typebox` from the user's pi install.
*/
import { ISSUE_LINKING_INSTRUCTIONS } from "../../provider/RuntimeInstructions.ts";
import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";

export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts";
Expand All @@ -30,6 +31,7 @@ const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)};
const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)};
const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)};
const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())};
const ISSUE_LINKING_INSTRUCTIONS = ${JSON.stringify(ISSUE_LINKING_INSTRUCTIONS)};
const PROTOCOL = "2025-06-18";
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]);
const FILE_CHANGE_TOOLS = new Set(${JSON.stringify(PI_FILE_CHANGE_TOOLS)});
Expand Down Expand Up @@ -260,13 +262,15 @@ export default async function t3McpExtension(pi: ExtensionAPI) {

const client = createMcpClient(endpoint, token);
let started: Promise<void> | undefined;
let issueToolsAvailable = false;

const ensureStarted = () => {
if (started !== undefined) return started;
const attempt = (async () => {
const signal = AbortSignal.timeout(10_000);
await client.connect(signal);
const tools = await client.listTools(signal);
issueToolsAvailable = tools.some((tool) => tool.name === "link_issue");
for (const tool of tools) {
const name = tool.name;
const registeredName = \`mcp__t3-code__\${name}\`;
Expand Down Expand Up @@ -322,7 +326,9 @@ export default async function t3McpExtension(pi: ExtensionAPI) {
// Wrapping the first user message instead would stop it from starting
// with "/" and silently break slash-command expansion.
pi.on("before_agent_start", (event) => ({
systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS,
systemPrompt:
event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS +
(issueToolsAvailable ? "\\n\\n" + ISSUE_LINKING_INSTRUCTIONS : ""),
}));
}
`;
20 changes: 20 additions & 0 deletions apps/server/src/provider/CodexDeveloperInstructions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,26 @@ describe("buildCodexDeveloperInstructions", () => {
});
});

describe("T3 issue developer instructions", () => {
it("uses issue access independently of browser and device access", () => {
const runtime = { model: "gpt-5.4", reasoningEffort: "high" };
NodeAssert.match(
buildCodexAdditionalContext(runtime, { browser: false, device: false, issues: true })
.t3_code_runtime!.value,
/<issue_linking>/,
);
NodeAssert.doesNotMatch(
buildCodexAdditionalContext(runtime, { browser: true, device: true, issues: false })
.t3_code_runtime!.value,
/<issue_linking>/,
);
NodeAssert.doesNotMatch(
buildCodexAdditionalContext(runtime, false).t3_code_runtime!.value,
/<issue_linking>/,
);
});
});

describe("T3 browser developer instructions", () => {
const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" };

Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/provider/CodexDeveloperInstructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators an
export interface T3CodeToolAvailability {
readonly browser: boolean;
readonly device: boolean;
readonly issues?: boolean;
}

const normalizeAvailability = (
Expand Down Expand Up @@ -218,7 +219,12 @@ export function buildCodexAdditionalContext(
t3_code_orchestration: { kind: "application", value: T3_CODE_ORCHESTRATION_INSTRUCTIONS },
t3_code_runtime: {
kind: "application",
value: buildRuntimeInstructions({ harness: "Codex", ...runtime }),
value: buildRuntimeInstructions({
harness: "Codex",
...runtime,
issueToolsAvailable:
typeof toolsAvailable === "boolean" ? toolsAvailable : toolsAvailable.issues === true,
}),
},
...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}),
};
Expand Down
Loading
Loading