Skip to content

Commit bc8ac3d

Browse files
authored
Add live Claude lifecycle conformance tests (#408)
* docs: define live agent test guardrails * test(integration): protect credential-backed diagnostics * test(claude): add live lifecycle conformance gate
1 parent 90bf423 commit bc8ac3d

11 files changed

Lines changed: 942 additions & 17 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,59 @@ jobs:
5353
if-no-files-found: ignore
5454
retention-days: 7
5555

56+
claude-live:
57+
name: Live Claude conformance
58+
runs-on: ubuntu-latest
59+
timeout-minutes: 15
60+
61+
steps:
62+
- name: Check for Anthropic testing key
63+
id: anthropic-key
64+
shell: bash
65+
env:
66+
ANTHROPIC_TESTING_KEY: ${{ secrets.ANTHROPIC_TESTING_KEY }}
67+
run: |
68+
if [ -n "$ANTHROPIC_TESTING_KEY" ]; then
69+
echo "available=true" >> "$GITHUB_OUTPUT"
70+
else
71+
echo "ANTHROPIC_TESTING_KEY is unavailable; skipping live Claude tests."
72+
fi
73+
74+
- name: Check out repository
75+
if: steps.anthropic-key.outputs.available == 'true'
76+
uses: actions/checkout@v4
77+
78+
- name: Set up Bun
79+
if: steps.anthropic-key.outputs.available == 'true'
80+
uses: oven-sh/setup-bun@v2
81+
with:
82+
bun-version: 1.3.14
83+
84+
- name: Install server dependencies
85+
if: steps.anthropic-key.outputs.available == 'true'
86+
working-directory: server
87+
run: bun install --frozen-lockfile
88+
89+
- name: Install integration-test dependencies
90+
if: steps.anthropic-key.outputs.available == 'true'
91+
working-directory: integration-tests
92+
run: bun install --frozen-lockfile
93+
94+
- name: Run live Claude conformance tests
95+
if: steps.anthropic-key.outputs.available == 'true'
96+
env:
97+
ANTHROPIC_TESTING_KEY: ${{ secrets.ANTHROPIC_TESTING_KEY }}
98+
run: bun run test:live:claude
99+
100+
- name: Upload live Claude diagnostics
101+
if: failure() && steps.anthropic-key.outputs.available == 'true'
102+
uses: actions/upload-artifact@v4
103+
with:
104+
name: claude-live-integration-diagnostics
105+
path: integration-tests/artifacts/server/
106+
if-no-files-found: ignore
107+
retention-days: 7
108+
56109
lightpanda-e2e:
57110
name: Lightpanda E2E
58111
runs-on: ubuntu-latest

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,11 @@ After completion of a task, verify:
389389

390390
Integration coverage is mandatory when correctness crosses server, HTTP/WebSocket, persistence, provider, or SPA boundaries. Add black-box server tests under `integration-tests/tests/server` for chat lifecycle, queueing, reconnect, restart semantics (including empty execution state), provider failure, ownership-journal recovery, fork, and deletion behavior. Add Lightpanda tests under `integration-tests/tests/e2e` when the browser workflow itself is part of the contract. Every production regression in these flows must gain an integration test that reproduces it; unit tests remain required for the underlying component behavior.
391391

392+
- Always use Haiku for Claude integration tests and `gpt-5.4-nano` for Codex integration tests.
393+
- Always use the lowest supported reasoning effort in integration tests.
394+
- Keep credential-backed agent suites under `test:live:*`, outside routine test commands.
395+
- Never run live-agent tests locally unless actively changing those tests; rely on the PR CI live-provider gate otherwise.
396+
392397
### Regression Focus Areas
393398

394399
- Chat lifecycle transitions.

integration-tests/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,16 @@ From the repository root:
109109
bun run typecheck
110110
bun run test:integration:server
111111

112+
ANTHROPIC_TESTING_KEY=... bun run test:live:claude
113+
112114
bun run build
113115
LIGHTPANDA_BIN=/path/to/lightpanda bun run test:integration:e2e
114116

115117
bun run check
116118
bun run test
117119
```
118120

119-
`bun run test:integration` runs the server integration lane but not the Lightpanda lane. The root `bun run test` command runs the server and web unit suites, so run the integration commands explicitly while developing cross-boundary changes. The E2E fixture requires a current production build at `web/build/index.html` and an executable `LIGHTPANDA_BIN`. CI pins and verifies the Lightpanda binary in `.github/workflows/integration-tests.yml`.
121+
`bun run test:integration` runs the deterministic server integration lane but not the credential-backed live-provider or Lightpanda lanes. The root `bun run test` command runs the server and web unit suites, so run the integration commands explicitly while developing cross-boundary changes. Credential-backed suites use the separate `test:live` and `test:live:<provider>` commands; do not run them locally unless actively changing those tests, and rely on the PR CI live-provider gate otherwise. The live Claude lane requires `ANTHROPIC_TESTING_KEY`; it uses the pinned test-only Claude CLI, Haiku, low effort, a temporary Claude home without changing the user's CLI login, and redacted failure diagnostics that omit provider content and server logs. The E2E fixture requires a current production build at `web/build/index.html` and an executable `LIGHTPANDA_BIN`. CI pins and verifies the Lightpanda binary in `.github/workflows/integration-tests.yml`.
120122

121123
Focused runs are useful while iterating:
122124

@@ -132,7 +134,9 @@ Keep the configured single-test concurrency. The suites exercise process lifecyc
132134

133135
The deterministic lane exercises `direct-openai-compatible` and `direct-anthropic-compatible` through separate protocol fakes. Both providers are present in every fixture and remain alive across Garcon restarts. The Anthropic fake covers the Messages HTTP and SSE contract; it is not a fake Claude Code process or Claude Agent SDK. Together the fakes prove Garcon's multi-agent routing, lifecycle, queue, transcript, persistence, search, and SPA behavior without spending credentials or depending on external availability. They do not prove the native behavior of Claude, Codex, Pi, OpenCode, Factory, Amp, Cursor, or other provider binaries.
134136

135-
Future credential-backed provider suites should be opt-in, isolated from the deterministic required lane, and explicit about cost, rate limits, cleanup, and supported environments. Add them when test API keys are available; do not weaken or replace fake-provider coverage with live-provider tests.
137+
Credential-backed provider suites remain isolated from the deterministic required lane and must be explicit about cost, rate limits, cleanup, and supported environments. The live Claude lane receives its testing key only in the temporary Garcon process environment. Do not weaken or replace fake-provider coverage with live-provider tests.
138+
139+
The live Claude lane covers correlated activity, consecutive queue dispatch, whole and historical forks, immediate re-forks, native graph isolation, restart/resume, permission decisions, live and reloaded tool results, interruption, stop, and post-cancellation recovery.
136140

137141
## Change Checklist
138142

integration-tests/bun.lock

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integration-tests/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
"test": "bun run test:server",
77
"test:server": "bun test --max-concurrency=1 --timeout=30000 tests/server",
88
"test:e2e": "bun test --max-concurrency=1 --timeout=60000 tests/e2e",
9+
"test:live": "bun run test:live:claude",
10+
"test:live:claude": "bun test --max-concurrency=1 --timeout=120000 tests/live/claude",
911
"typecheck": "bunx tsc -p tsconfig.json"
1012
},
1113
"devDependencies": {
14+
"@anthropic-ai/claude-code": "2.1.220",
1215
"@types/bun": "1.3.14",
1316
"puppeteer-core": "25.3.0",
1417
"typescript": "7.0.2"

integration-tests/support/garcon-client.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
ForkChatResponse,
2020
ForkRunCommandRequest,
2121
ForkRunCommandResponse,
22+
PermissionDecisionCommandRequest,
2223
QueueEntryCommandResponse,
2324
QueueEntryCreateCommandRequest,
2425
QueueEntryDeleteCommandRequest,
@@ -162,10 +163,24 @@ interface GarconWebSocket {
162163

163164
export interface GarconTestClientOptions {
164165
createWebSocket?: (url: string) => GarconWebSocket;
166+
redactSensitiveDiagnostics?: boolean;
165167
}
166168

167169
const WEB_SOCKET_OPEN = 1;
168170
const WEB_SOCKET_CLOSED = 3;
171+
const SAFE_DIAGNOSTIC_STRING_KEYS = new Set([
172+
'agentid',
173+
'code',
174+
'method',
175+
'model',
176+
'permissionmode',
177+
'requesttype',
178+
'status',
179+
'stopintent',
180+
'thinkingmode',
181+
'timestamp',
182+
'type',
183+
]);
169184

170185
export class GarconApiError extends Error {
171186
constructor(
@@ -186,8 +201,20 @@ export class GarconWsRequestError extends Error {
186201
}
187202
}
188203

189-
function redact(value: unknown): unknown {
190-
if (Array.isArray(value)) return value.map(redact);
204+
function redact(
205+
value: unknown,
206+
redactSensitiveDiagnostics = false,
207+
parentKey?: string,
208+
): unknown {
209+
if (Array.isArray(value)) {
210+
return value.map((item) => redact(item, redactSensitiveDiagnostics, parentKey));
211+
}
212+
if (redactSensitiveDiagnostics && typeof value === 'string') {
213+
const normalized = parentKey?.toLowerCase() ?? '';
214+
return SAFE_DIAGNOSTIC_STRING_KEYS.has(normalized) || normalized.endsWith('id')
215+
? value
216+
: '[REDACTED]';
217+
}
191218
if (!isRecord(value)) return value;
192219
return Object.fromEntries(Object.entries(value).map(([key, item]) => {
193220
const normalized = key.toLowerCase();
@@ -199,7 +226,7 @@ function redact(value: unknown): unknown {
199226
) {
200227
return [key, '[REDACTED]'];
201228
}
202-
return [key, redact(item)];
229+
return [key, redact(item, redactSensitiveDiagnostics, key)];
203230
}));
204231
}
205232

@@ -234,6 +261,7 @@ async function responseBody(response: Response): Promise<unknown> {
234261
export class GarconTestClient {
235262
readonly #baseUrl: string;
236263
readonly #createWebSocket: (url: string) => GarconWebSocket;
264+
readonly #redactSensitiveDiagnostics: boolean;
237265
readonly #exchanges: HttpExchange[] = [];
238266
readonly #eventRecords: EventRecord[] = [];
239267
readonly #waiters = new Set<EventWaiter>();
@@ -244,6 +272,7 @@ export class GarconTestClient {
244272
private constructor(baseUrl: string, options: GarconTestClientOptions) {
245273
this.#baseUrl = baseUrl.replace(/\/$/, '');
246274
this.#createWebSocket = options.createWebSocket ?? ((url) => new WebSocket(url));
275+
this.#redactSensitiveDiagnostics = options.redactSensitiveDiagnostics === true;
247276
}
248277

249278
static async connect(baseUrl: string, options: GarconTestClientOptions = {}): Promise<GarconTestClient> {
@@ -273,7 +302,12 @@ export class GarconTestClient {
273302
}
274303

275304
eventRecords(): readonly EventRecord[] {
276-
return this.#eventRecords.slice();
305+
if (!this.#redactSensitiveDiagnostics) return this.#eventRecords.slice();
306+
return this.#eventRecords.map((record) => ({
307+
raw: redact(record.raw, true),
308+
parsed: redact(record.parsed, true) as ServerWsMessage,
309+
receivedAt: record.receivedAt,
310+
}));
277311
}
278312

279313
exchanges(): readonly HttpExchange[] {
@@ -503,6 +537,15 @@ export class GarconTestClient {
503537
return this.post<ForkRunCommandResponse>('/api/v1/chats/fork-run', request);
504538
}
505539

540+
sendPermissionDecision(
541+
request: PermissionDecisionCommandRequest,
542+
): Promise<CommandAcceptedResponse> {
543+
return this.post<CommandAcceptedResponse>(
544+
'/api/v1/chats/permissions/decision',
545+
request,
546+
);
547+
}
548+
506549
switchAgentModel(request: AgentModelPatchRequest): Promise<AgentModelPatchResponse> {
507550
return this.patch<AgentModelPatchResponse>('/api/v1/chats/agent-model', request);
508551
}
@@ -798,11 +841,11 @@ export class GarconTestClient {
798841
}
799842

800843
describeEvents(): string {
801-
return JSON.stringify(this.#eventRecords.map((record, index) => ({
844+
return JSON.stringify(redact(this.#eventRecords.map((record, index) => ({
802845
index,
803846
receivedAt: record.receivedAt,
804847
event: record.parsed,
805-
})), null, 2);
848+
})), this.#redactSensitiveDiagnostics), null, 2);
806849
}
807850

808851
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
@@ -816,8 +859,10 @@ export class GarconTestClient {
816859
method,
817860
path,
818861
status: response.status,
819-
...(body === undefined ? {} : { requestBody: redact(body) }),
820-
responseBody: redact(parsed),
862+
...(body === undefined
863+
? {}
864+
: { requestBody: redact(body, this.#redactSensitiveDiagnostics) }),
865+
responseBody: redact(parsed, this.#redactSensitiveDiagnostics),
821866
});
822867
if (!response.ok) throw new GarconApiError(response.status, parsed, method, path);
823868
return parsed as T;

0 commit comments

Comments
 (0)