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
77 changes: 63 additions & 14 deletions hypaware-core/smoke/flows/claude_attach_detach.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import { requireAiGatewayRuntime } from '../../plugins-workspace/ai-gateway/src/
*
* - `hyp attach --client claude` patches `~/.claude/settings.json`
* with the HypAware marker, `env.ANTHROPIC_BASE_URL`, and the
* managed session-context hook entries (golden compare).
* managed hook entries (golden compare): `session-context` on every
* managed event, plus the LLP 0106 `classify-cwd` hook, which the
* plugin scopes to the two fresh-cwd events.
* - A `client.attach` span exists with `hyp_plugin=@hypaware/claude`,
* `client_name=claude`, `status=ok`, `restored=false`.
* - `hyp detach --client claude` removes the managed keys and the
Expand Down Expand Up @@ -181,23 +183,51 @@ export async function run({ harness, expect }) {
typeof v.state_file === 'string' &&
v.state_file.endsWith('session-context.jsonl')
)
// LLP 0106 settles that attach installs the classification hook *alongside*
// the existing session-context hook, which is what makes a golden compare
// expecting session-context on its own stale.
//
// Which events each kind rides is not 0106's to say and is not stated
// there: `session-context` on every managed event, `classify-cwd` only
// where a *fresh* working directory appears (SessionStart, CwdChanged), is
// decided by `MANAGED_HOOK_SPECS` in the claude plugin's `src/settings.js`,
// with the reasoning in the comment above it. Both sides are asserted here
// so dropping either kind, or leaking `classify-cwd` onto the per-prompt
// and per-tool events, fails the golden compare instead of passing quietly.
// @ref LLP 0106#decision [tests]: attach installs the classification hook beside the session-context hook
expect.that(
'settings: SessionStart hook installed with --state-file pointing at the plugin state dir',
attached?.hooks?.SessionStart,
'settings: SessionStart carries session-context (with --state-file) then classify-cwd',
hookCommands(attached?.hooks?.SessionStart),
(v) =>
Array.isArray(v) &&
v.length === 1 &&
Array.isArray(v[0].hooks) &&
v[0].hooks[0]?.type === 'command' &&
typeof v[0].hooks[0]?.command === 'string' &&
v[0].hooks[0].command.includes('claude-hook session-context') &&
v[0].hooks[0].command.includes('--state-file ') &&
v[0].hooks[0].command.includes('session-context.jsonl')
v.length === 2 &&
v[0].includes('claude-hook session-context') &&
v[0].includes('--state-file ') &&
v[0].includes('session-context.jsonl') &&
v[1].endsWith('claude-hook classify-cwd')
)
expect.that(
'settings: CwdChanged carries the same pair (the other fresh-cwd event)',
hookCommands(attached?.hooks?.CwdChanged),
(v) =>
v.length === 2 &&
v[0].includes('claude-hook session-context') &&
v[0].includes('session-context.jsonl') &&
v[1].endsWith('claude-hook classify-cwd')
)
expect.that(
'settings: PostToolUse hook scoped to Bash matcher',
attached?.hooks?.PostToolUse?.[0]?.matcher,
(v) => v === 'Bash'
'settings: UserPromptSubmit carries session-context only (no re-ask per prompt)',
hookCommands(attached?.hooks?.UserPromptSubmit),
(v) => v.length === 1 && v[0].includes('claude-hook session-context')
)
expect.that(
'settings: PostToolUse carries session-context only, scoped to the Bash matcher',
attached?.hooks?.PostToolUse,
(v) =>
Array.isArray(v) &&
v.length === 1 &&
v[0]?.matcher === 'Bash' &&
hookCommands(v).length === 1 &&
hookCommands(v)[0].includes('claude-hook session-context')
)

// Drive `hyp detach --client claude` through the dispatcher.
Expand Down Expand Up @@ -295,6 +325,25 @@ export async function run({ harness, expect }) {
}
}

/**
* Flatten a `hooks.<event>` block into its command strings, in install order.
* Each element of the block is a `{ matcher?, hooks: [{ type, command }] }`
* group, and attach pushes one group per command kind the event carries.
*
* @param {unknown} groups
* @returns {string[]}
*/
function hookCommands(groups) {
if (!Array.isArray(groups)) return []
return groups.flatMap((group) => {
const handlers = group?.hooks
if (!Array.isArray(handlers)) return []
return handlers
.filter((/** @type {any} */ h) => h?.type === 'command' && typeof h.command === 'string')
.map((/** @type {any} */ h) => /** @type {string} */ (h.command))
})
}

/**
* @param {{ hypHome: string }} harness
*/
Expand Down
Loading
Loading