Skip to content
Merged
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
80 changes: 76 additions & 4 deletions packages/extension/scores/overture/SCORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ stages:
prompt: "Want me to show you the full workflow end-to-end? (requires Julia)"
choices: ["Yes, show me", "Skip the demo"]
default: "Yes, show me"
- id: environment
questions:
- id: environment
prompt: "How will pulses eventually reach hardware — what are we patching into?"
choices:
[
"QICK lab (on-prem control code)",
"Cloud system with emulator (e.g. Pasqal)",
"Simulation only for now",
"Something else",
]
default: "Simulation only for now"
- id: devices
optional: true
questions:
- id: devices
prompt: "Any specific device(s) you want me to remember? (name, platform, qubit count — or skip)"
default: "skip for now"
- id: goals
questions:
- id: goals
prompt: "What are you hoping to accomplish with Amico?"
kind: text
- id: handoff
questions:
- id: handoff
prompt: "Ready to get started?"
choices: ["Walk me through designing a pulse", "Open a normal session", "Show me around first"]
default: "Walk me through designing a pulse"
---

You are running the **overture** — Amico's onboarding interview (session zero).
Expand Down Expand Up @@ -164,7 +193,50 @@ Per-stage guidance and the `amicode_profile` mapping:
- The demo MUST NOT create vault artifacts (no problem card, no pulse bank entry).
- If `isDemoCompleted()` is true (archive marker exists), skip — don't re-offer.

After the demo (or skipping), advance to the next stage. **Stages 5–8 are
defined in subsequent slices** — for now, after Stage 4 completes, record
the completion marker: `amicode_profile {entity:"onboarding_completed"}`
and hand off to a normal session.
After the demo (or skipping), advance to Stage 5.

5. **environment** — ask how pulses will reach hardware. **Pre-fill from
seeds:** call `amicode_profile {entity:"status"}` and check if an
environment is already recorded from the context-seed (Stage 3). If so,
present it as a confirmation: "I found you use {archetype} — confirm, or
change?" via the `question` tool. If no seed, ask the standard choice
question with the options above.

Record: `amicode_profile {entity:"environment", payload:{slug, archetype}}`.
Follow up on details per archetype if confirmed (QICK: tProc version,
repo pointer; cloud-Pasqal: which provider, emulator access; etc.).

6. **devices** _(optional)_ — same pre-fill pattern: if a device was seeded,
confirm it. Otherwise ask: "Any specific device(s) you want me to remember?"
This stage is ALWAYS skippable — "none" or "skip" is a valid answer.

Record: `amicode_profile {entity:"device", payload:{name, platform, qubits}}`.
If skipped, move on without recording.

7. **goals** — free-text question via `question` tool with `kind: "text"`:
"What are you hoping to accomplish with Amico?" No pre-fill (goals are
personal, not inferrable from configs).

Record: `amicode_profile {entity:"profile", payload:{goals:"..."}}`.

8. **handoff** — the terminal stage. FIRST, record the completion marker:
`amicode_profile {entity:"onboarding_completed"}` (exactly once — this is
what lets Amico remember them next time and triggers the distiller to
materialize the vault).

Then route by the user's intent selections (from Stage 2 — read from the
events stream, do NOT re-ask):

- **Research** selected (alone or combined) → "Let's design your first
pulse" → continue straight into the **pulse-designer interview** in this
same session. Use everything learned (platform, environment, device) to
skip pulse-design questions already answered.
- **Research + General coding** → same as above, but acknowledge: "I'm also
your general coding companion — you can switch modes any time."
- **General coding only** (no Research) → open a normal session: "You're all
set — I'll remember your context across sessions. Ask me anything."
Highlight memory + vault features briefly.
- **Exploring only** → "Welcome aboard — want a quick tour of what I can do,
or just dive in?" Offer a brief orientation tour.

The handoff does NOT re-ask intent — it reads what was recorded and routes.
88 changes: 88 additions & 0 deletions packages/extension/src/handoff_routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Handoff routing — Stage 8 intent-based routing (#438)
//
// Pure routing logic for the onboarding handoff: given the user's intent
// selections from Stage 2, determines the correct next experience.

// ─── Types ───────────────────────────────────────────────────────────────────

export type IntentSlug = "research" | "general_coding" | "exploring";

export type HandoffAction =
| "pulse-designer" // Research selected → guided pulse-designer interview
| "normal-session" // General coding only → open session, highlight memory + vault
| "tour-session" // Exploring only → open session with brief tour offer
| "pulse-designer-plus"; // Research + General coding → pulse-designer with broader studio mention

// ─── Routing table (pure, testable) ──────────────────────────────────────────

/** Given the user's intent selections, determine the handoff action.
* Research takes priority when combined with other selections. */
export function resolveHandoffAction(intents: IntentSlug[]): HandoffAction {
const hasResearch = intents.includes("research");
const hasGeneral = intents.includes("general_coding");
const hasExploring = intents.includes("exploring");

// Research always routes to pulse-designer (possibly with broader mention)
if (hasResearch && hasGeneral) return "pulse-designer-plus";
if (hasResearch && hasExploring) return "pulse-designer";
if (hasResearch) return "pulse-designer";

// General coding only
if (hasGeneral && hasExploring) return "normal-session";
if (hasGeneral) return "normal-session";

// Exploring only
if (hasExploring) return "tour-session";

// Fallback (empty or unknown) — safe default
return "normal-session";
}

// ─── Pre-fill resolution ─────────────────────────────────────────────────────

export interface SeedState {
environment?: { slug?: string; archetype?: string };
device?: { name?: string; platform?: string };
}

export interface PreFillResult {
/** If non-null, show confirmation prompt with this value. If null, ask fresh. */
environmentSeed: string | null;
/** If non-null, show confirmation prompt. If null, ask fresh or skip. */
deviceSeed: string | null;
}

/** Given the current onboarding state (from readOnboardingState), determine
* what pre-fill values to offer for Stages 5-6. */
export function resolvePreFills(state: SeedState): PreFillResult {
let environmentSeed: string | null = null;
let deviceSeed: string | null = null;

if (state.environment?.archetype || state.environment?.slug) {
environmentSeed = state.environment.archetype ?? state.environment.slug ?? null;
}

if (state.device?.name) {
deviceSeed = state.device.name;
if (state.device.platform) {
deviceSeed += ` (${state.device.platform})`;
}
}

return { environmentSeed, deviceSeed };
}

// ─── Intent reading from events stream ───────────────────────────────────────

/** Read the intent selections from the onboarding state's profile entity.
* Returns the intent array or an empty array if not yet recorded. */
export function readIntentFromState(
profileState: Record<string, unknown> | undefined,
): IntentSlug[] {
if (!profileState) return [];
const intent = profileState.intent;
if (!Array.isArray(intent)) return [];
return intent.filter((i): i is IntentSlug =>
typeof i === "string" && ["research", "general_coding", "exploring"].includes(i),
);
}
123 changes: 123 additions & 0 deletions packages/extension/test/handoff_routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Handoff routing tests — Stage 8 (#438)
//
// Table-driven tests for the intent-based handoff routing, pre-fill resolution,
// and intent reading from the events stream.

import { describe, it, expect } from "vitest";

import {
resolveHandoffAction,
resolvePreFills,
readIntentFromState,
type IntentSlug,
type HandoffAction,
type SeedState,
} from "../src/handoff_routing";

// ─── AC6: Handoff routing table ─────────────────────────────────────────────

describe("resolveHandoffAction — intent-based routing (AC6)", () => {
const cases: Array<{ intents: IntentSlug[]; expected: HandoffAction; name: string }> = [
{ intents: ["research"], expected: "pulse-designer", name: "research only → pulse-designer" },
{ intents: ["general_coding"], expected: "normal-session", name: "general coding only → normal-session" },
{ intents: ["exploring"], expected: "tour-session", name: "exploring only → tour-session" },
{ intents: ["research", "general_coding"], expected: "pulse-designer-plus", name: "research + general → pulse-designer-plus" },
{ intents: ["research", "exploring"], expected: "pulse-designer", name: "research + exploring → pulse-designer" },
{ intents: ["general_coding", "exploring"], expected: "normal-session", name: "general + exploring → normal-session" },
{ intents: ["research", "general_coding", "exploring"], expected: "pulse-designer-plus", name: "all three → pulse-designer-plus" },
];

for (const { intents, expected, name } of cases) {
it(name, () => {
expect(resolveHandoffAction(intents)).toBe(expected);
});
}

it("empty intents → normal-session (safe default)", () => {
expect(resolveHandoffAction([])).toBe("normal-session");
});
});

// ─── AC1-2: Pre-fill resolution ─────────────────────────────────────────────

describe("resolvePreFills — seed-based confirmation prompts (AC1, AC2)", () => {
it("returns null seeds when no state exists", () => {
const result = resolvePreFills({});
expect(result.environmentSeed).toBeNull();
expect(result.deviceSeed).toBeNull();
});

it("AC1: returns environment archetype as seed when available", () => {
const state: SeedState = {
environment: { slug: "stanford-qick-lab", archetype: "qick-lab" },
};
const result = resolvePreFills(state);
expect(result.environmentSeed).toBe("qick-lab");
});

it("AC1: falls back to slug when archetype is missing", () => {
const state: SeedState = {
environment: { slug: "my-lab" },
};
const result = resolvePreFills(state);
expect(result.environmentSeed).toBe("my-lab");
});

it("AC2: returns device name + platform as seed", () => {
const state: SeedState = {
device: { name: "Emerald-Q3", platform: "transmon" },
};
const result = resolvePreFills(state);
expect(result.deviceSeed).toBe("Emerald-Q3 (transmon)");
});

it("AC2: device name only (no platform) works", () => {
const state: SeedState = {
device: { name: "MyDevice" },
};
const result = resolvePreFills(state);
expect(result.deviceSeed).toBe("MyDevice");
});
});

// ─── Intent reading from state ───────────────────────────────────────────────

describe("readIntentFromState — parse intent from profile (AC6)", () => {
it("returns intent array from profile state", () => {
const profile = { name: "JJ", intent: ["research", "general_coding"] };
expect(readIntentFromState(profile)).toEqual(["research", "general_coding"]);
});

it("filters out invalid intent values", () => {
const profile = { intent: ["research", "invalid_thing", "exploring"] };
expect(readIntentFromState(profile)).toEqual(["research", "exploring"]);
});

it("returns empty array when no profile state", () => {
expect(readIntentFromState(undefined)).toEqual([]);
});

it("returns empty array when intent is not an array", () => {
const profile = { intent: "research" };
expect(readIntentFromState(profile)).toEqual([]);
});

it("returns empty array when intent field is missing", () => {
const profile = { name: "JJ" };
expect(readIntentFromState(profile)).toEqual([]);
});
});

// ─── AC7: Seed rejection (last-write-wins) ───────────────────────────────────

describe("seed rejection semantics (AC7)", () => {
it("resolvePreFills returns whatever is latest in state (last-write-wins)", () => {
// If the user overrides a seeded environment, the state will reflect the override
// because appendOnboardingEvent appends and readOnboardingState replays last-value-wins
const stateAfterOverride: SeedState = {
environment: { slug: "user-chosen-env", archetype: "local-sim" },
};
const result = resolvePreFills(stateAfterOverride);
expect(result.environmentSeed).toBe("local-sim");
});
});
Loading
Loading