mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
* refactor(native-chat): make structured chat the only Chat UI runtime Retire the experimentalStructuredNativeChat opt-in. Chat UI on is now the one gate for structured chat: new agent tabs that default to Chat UI start a structured session wherever the launch is supported, and unsupported launches keep falling back to terminal-backed chat. - Shared isNativeChatEnabled predicate replaces prefersStructuredNativeChatByDefault; host admission, renderer routing, orchestration mode, startup projection, tab sync and the resume offer all read it. - The retired key is stripped on load and on save, dropped from the settings type, defaults, the published client-settings projection and telemetry. - Settings no longer offer the structured toggle; resume-after-restart and shell-environment rows show under the Chat UI default view. - Startup structured-tab projection moves beside its sync hook so both gates read the same predicate. * docs(native-chat): describe structured chat as the Chat UI runtime New local Claude and Codex chats no longer sit on a PTY, so the page stops saying the terminal is always the source of truth and scopes the chat/terminal toggle to terminal-backed chats. * fix(native-chat): stop promising a Windows fallback in the Chat UI scope note A host refusal after the route picks structured chat still ends as a failed launch. * fix(native-chat): keep open structured chats reachable when Chat UI is off Turning Chat UI off hid open Claude/Codex chat tabs while their provider processes kept running, because the host and the desktop mirror gated serving existing sessions on the same setting that decides how new launches open. The host now asks two separate questions. Creating a new structured session (agentSession.create, createSupport, ensure) needs the negotiated capability plus the Chat UI setting. Serving a session that already exists (listing, reading, sending, resuming, closing, and the mobile projection and restore) needs the capability only. The desktop mirror, startup restore and resume-on-restart offer no longer read the setting, and the toggle-off teardown and the mobile re-publish on setting change are removed as dead. * fix(native-chat): run the local structured tab sync only where chats may exist The previous commit started the every-worktree session-tab census and subscription, the startup restore, and the resume-offer host read for every desktop user. Gate them on a derived fact instead: Chat UI is on, or the local host holds at least one structured session record. With Chat UI off nothing can create a session, so the answer can only go from yes to no. The host answers from its installed record store, or from whether the store file exists when the host is not installed, so a machine that never had a structured chat pays one existence check and never starts the host. Chat UI turning on starts the sync; turning it off never stops it. * fix(native-chat): answer an empty restart offer without starting the chat host With every Chat UI user now on structured chat, the launch-time resume offer read built the host even on machines with no stored chat, which on Linux scans every process environment. No store on disk means no offer, so the listing answers empty without installing the host. * fix(native-chat): release the structured tab sync when it never subscribed A failed capability probe reads as unsupported, so the sync returned without subscribing while still holding the one-sync slot. Nothing could start another, and chat tabs stopped live-updating until relaunch. The start now reports whether it subscribed, and a sync that did not releases the slot so the next Chat UI change retries. * fix(native-chat): let the host answer the restart offer whatever Chat UI says The renderer asked main whether a structured store existed before reading the restart offer, so it would not start the chat host for users without one. The host now answers an empty offer without starting itself, which made that probe an extra round trip. In paired web it was also wrong: the probe is hard-coded to no and the gate read the browser's own Chat UI setting, so a paired host's chats were never offered back once that was off. * refactor(native-chat): drop the structured tab sync generation fence The fence existed so a Chat UI toggle-off could retire in-flight inventory replies before it cleared the mirror. That teardown is gone: turning Chat UI off keeps open chats, and only a test reset still bumped the generation, so every production check compared equal. The sync's own disposal still fences its subscription, and the startup restore latch and publication cursors stay. * fix(native-chat): retry the structured tab sync when the capability probe fails A failed runtime status probe maps to an empty capability list, which the sync read as "unsupported" and gave up on. The release only let a later Chat UI change try again, so one transient startup failure left chat tabs without live updates for the whole session. The sync now tells an unknown answer apart from a host that answered without the surface, re-asks unknown answers on its existing subscribe backoff, and stops only on a definite no. * refactor(native-chat): begin single-switch structured launch routing * Make Chat UI the single structured launch default * Remove retired chat settings copy from localization catalogs * Keep locale checks aligned with the single Chat UI switch * Keep explicit chats accessible and older client defaults terminal * Align newly landed Settings tests with the Chat UI switch * Expect terminal fallback on older paired structured hosts * Correct terminal fallback launch expectation * Update tests that still expected the retired chat settings * Keep chat composer options off terminal launches and restore route coverage Chat composer model and effort no longer become CLI flags on terminal launches, since a new launch can no longer open in the terminal chat view; structured launches seed their own options. Restores dropped route coverage, including that an explicit resume-in-new-chat request stays available while Chat UI is off. * Replace the stale remembered-options test with terminal fallback coverage The test expected chat composer model and effort on a codex launch with Chat UI on, but its mocked host has no structured capability, so that launch is the terminal fallback that must not carry them. It now checks the fallback with Chat UI on and off, with shipped and stored arguments; structured creates are covered where the seed is applied. * Lower the terminal pane listener budget to the measured 15 The explicit-chat change retired the per-pane Chat UI setting read, so each mounted pane opens one fewer store listener. Pin the budget to the measured count and count that read among the saved listeners.
593 lines
18 KiB
TypeScript
593 lines
18 KiB
TypeScript
// Schema round-trip coverage for the event map. Fail-closed invariants that
|
|
// must hold: agent_error is enum-only (error_message / error_stack rejected
|
|
// by `.strict()`), unknown enum values fail, and any well-formed payload
|
|
// round-trips without coercion.
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { errorClassSchema, eventSchemas, isCohortExtendedEvent } from './telemetry-events'
|
|
|
|
describe('feature_interaction_usage_bucket_reached schema', () => {
|
|
it('accepts a valid bucket payload', () => {
|
|
const parsed = eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
feature_id: 'browser-tab-created',
|
|
feature_category: 'browser',
|
|
count_bucket: 'count_3_4',
|
|
bucket_source: 'crossed_now',
|
|
nth_repo_added: 2
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('is in the runtime cohort-injection roster', () => {
|
|
expect(isCohortExtendedEvent('feature_interaction_usage_bucket_reached')).toBe(true)
|
|
})
|
|
|
|
it('rejects unknown enum values and mismatched categories', () => {
|
|
const valid = {
|
|
feature_id: 'github-tasks',
|
|
feature_category: 'task_management',
|
|
count_bucket: 'count_1',
|
|
bucket_source: 'observed_existing'
|
|
}
|
|
expect(
|
|
eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
...valid,
|
|
feature_id: 'unknown-feature'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
...valid,
|
|
feature_category: 'browser'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
...valid,
|
|
count_bucket: 'count_4'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
...valid,
|
|
bucket_source: 'renderer'
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('rejects raw privacy fields via .strict()', () => {
|
|
const rawFields = [
|
|
'prompt',
|
|
'command',
|
|
'path',
|
|
'repo',
|
|
'branch',
|
|
'url',
|
|
'hostname',
|
|
'error',
|
|
'text',
|
|
'query',
|
|
'result_label',
|
|
'workspace_name',
|
|
'setting_name',
|
|
'target_id',
|
|
'annotation_text',
|
|
'dom_snippet',
|
|
'screenshot',
|
|
'page_title',
|
|
'trusted_directory',
|
|
'trigger_x',
|
|
'trigger_y',
|
|
'focus_state',
|
|
'minimize_state',
|
|
'audio',
|
|
'transcript',
|
|
'model',
|
|
'device',
|
|
'error_detail'
|
|
]
|
|
for (const field of rawFields) {
|
|
const parsed = eventSchemas.feature_interaction_usage_bucket_reached.safeParse({
|
|
feature_id: 'browser-annotations-sent-to-agent',
|
|
feature_category: 'browser',
|
|
count_bucket: 'count_1',
|
|
bucket_source: 'crossed_now',
|
|
[field]: 'raw'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('app_starred_orca schema', () => {
|
|
it('accepts cohort context on successful app star telemetry', () => {
|
|
const parsed = eventSchemas.app_starred_orca.safeParse({
|
|
source: 'settings',
|
|
nth_repo_added: 2
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('rejects unknown app star source values', () => {
|
|
const parsed = eventSchemas.app_starred_orca.safeParse({
|
|
source: 'github_website'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects extra keys via .strict()', () => {
|
|
const parsed = eventSchemas.app_starred_orca.safeParse({
|
|
source: 'landing',
|
|
repo: 'stablyai/orca'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('star_nag_outcome schema', () => {
|
|
const valid = {
|
|
outcome: 'shown',
|
|
source: 'threshold',
|
|
mode: 'gh',
|
|
threshold: 35,
|
|
agents_since_baseline: 35,
|
|
agents_since_baseline_bucket: '35-69',
|
|
nth_repo_added: 2
|
|
}
|
|
|
|
it('accepts a strict valid payload with cohort context', () => {
|
|
expect(eventSchemas.star_nag_outcome.safeParse(valid).success).toBe(true)
|
|
})
|
|
|
|
it('is in the runtime cohort-injection roster', () => {
|
|
expect(isCohortExtendedEvent('star_nag_outcome')).toBe(true)
|
|
})
|
|
|
|
it('accepts next_threshold only as a positive integer for deferrals', () => {
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
outcome: 'dismissed',
|
|
next_threshold: 70
|
|
}).success
|
|
).toBe(true)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
outcome: 'later',
|
|
next_threshold: 70
|
|
}).success
|
|
).toBe(true)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 0 }).success).toBe(
|
|
false
|
|
)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, next_threshold: 1.5 }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'shown', next_threshold: 70 })
|
|
.success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('accepts cooldown_days only for deferrals', () => {
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
outcome: 'later',
|
|
cooldown_days: 30
|
|
}).success
|
|
).toBe(true)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
outcome: 'dismissed',
|
|
cooldown_days: 30
|
|
}).success
|
|
).toBe(true)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, cooldown_days: 0 }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
outcome: 'opened_repo',
|
|
cooldown_days: 30
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('accepts new star nag action outcomes', () => {
|
|
for (const outcome of [
|
|
'star_clicked',
|
|
'direct_star_succeeded',
|
|
'direct_star_failed',
|
|
'opened_repo',
|
|
'later'
|
|
]) {
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome }).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects unknown outcome source mode and bucket values', () => {
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, outcome: 'ignored' }).success).toBe(
|
|
false
|
|
)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, source: 'renderer' }).success).toBe(
|
|
false
|
|
)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, mode: 'desktop' }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({
|
|
...valid,
|
|
agents_since_baseline_bucket: '35+'
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('rejects malformed numeric fields and raw extra fields', () => {
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: -1 }).success).toBe(false)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, threshold: 1.5 }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({ ...valid, agents_since_baseline: -1 }).success
|
|
).toBe(false)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, nth_repo_added: -1 }).success).toBe(
|
|
false
|
|
)
|
|
expect(eventSchemas.star_nag_outcome.safeParse({ ...valid, error: 'gh failed' }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.star_nag_outcome.safeParse({ ...valid, url: 'https://github.com' }).success
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('agent_error schema', () => {
|
|
it('round-trips a minimal {error_class, agent_kind} payload', () => {
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'unknown',
|
|
agent_kind: 'claude-code'
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
// Core invariant: `.strict()` rejects raw error strings. If this test ever
|
|
// flips, the analytics lane is leaking UGC — revert the offending schema
|
|
// change.
|
|
it('rejects error_message via .strict()', () => {
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'unknown',
|
|
agent_kind: 'claude-code',
|
|
error_message: 'boom at /Users/alice/secret/path'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects error_stack via .strict()', () => {
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'unknown',
|
|
agent_kind: 'claude-code',
|
|
error_stack: 'Error: boom\n at /Users/alice/...'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects error_name (deferred — schema is enum-only)', () => {
|
|
// `error_name` was part of an earlier draft. The trimmed schema is
|
|
// enum-only; if a future PR re-introduces it as additive-optional,
|
|
// this test should be replaced rather than relaxed silently.
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'unknown',
|
|
agent_kind: 'claude-code',
|
|
error_name: 'BinaryNotFound'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects unknown error_class enum values', () => {
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'made_up_class',
|
|
agent_kind: 'claude-code'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects unknown agent_kind enum values', () => {
|
|
const parsed = eventSchemas.agent_error.safeParse({
|
|
error_class: 'unknown',
|
|
agent_kind: 'made_up_agent'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('daemon_lifecycle schema', () => {
|
|
it('round-trips a startup replace payload', () => {
|
|
const parsed = eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'replaced',
|
|
reason: 'stale_bundle',
|
|
live_session_count_bucket: '0'
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('round-trips a retirement payload', () => {
|
|
const parsed = eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'retired',
|
|
reason: 'died_respawn',
|
|
live_session_count_bucket: 'unknown'
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
// Core privacy invariant: enum-only + bucketed counts. If this flips, the lane is leaking
|
|
// paths/versions/exact counts — revert the offending schema change (STA-2376).
|
|
// Both union members, so neither can lose .strict() unnoticed.
|
|
it('rejects raw paths, versions, and unbucketed counts via .strict()', () => {
|
|
const bases = [
|
|
{ transition: 'replaced', reason: 'failed_health_check', live_session_count_bucket: '2-5' },
|
|
{ transition: 'retired', reason: 'died_respawn', live_session_count_bucket: 'unknown' }
|
|
]
|
|
for (const base of bases) {
|
|
for (const leak of [
|
|
{ daemon_path: '/Users/alice/Orca.app' },
|
|
{ daemon_app_version: '1.4.129' },
|
|
{ live_session_count: 3 }
|
|
]) {
|
|
const parsed = eventSchemas.daemon_lifecycle.safeParse({ ...base, ...leak })
|
|
expect(parsed.success).toBe(false)
|
|
}
|
|
// Sanity: the base itself must be valid, so the rejections above are the leak, not the base.
|
|
expect(eventSchemas.daemon_lifecycle.safeParse(base).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects unknown reason and bucket enum values', () => {
|
|
expect(
|
|
eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'replaced',
|
|
reason: 'made_up_reason',
|
|
live_session_count_bucket: '0'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'replaced',
|
|
reason: 'stale_bundle',
|
|
live_session_count_bucket: '99'
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
|
|
it('rejects reasons and fields that do not belong to the transition', () => {
|
|
expect(
|
|
eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'replaced',
|
|
reason: 'died_respawn',
|
|
live_session_count_bucket: 'unknown'
|
|
}).success
|
|
).toBe(false)
|
|
expect(
|
|
eventSchemas.daemon_lifecycle.safeParse({
|
|
transition: 'retired',
|
|
reason: 'failed_health_check',
|
|
live_session_count_bucket: 'unknown'
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('workspace_created schema', () => {
|
|
it('rejects unknown source', () => {
|
|
const parsed = eventSchemas.workspace_created.safeParse({
|
|
source: 'carrier_pigeon',
|
|
from_existing_branch: false
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('accepts a valid payload', () => {
|
|
const parsed = eventSchemas.workspace_created.safeParse({
|
|
source: 'command_palette',
|
|
from_existing_branch: true
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('rejects extra keys via .strict()', () => {
|
|
const parsed = eventSchemas.workspace_created.safeParse({
|
|
source: 'command_palette',
|
|
from_existing_branch: true,
|
|
branch: 'refs/heads/main' // raw branch name is UGC — rejected by .strict()
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('agent_started schema', () => {
|
|
it('requires all three keys', () => {
|
|
const parsed = eventSchemas.agent_started.safeParse({
|
|
agent_kind: 'claude-code',
|
|
launch_source: 'sidebar'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('agent_prompt_sent schema', () => {
|
|
it('accepts a hook-confirmed prompt-send payload with cohort context', () => {
|
|
const parsed = eventSchemas.agent_prompt_sent.safeParse({
|
|
agent_kind: 'codex',
|
|
launch_source: 'unknown',
|
|
request_kind: 'followup',
|
|
nth_repo_added: 1
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('rejects prompt text via .strict()', () => {
|
|
const parsed = eventSchemas.agent_prompt_sent.safeParse({
|
|
agent_kind: 'claude-code',
|
|
launch_source: 'unknown',
|
|
request_kind: 'followup',
|
|
prompt: 'please inspect /Users/alice/private-repo'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('agent_hook_unattributed schema', () => {
|
|
it('accepts the two bounded attribution failure reasons', () => {
|
|
for (const reason of ['empty_pane_key', 'unknown_tab_id'] as const) {
|
|
expect(eventSchemas.agent_hook_unattributed.safeParse({ reason }).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects extra payload fields via .strict()', () => {
|
|
const parsed = eventSchemas.agent_hook_unattributed.safeParse({
|
|
reason: 'unknown_tab_id',
|
|
pane_key: 'tab-secret:1'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('add_repo_setup_step_action schema', () => {
|
|
it('rejects unknown action enum values', () => {
|
|
const parsed = eventSchemas.add_repo_setup_step_action.safeParse({
|
|
action: 'export_to_pdf'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects extra keys via .strict()', () => {
|
|
const parsed = eventSchemas.add_repo_setup_step_action.safeParse({
|
|
action: 'skip',
|
|
repo_name: 'orca' // raw repo names are UGC — must not cross the wire
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('add_repo_default_checkout_handoff schema', () => {
|
|
it('accepts bounded handoff outcome enums', () => {
|
|
const parsed = eventSchemas.add_repo_default_checkout_handoff.safeParse({
|
|
source: 'clone_url',
|
|
result: 'opened_default_checkout',
|
|
reason: 'detected_default_checkout',
|
|
nth_repo_added: 1
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('rejects raw repo/path context via .strict()', () => {
|
|
const parsed = eventSchemas.add_repo_default_checkout_handoff.safeParse({
|
|
source: 'local_folder_picker',
|
|
result: 'revealed_project',
|
|
reason: 'no_default_checkout',
|
|
repo_name: 'secret-repo',
|
|
path: '/Users/alice/secret-repo'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('workspace_create_failed schema', () => {
|
|
it('accepts a valid payload', () => {
|
|
const parsed = eventSchemas.workspace_create_failed.safeParse({
|
|
source: 'sidebar',
|
|
error_class: 'git_failed'
|
|
})
|
|
expect(parsed.success).toBe(true)
|
|
})
|
|
|
|
it('rejects unknown error_class values', () => {
|
|
const parsed = eventSchemas.workspace_create_failed.safeParse({
|
|
source: 'sidebar',
|
|
error_class: 'cosmic_ray'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
// Core invariant mirroring agent_error: raw error strings never cross the
|
|
// wire. If this test ever flips, the failure-rate lane is leaking UGC —
|
|
// revert the offending schema change.
|
|
it('rejects error_message via .strict()', () => {
|
|
const parsed = eventSchemas.workspace_create_failed.safeParse({
|
|
source: 'sidebar',
|
|
error_class: 'git_failed',
|
|
error_message: 'fatal: cannot create work tree at /Users/alice/secret'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
|
|
it('rejects error_stack via .strict()', () => {
|
|
const parsed = eventSchemas.workspace_create_failed.safeParse({
|
|
source: 'sidebar',
|
|
error_class: 'git_failed',
|
|
error_stack: 'Error: cannot create work tree\n at /Users/alice/...'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('settings_changed schema', () => {
|
|
it('accepts the Chat UI switch as a boolean adoption signal', () => {
|
|
expect(
|
|
eventSchemas.settings_changed.safeParse({
|
|
setting_key: 'experimentalNativeChat',
|
|
value_kind: 'bool'
|
|
}).success
|
|
).toBe(true)
|
|
})
|
|
|
|
it.each(['experimentalStructuredNativeChat', 'openAgentTabsInChatByDefault'])(
|
|
'rejects the retired %s key',
|
|
(settingKey) => {
|
|
expect(
|
|
eventSchemas.settings_changed.safeParse({ setting_key: settingKey, value_kind: 'bool' })
|
|
.success
|
|
).toBe(false)
|
|
}
|
|
)
|
|
|
|
it('rejects non-whitelisted setting keys', () => {
|
|
const parsed = eventSchemas.settings_changed.safeParse({
|
|
setting_key: 'telemetryOptIn', // deliberately excluded from the whitelist
|
|
value_kind: 'bool'
|
|
})
|
|
expect(parsed.success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('exported enum schemas', () => {
|
|
it('errorClassSchema rejects novel classes', () => {
|
|
expect(errorClassSchema.safeParse('kernel_panic').success).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('remote_outbound_budget_close schema', () => {
|
|
it('round-trips every emitter', () => {
|
|
for (const emitter of ['size', 'queue']) {
|
|
expect(eventSchemas.remote_outbound_budget_close.safeParse({ emitter }).success).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('rejects an unknown emitter and any payload-describing extra key', () => {
|
|
expect(eventSchemas.remote_outbound_budget_close.safeParse({ emitter: 'other' }).success).toBe(
|
|
false
|
|
)
|
|
expect(
|
|
eventSchemas.remote_outbound_budget_close.safeParse({
|
|
emitter: 'size',
|
|
byte_length: 4194305
|
|
}).success
|
|
).toBe(false)
|
|
})
|
|
})
|