Files
LING DUANandClaude Opus 5 f29bbc4daa feat: sample declared interactive state before classroom questions (#1508)
* feat: sample declared interactive state before classroom questions

* test: exercise generated publication example through the iframe reader

* chore(generation): bump package version for observation prompt contract

* fix: keep interactive state optional on insecure HTTP origins

* fix(playback): keep component picking and separate state from reference

A declared state interface replaced the component picker with a forced
whole-area `#experiment` reference, removing the per-component selection
and outline that `main` already ships. Sampling was also gated on the
reference selector, so the only way to obtain state was to give up the
selection.

Reference identity and area state are now independent request-scoped
evidence items:

- `handleToggleElementPick` always arms the picker again, so a scene that
  declares the interface keeps main's per-component selection, outline,
  and send-time clearing.
- `sampleInteractiveState` follows the current Scene instead of the draft
  reference, so an unreferenced follow-up still reports current facts and
  never re-creates or extends a reference.
- The Host carries area state with or without a component reference. The
  evidence header names both identities and refuses to present area facts
  as properties of the referenced component.
- `metadata` is absent when only area state travels, so no element
  identity and no Spotlight authorization can be derived from it, and the
  accepted-reference receipt stays driven by explicit references only.

Review follow-ups in the same change:

- Client sampling follows `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED`.
  An ungated packet turned an ordinary Pi question into a 400 while the
  reference feature was disabled.
- A Scene that declares the interface always receives an availability
  boundary, including when the browser produced no packet at all. It is
  reported as `not-sampled` rather than the previous `no-interface`,
  which was a false statement about an activity that does declare one.
  Courseware without the interface keeps its unreferenced behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(generation): register the observation snippet as a packaged asset

The interactive-observation snippet is referenced by all six widget
content templates but was never added to the packaged-asset manifest, so
the asset test and the golden scene prompt both failed.

- `SNIPPET_IDS` now lists `interactive-observation`, restoring both the
  "exactly the generation-owned templates and referenced snippets" check
  and the "every referenced snippet is packaged" cross-check.
- The interactive system-prompt snapshot is re-pinned. The change is
  purely additive: the snippet is appended to the simulation template.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): stop injecting state constraints while the feature is disabled

The route rejects a request that carries a reference or a state packet
while `NEXT_PUBLIC_COURSEWARE_REFERENCE_ENABLED` is off, but an ordinary
question carries neither. It still reached the Host, and a Scene that
declares the state interface then received the full page-state block —
several kilobytes of constraints about evidence the deployment can never
sample.

The Host now returns before building that note when the feature is off.
A route-level regression asserts that neither the Director prompt nor the
Child prompt gains `PAGE-REPORTED STATE` in that configuration; disabling
the guard makes it fail with exactly that symptom.

Also reopens the composer before the unreferenced follow-up in the
classroom browser spec. An accepted answer may close it, which made the
assertion flaky without changing the behaviour under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): decouple reference Scene from state freshness and bound assembled evidence

Cross-review found two defects in the request-scoped state evidence.

The reference's Scene was folded into the sample's staleness test. The packet
is already bound to the current Scene by the identity check above it, so a
valid current-Scene sample was being discarded as `stale-sample` purely because
the student's component reference came from an earlier Scene. Reference and
area state are independent evidence items; freshness is a property of the
sample alone. With the coupling gone the two can now disagree on Scene, so the
note says so explicitly rather than letting the model attribute area facts to a
component that may not be on the current Scene.

The assembled evidence had no stated output budget. The static component packet
is bounded to 24,000 code points upstream, but that bound covers the static
packet alone; the note and the escaped observation JSON were appended without a
recheck. Escaping `<` for the prompt expands one code point into six, and `<` is
legal in a label or a fact value, so a packet the Host accepts could assemble to
149,385 code points. The budget is now declared as the static bound plus the room
the note frame needs, which is what makes the degradation terminate. Over budget,
the state body drops whole to an explicit `unavailable` statement: truncating the
JSON would emit a broken packet, and thinning a `complete` relation set would turn
an exhaustive set into a false one. The relationship summary degrades with it, so
the prose never asserts COMPLETE over a body that is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(pi): keep slide references independent of activity state

* refactor(interactive): simplify declared state and unify iframe preparation

Accept any JSON report within byte and depth budgets, without generated field
requirements or relationship-completeness semantics. Keep publishState and an
optional rendered result in the generation guidance.

Prepare the observation responder through patchHtmlForIframe and let the pool
own document identity, preserving state across placeholder remounts. Settle
sampling failures locally and align browser/server nesting limits.

Cover permissive JSON delivery, resource limits, lifecycle, legacy behavior,
and real renderer remounts with focused regression tests.

* fix(generation): publish automatic activity changes with clear positions

* fix(interactive): report missing legacy scope as no interface

* fix(interactive): guard sampling capabilities and bind scopes lazily

* chore(generation): bump version after main integration

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:17:30 +08:00

96 lines
3.8 KiB
TypeScript

/** Experimental, declared semantic evidence. Not a complete JS-state export or tool authority. */
export const OBSERVATION_VERSION = 1;
export const OBSERVATION_MAX_BYTES = 32_768;
/**
* Nesting bound, checked iteratively. Bytes do not bound depth — 20 KB of JSON
* can nest ten thousand levels — and every later step over a report
* (`JSON.stringify`, `structuredClone`, freezing) recurses and throws
* `RangeError` past the engine's stack. This is a platform bound like the byte
* cap, not a requirement on what a lesson reports.
*/
export const OBSERVATION_MAX_DEPTH = 64;
export const OBSERVATION_ATTRIBUTE = 'data-maic-observation';
/**
* The single declared activity scope a page publishes state for. It names the
* state-evidence scope only; it is never the identity of a referenced component.
*/
export const OBSERVATION_SCOPE_ID = 'experiment';
/** Optional browser capability; unsupported contexts retain static references. */
export function supportsInteractiveObservation(): boolean {
return (
typeof globalThis.crypto?.randomUUID === 'function' &&
typeof globalThis.crypto?.subtle?.digest === 'function' &&
typeof globalThis.AbortSignal?.any === 'function'
);
}
/**
* A page's own report of what its activity is now.
*
* Generation asks for a `summary` and a `state`; reading enforces neither. A
* report that drifts from the asked-for shape is still the only account of the
* activity there is, and dropping it would cost that lesson the capability over
* a field name. So this layer parses, bounds bytes, and hands the result on;
* identity and freshness are checked where the platform message is handled.
*
* An earlier revision required an objects/facts/relations graph with declared
* relation completeness, and that was the wrong place for it: the reader of this
* JSON is a language model that reads free-form data fine, while the writer is a
* generator least reliable exactly on the load-bearing fields. A carelessly
* emitted `complete` would have had the platform certify that unlisted
* relationships were provably absent.
*
* "Unknown stays unknown" is a prompt-level instruction and lives with the
* request evidence, not in this shape.
*/
export type Observation = unknown;
export type UnavailableReason =
| 'no-interface'
| 'not-ready'
| 'invalid-data'
| 'too-large'
| 'scope-changed'
| 'document-changed'
| 'timeout'
| 'cancelled';
export type ParsedObservation =
| { status: 'available'; observation: Observation }
| { status: 'unavailable'; reason: UnavailableReason };
/** Iterative, so measuring the bound cannot itself overflow the stack. */
export function exceedsDepth(value: unknown, limit = OBSERVATION_MAX_DEPTH): boolean {
const stack: { value: unknown; depth: number }[] = [{ value, depth: 1 }];
while (stack.length) {
const { value: current, depth } = stack.pop()!;
if (!current || typeof current !== 'object') continue;
if (depth > limit) return true;
for (const child of Object.values(current)) stack.push({ value: child, depth: depth + 1 });
}
return false;
}
export function parseObservation(raw: string): ParsedObservation {
if (
raw.length > OBSERVATION_MAX_BYTES ||
new TextEncoder().encode(raw).length > OBSERVATION_MAX_BYTES
)
return { status: 'unavailable', reason: 'too-large' };
try {
const parsed: unknown = JSON.parse(raw);
if (exceedsDepth(parsed)) return { status: 'unavailable', reason: 'too-large' };
return { status: 'available', observation: parsed };
} catch {
return { status: 'unavailable', reason: 'invalid-data' };
}
}
/** Request results are detached and recursively frozen; future publications cannot change them. */
export function freezeEvidence<T>(value: T): T {
if (value && typeof value === 'object') {
for (const item of Object.values(value)) freezeEvidence(item);
Object.freeze(value);
}
return value;
}