Skip to content
Open
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
104 changes: 104 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,110 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
);
});

describe("ClaudeAdapterV2 append-system-prompt launch args", () => {
const startTurnWithLaunchArgs = (launchArgs: string) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-" });
yield* fileSystem.writeFileString(path.join(cwd, "extra.md"), "code word: pineapple");
let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
const adapter = ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs },
environment: {},
attachmentsDir: cwd,
fileSystem,
path,
idAllocator: yield* IdAllocator.IdAllocatorV2,
queryRunner: {
allocateSessionId: Effect.succeed("native-thread-claude-append"),
open: (input) =>
Effect.sync(() => {
openedOptions = input.options;
return {
messages: Stream.never,
offer: () => Effect.void,
setModel: () => Effect.void,
interrupt: Effect.void,
close: Effect.void,
};
}),
forkSession: () => Effect.die("unused"),
subagentLaunchToolUseId: () => Effect.succeed(null),
assertComplete: Effect.void,
},
});
const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
...CLAUDE_TEST_RUNTIME_POLICY,
cwd,
});
const threadId = ThreadId.make("thread-claude-append");
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-claude-append"),
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
});
const exit = yield* runtime
.startTurn(
makeClaudeTestTurnInput({
threadId,
providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-claude-append"),
text: "What is the code word?",
attachments: [],
runtimePolicy,
}),
)
.pipe(Effect.exit);
return { exit, openedOptions };
});

it.effect("folds the file and inline text into the system prompt append", () =>
Effect.scoped(
Effect.gen(function* () {
const { exit, openedOptions } = yield* startTurnWithLaunchArgs(
`--append-system-prompt-file extra.md --append-system-prompt "inline text"`,
);
assert.isTrue(Exit.isSuccess(exit));
const systemPrompt = openedOptions?.systemPrompt;
if (
typeof systemPrompt !== "object" ||
systemPrompt === null ||
!("append" in systemPrompt)
) {
return assert.fail("expected a preset system prompt with an append");
}
assert.isTrue(
systemPrompt.append?.endsWith("\n\ncode word: pineapple\n\ninline text"),
systemPrompt.append,
);
assert.isUndefined(openedOptions?.extraArgs?.["append-system-prompt"]);
assert.isUndefined(openedOptions?.extraArgs?.["append-system-prompt-file"]);
}).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
),
);

it.effect("fails the turn when the file cannot be read", () =>
Effect.scoped(
Effect.gen(function* () {
const { exit, openedOptions } = yield* startTurnWithLaunchArgs(
"--append-system-prompt-file missing.md",
);
assert.isTrue(Exit.isFailure(exit));
assert.isUndefined(openedOptions);
}).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
),
);
});

describe("ClaudeAdapterV2 approval cancellation", () => {
it.effect("observes an approval signal that was already aborted", () =>
Effect.gen(function* () {
Expand Down
44 changes: 42 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -784,13 +784,23 @@ export function makeClaudeQueryOptions(input: {
readonly onUserDialog?: ClaudeQueryOptions["onUserDialog"];
readonly supportedDialogKinds?: ClaudeQueryOptions["supportedDialogKinds"];
readonly allowDangerouslySkipPermissions?: boolean;
/** Contents of the `--append-system-prompt-file` launch arg, read by the caller. */
readonly appendSystemPromptFileText?: string;
}): ClaudeAgentSdkQueryOptions {
const compiledSelection = compileClaudeModelSelection(input.modelSelection);
const {
"permission-mode": launchArgPermissionMode,
"dangerously-skip-permissions": launchArgSkipPermissions,
...extraArgs
} = input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags;
// The SDK sends systemPrompt.append on initialize and the CLI assigns it over
// the text these launch args loaded, so their text joins that append instead.
// A flag without a value is left for the CLI to report.
const launchArgAppendSystemPrompt = extraArgs["append-system-prompt"] ?? undefined;
if (launchArgAppendSystemPrompt !== undefined) delete extraArgs["append-system-prompt"];
if (input.appendSystemPromptFileText !== undefined) {
delete extraArgs["append-system-prompt-file"];
}
const requestThinkingSummaries =
compiledSelection.settings.alwaysThinkingEnabled !== false &&
extraArgs["thinking-display"] !== "omitted";
Expand Down Expand Up @@ -866,9 +876,14 @@ export function makeClaudeQueryOptions(input: {
systemPrompt: {
type: "preset" as const,
preset: "claude_code" as const,
append:
append: [
buildRuntimeInstructions({ harness: "Claude Code" }) +
(input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
(input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS),
input.appendSystemPromptFileText,
launchArgAppendSystemPrompt,
]
.filter((part) => part !== undefined && part !== "")
.join("\n\n"),
},
...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }),
};
Expand Down Expand Up @@ -6779,6 +6794,30 @@ export function makeClaudeAdapterV2(
return existing;
}

// Read before closing the live query, so a bad path leaves it intact.
// A missing file fails the turn, as it fails the CLI, instead of
// starting a session silently missing the text.
const appendSystemPromptFile = parseCliArgs(adapterOptions.settings.launchArgs).flags[
"append-system-prompt-file"
];
const appendSystemPromptFileText =
typeof appendSystemPromptFile === "string"
? yield* fileSystem
.readFileString(
path.resolve(turnInput.runtimePolicy.cwd ?? ".", appendSystemPromptFile),
)
.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapter.ProviderAdapterProtocolError({
driver: CLAUDE_PROVIDER,
detail: `Failed to read --append-system-prompt-file '${appendSystemPromptFile}'`,
payload: cause,
}),
),
)
: undefined;

// openQuery owns one live process. Closing it for another native
// thread kills that sibling's CLI; it can never emit a roster clear,
// so drop its process-scoped Waiting/wake state immediately. Closing
Expand Down Expand Up @@ -6828,6 +6867,7 @@ export function makeClaudeAdapterV2(
canUseTool,
onUserDialog,
supportedDialogKinds: ["resume_return"],
...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }),
}),
})
.pipe(
Expand Down
Loading