mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 07:10:12 +02:00
feat(auq): add gstack-session-kind + echo SESSION_KIND in preamble
Classifies the session as spawned | headless | interactive from env markers (OPENCLAW_SESSION / GSTACK_HEADLESS / CONDUCTOR_* / CLAUDE_CODE_ENTRYPOINT / CI), defaulting to interactive. Echoed once at skill start alongside BRANCH/REPO_MODE so the AskUserQuestion-failure fallback can branch without a shell-out at failure time. Degrade-safe: empty/error => interactive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-session-kind — classify the current agent session so skills know whether
|
||||
# a human can answer an interactive prompt (AskUserQuestion).
|
||||
#
|
||||
# Usage: gstack-session-kind → prints one of: spawned | headless | interactive
|
||||
#
|
||||
# Used by the preamble (generate-preamble-bash.ts) which echoes
|
||||
# SESSION_KIND: <value>
|
||||
# so the AskUserQuestion-failure fallback rule can branch without a shell-out at
|
||||
# failure time:
|
||||
# spawned → orchestrator session (OpenClaw). Auto-choose recommended option
|
||||
# per the skill's SPAWNED_SESSION block. Never prose, never BLOCKED.
|
||||
# headless → no human present (claude -p evals / CI). BLOCK on AUQ failure.
|
||||
# interactive → a human is present. Prose-fallback on AUQ failure.
|
||||
#
|
||||
# Detection is best-effort. On ANY ambiguity it prints `interactive` — BLOCK only on
|
||||
# a positive headless signal, since a stray prose message in an unmarked one-shot
|
||||
# `-p` run just ends the turn (harmless), whereas wrongly BLOCKING a real human is not.
|
||||
#
|
||||
# Why env vars and not TTY/entrypoint: an interactive Conductor session reports
|
||||
# CLAUDE_CODE_ENTRYPOINT=sdk-ts with no TTY — identical to a headless SDK eval. The
|
||||
# signals that actually discriminate are the host/orchestrator/CI env markers below.
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Orchestrator-spawned session (OpenClaw). Authoritative block lives in the skill;
|
||||
# we only surface the classification.
|
||||
if [ -n "${OPENCLAW_SESSION:-}" ]; then
|
||||
echo "spawned"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2. Explicit headless override (set by the eval/E2E harness for determinism).
|
||||
if [ -n "${GSTACK_HEADLESS:-}" ]; then
|
||||
echo "headless"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3. Positive interactive-host signals: a human-driven host is present.
|
||||
# - Conductor app sets CONDUCTOR_* workspace vars.
|
||||
# - Plain interactive `claude` CLI sets CLAUDE_CODE_ENTRYPOINT=cli.
|
||||
if [ -n "${CONDUCTOR_WORKSPACE_PATH:-}" ] || [ -n "${CONDUCTOR_PORT:-}" ] || [ "${CLAUDE_CODE_ENTRYPOINT:-}" = "cli" ]; then
|
||||
echo "interactive"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4. CI / automation markers with no interactive host → headless.
|
||||
if [ -n "${CI:-}" ] || [ -n "${GITHUB_ACTIONS:-}" ]; then
|
||||
echo "headless"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 5. No positive headless signal → assume a human is present (degrade-safe default).
|
||||
echo "interactive"
|
||||
@@ -33,6 +33,9 @@ echo "SKILL_PREFIX: $_SKILL_PREFIX"
|
||||
source <(${ctx.paths.binDir}/gstack-repo-mode 2>/dev/null) || true
|
||||
REPO_MODE=\${REPO_MODE:-unknown}
|
||||
echo "REPO_MODE: $REPO_MODE"
|
||||
_SESSION_KIND=$(${ctx.paths.binDir}/gstack-session-kind 2>/dev/null || echo "interactive")
|
||||
case "$_SESSION_KIND" in spawned|headless|interactive) ;; *) _SESSION_KIND="interactive" ;; esac
|
||||
echo "SESSION_KIND: $_SESSION_KIND"
|
||||
_LAKE_SEEN=$([ -f ~/.gstack/.completeness-intro-seen ] && echo "yes" || echo "no")
|
||||
echo "LAKE_INTRO: $_LAKE_SEEN"
|
||||
_TEL=$(${ctx.paths.binDir}/gstack-config get telemetry 2>/dev/null || true)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* gstack-session-kind — classifies the session so skills know whether a human can
|
||||
* answer an AskUserQuestion. Drives the AUQ-failure fallback branch:
|
||||
* spawned → auto-choose (orchestrator)
|
||||
* headless → BLOCK on AUQ failure
|
||||
* interactive → prose fallback on AUQ failure
|
||||
*
|
||||
* These permutations are the contract the resolver rule depends on. Run with a
|
||||
* SCRUBBED env (the test process itself runs inside Conductor, so CONDUCTOR_* /
|
||||
* CLAUDE_CODE_* would leak in and contaminate the classification).
|
||||
*
|
||||
* Free, deterministic, gate-tier.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as path from 'path';
|
||||
|
||||
const BIN = path.resolve(__dirname, '..', 'bin', 'gstack-session-kind');
|
||||
|
||||
/** Run the helper with ONLY the supplied env (plus PATH so bash resolves). */
|
||||
function kind(env: Record<string, string>): string {
|
||||
return execFileSync(BIN, [], {
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', ...env },
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe('gstack-session-kind', () => {
|
||||
test('OPENCLAW_SESSION → spawned (highest precedence)', () => {
|
||||
expect(kind({ OPENCLAW_SESSION: '1' })).toBe('spawned');
|
||||
// spawned wins even when other markers are also present
|
||||
expect(kind({ OPENCLAW_SESSION: '1', GSTACK_HEADLESS: '1', CONDUCTOR_PORT: '5' })).toBe('spawned');
|
||||
});
|
||||
|
||||
test('GSTACK_HEADLESS → headless', () => {
|
||||
expect(kind({ GSTACK_HEADLESS: '1' })).toBe('headless');
|
||||
});
|
||||
|
||||
test('CONDUCTOR_* → interactive (a human host is present)', () => {
|
||||
expect(kind({ CONDUCTOR_WORKSPACE_PATH: '/tmp/ws' })).toBe('interactive');
|
||||
expect(kind({ CONDUCTOR_PORT: '55010' })).toBe('interactive');
|
||||
});
|
||||
|
||||
test('CLAUDE_CODE_ENTRYPOINT=cli → interactive', () => {
|
||||
expect(kind({ CLAUDE_CODE_ENTRYPOINT: 'cli' })).toBe('interactive');
|
||||
});
|
||||
|
||||
test('interactive host beats CI markers', () => {
|
||||
expect(kind({ CONDUCTOR_PORT: '5', CI: '1' })).toBe('interactive');
|
||||
});
|
||||
|
||||
test('CI / GITHUB_ACTIONS with no host → headless', () => {
|
||||
expect(kind({ CI: '1' })).toBe('headless');
|
||||
expect(kind({ GITHUB_ACTIONS: 'true' })).toBe('headless');
|
||||
});
|
||||
|
||||
test('GSTACK_HEADLESS beats CONDUCTOR (explicit override wins)', () => {
|
||||
expect(kind({ GSTACK_HEADLESS: '1', CONDUCTOR_PORT: '5' })).toBe('headless');
|
||||
});
|
||||
|
||||
test('bare env → interactive (degrade-safe default)', () => {
|
||||
expect(kind({})).toBe('interactive');
|
||||
});
|
||||
|
||||
test('empty GSTACK_HEADLESS is treated as unset (interactive)', () => {
|
||||
// The resolver/helper guard on -n, so an empty string must NOT mean headless —
|
||||
// this is the opt-out path harness suites use to exercise the interactive branch.
|
||||
expect(kind({ GSTACK_HEADLESS: '' })).toBe('interactive');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user