Release/1.21.0 - #1097
Merged
Merged
Release/1.21.0#1097
Conversation
…ments-user-surface feat(announcements): user feed, acknowledgements, and What's New panel (PR-2)
Closes the gap §8 of docs/specs/artifact-sharing.md has carried since the feature shipped: a recipient of a shared conversation saw nothing where the owner sees artifact cards. Silently — no placeholder, no error — because artifact hydration filters HEAD rows by the requesting user. It stayed open because the fix needed a consent decision rather than wiring. The decision is that sharing a conversation shares the artifacts it produced. ## The conversation share is the grant No artifact share records are created for this, which is where the original sketch in §8 pointed and where this deliberately does not go. `create_share` pins the session's artifacts — at the version each stood at right then — into the snapshot body next to the messages. That does two jobs. It keeps the point-in-time promise the snapshot already makes, so a recipient reading a frozen conversation is not shown an artifact the transcript around it never describes. And it makes the snapshot the ALLOWLIST. `resolve_shared_artifact` is then the entire access boundary, and does both halves: may this viewer open this share, and is this artifact one the snapshot pinned. It hands an owner id and a pinned version to `mint_for_conversation_share`, which checks nothing itself and says so in a comment — the token's `sub` is a DynamoDB partition address, so without the second half any valid share id plus a guessed artifact id would read the owner's whole artifact partition. Provisioning parallel artifact shares was rejected on two grounds. Each would need cascading on update, revoke, artifact delete and session delete, and a missed cascade leaves an artifact readable after its conversation was locked down — a security bug, not a display one. It would also put N rows in the recipient's "Shared with you" inbox for one conversation share, when the conversation is the thing that was shared. The payoff is that access has one source of truth: narrow a conversation's allowlist and its artifacts lock down in the same write; revoke it and they go with it. Both are tested as such. ## Compatibility Conversation sharing is in production, so the snapshot's `artifacts` key is optional on read: bodies written before this exist, and legacy inline shares predate the S3 offload by a wider margin still. Both read as an empty list. No migration, no schema bump — the addition is additive. Capture is best-effort. Sharing a conversation must not fail because the artifacts feature is off in an environment or its table hiccuped; a share with no artifacts is what every share was until now. ## Also `_load_snapshot_body` is now a narrowing of a new `_load_snapshot_raw`, so a caller wanting another key of the body does not have to widen the tuple every existing caller destructures. `heads_for_session` returns one row per artifact at HEAD, off SessionIndex alone — the shape a snapshot wants, where `list_for_session` returns one row per version for the session view's per-turn anchoring. Backend: 914 passed across app_api + architecture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR-3 of docs/specs/feature-announcements.md. Removes the "author by curl"
step: announcements can now be written, scheduled, published, archived and
re-shown from the admin console.
- `manage-announcements.page.ts` — list with state chips, surface icons, and
the lifecycle actions an announcement has and a link does not (Publish,
Show again, Archive). Mirrors `manage-user-menu-links`.
- `announcement-form.page.ts` — title, markdown body with live preview,
surface checkboxes, severity, schedule, role picker, `showToNewUsers`,
`requiresAck`, CTA.
- Nav entry under Customization, `data: { scope: 'admin.announcements' }`.
Three server rules are mirrored in the form so an admin finds out before
submitting rather than through a 422: `expiresAt` is required once a banner
or modal is selected, `ctaLabel`/`ctaUrl` travel together and must be
http(s), and the body cap is counted in **bytes** (the server's limit is
16 KB of UTF-8, so a 3-byte character costs three).
The panel checkbox is deliberately rendered checked-and-disabled rather than
omitted. The server forces `panel` on regardless (§D1); showing it explains
why dismissing a banner never destroys the information, where silently adding
it after save would not.
Two lifecycle guards are carried into the UI rather than left to the API:
- Edit never sends `state`, and "Show again" is a separate action from
editing — a typo fix must not re-fire a modal at everyone (§D4). The
confirm text says exactly that.
- Publish is not offered on an archived announcement, because the server
refuses it; offering a button that returns a 400 is worse than no button.
The markdown preview uses `message-block`, the app's real markdown
stylesheet, so what the admin previews is what the panel renders. The `prose`
classes would be inert — the Tailwind typography plugin is not installed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ipient PR-2 of the pair. #971 made the artifacts reachable; this makes them visible. A recipient now sees artifact cards anchored under the same turns the owner sees them under, and opens them read-only. ## A separate recipient component, not a mode of the owner's `ArtifactCardComponent` opens the docked panel and carries download, share, rename and delete; `ArtifactPanelComponent` adds a version picker and a code view. Every one of those is keyed on something a conversation-share recipient does not have — an owned artifact, or an artifact share id. Bending either into a "read-only mode" would mean a component whose every action is conditional on a flag, and the failure mode of a missed condition is a visible button that 403s. So the card and dialog are their own thing. What IS shared is the layer that should be: `ArtifactViewerComponent`, which is purely presentational and already served the owner panel and the standalone recipient page through two mint endpoints. This is the third, and it needed no change to that component — which is the sign the split was drawn in the right place. ## Artifacts arrive as an input, not through the state service `MessageListComponent` reads artifacts from `ArtifactStateService`, which is the OWNER's live session state: populated by SSE events and owner-scoped hydration, neither of which a recipient has. Feeding it recipient rows would put another user's artifacts into the signal the real session view reads. So the shared view passes them down, and the list groups them with the same index-anchoring logic — including the orphan fallback, so an artifact with no usable anchor lands in the end strip rather than disappearing, which is the exact failure this whole feature exists to fix. ## No code view The source endpoint is keyed on an artifact share id, which a conversation share does not have. The toggle is therefore absent rather than present and permanently failing. Adding it is a backend change, not a UI one. A refused artifact and a missing one get one message: "not part of this share" and "you may not open this share" are the same fact to a recipient, and telling them apart would describe what the owner has. SPA suite: 2367 passed (+26). 20 new tests: card, dialog (including the sandbox isolation the third mint path must not weaken, and the superseded-mint race), and the message-list anchoring — which had no component spec at all before, so the orphan-fallback branch the owner path also relies on is now covered. Verified in the browser against the compiled stylesheet at 1080px in both themes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-shared-with-you-tabs feat(artifacts): All / Yours / Shared with you tabs on the library
…onversation-artifacts feat(artifacts): share a conversation's artifacts with the conversation
…ments-admin-ui feat(announcements): admin list and form pages (PR-3)
…onversation-artifacts-ui feat(artifacts): render a shared conversation's artifacts for its recipient
…ncement form Found by browser-verifying the page in dev: every field filled, the form reporting `ng-valid`, and "Create draft" still disabled. No announcement could be authored from the UI at all. `canSubmit` is a `computed`, and a computed tracks the signals read during its *last* execution — so an early `return` shortens its dependency set. The guard chain read `isSubmitting()` and then `if (this.form.invalid) return false`, and `FormGroup.invalid` is a plain getter, not a signal. On the first evaluation the form was empty, so it returned there having tracked only `isSubmitting`. No later edit could schedule a recompute, and `isSubmitting` changes only inside `onSubmit` — which the disabled button prevented. Two changes, both load-bearing: - form validity is mirrored into a signal fed by `statusChanges`, like the other `valueChanges` mirrors already in this component; - every input is read unconditionally before being combined, so no branch can shrink the tracked dependency set again. The three new tests read `canSubmit()` while the form is still **empty**, then fill it. That ordering is the whole point: the existing 24 specs only ever read it after filling, so the computed's first evaluation saw a valid form, tracked everything, and stayed reactive — all 24 pass against the broken code. Verified by reverting the fix: the 3 new tests fail, the other 24 do not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening "Shared with you" with nothing shared showed "No artifacts match
your search" — with an empty search box. Found on dev the moment the
inbox flag went live.
`isFilteredEmpty` gated on the LIBRARY total, so any non-empty library
made an empty tab look like a failed search. It needed the SELECTED
TAB's count instead. The irony is that the comment above it already
warned about exactly this conflation ("'Nothing matches' is a different
message from 'you have nothing'") — tabs added a third state, "nothing
*here*", and the old gate quietly folded it into the wrong one.
So there are now three, in priority order:
isEmpty nothing anywhere "No artifacts yet" + CTA
isTabEmpty nothing in this tab names the tab
isFilteredEmpty filtered to nothing "No artifacts match your search"
`isEmpty` still wins when the library is empty outright: a per-tab
message would bury the one statement that actually matters.
Why the tests missed it: every empty-state spec asserted which ROWS
rendered, never which SENTENCE appeared when none did. The three added
here assert the sentence, including the case where blaming the search
is correct.
SPA suite: 2431 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s-form-submit-gate fix(announcements): submit button could never enable on the new-announcement form
…ty-tab-message fix(artifacts): stop blaming a search the user never made
The `banner` surface has been authorable since PR-1 and computed by the server since PR-2, but nothing in the SPA consumed `bannerItem()` — an admin who ticked "banner" got a field that did nothing, with no way to tell from the UI that the surface was unbuilt. `components/announcement-banner` renders the one banner the server picked (§D7) as a strip at the top of the shell: severity icon and colour from the `state-*` scale, the `summary` line when the author wrote one, an optional CTA, and a ✕ that records a durable `dismissed` ack. It writes `seen` on render — once per announcement per tab — which is what clears the unread dot for someone who reads the banner and never opens What's New. That write races the ✕, and deliberately relies on §D2's monotonic server-side rank rather than ordering the two client-side. Placement is a flex child of the shell's `<main>`, above the scroll container, so content reflows instead of hiding underneath. Three pieces of viewport-fixed chrome would otherwise paint over it, so the strip publishes its measured height as `--announcement-banner-height` and they offset against it: the chat topnav, the full-page empty-state overlay (which was `inset: 0`), and the two floating sidenav control clusters. The height is measured rather than hardcoded because the line wraps on narrow viewports. The voice overlay still covers it, which is right — that one is a modal. Gated on `isAuthenticated()`, not just chrome. `AnnouncementsService` loads its feed on the first read of `bannerItem()` and `resource()` loads exactly once, so mounting the banner on the login screen would fire `GET /announcements` unauthenticated, take the 401's empty-feed fallback, and never retry — announcements would be missing for the life of the tab. Found in the browser, not by a spec. Verified end to end against dev data with a local app-api: strip renders in light and dark and at 375px with no horizontal overflow, ✕ writes an ack that upgrades the existing `seen` row in place to rank 2 rather than duplicating it, the server then returns `banner: null` while the panel entry survives (§D1/§D2), and deleting the ack brings the banner back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner feat(announcements): render the banner surface (PR-4)
The interruptive surface, and the last one. `components/announcement-modal` renders the single modal the server picked; `AnnouncementModalService` decides whether interrupting is acceptable at all. The gate is the substance of this PR. The modal opens on route settle and only when there is no active stream, no pending tool-approval / OAuth-consent / MCP-App-consent prompt, no draft in a focused composer, and the route is not a minimal-chrome page. The consent checks are not redundant with the stream check: per mid-turn-steering (#934) `isLoading()` is FALSE while a turn is paused on an interrupt, so a stream-only gate would throw a dialog over an OAuth consent prompt and steal its focus. The prompt services are asked directly. Every gate input is read `untracked`. Read reactively, the effect would re-run the instant a stream ended or a consent was answered and fire a modal seconds after the user finished a thought — which §D8 forbids in as many words: a failed gate leaves the announcement eligible for the next clean load, it does not queue it. So the effect tracks only the announcement and a navigation counter, and snapshots the rest. `requiresAck` makes the confirm button the only exit: no ✕, `disableClose` on the overlay, and the in-component Escape and backdrop handlers return without writing an ack. Belt and braces on purpose — the CDK option and the guards fail independently. The button label follows the ack it writes, "I understand" → `acknowledged` and "Got it" → `dismissed`, so it cannot misdescribe the record. Started via `provideAppInitializer` rather than mounted in app.html: a CDK overlay is not a layout element, and nothing else would ever inject the service. Same shape as ThemeService. It also means this PR does not touch the app shell, so it does not conflict with PR-4. Body uses `.message-block`, not `prose` — the typography plugin is not installed, so the older user-menu-link-modal's classes are inert and strip list markers. Sanitization stays on (§D10): `admin.announcements` is delegable, so this body may be authored by a non-admin and reaches every user. Verified end to end against dev data with a local app-api. With `requiresAck`: opens on load, no ✕, Escape and backdrop clicks leave it open, and the button writes `acknowledged` at rank 3 — upgrading the `seen` row in place rather than duplicating it. Without it: ✕ and Escape both write `dismissed` at rank 2. Afterwards the server returns `modal: null` while both panel entries survive (§D1/§D2). Light, dark, and 375px with no horizontal overflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-modal feat(announcements): render the modal surface with the §D8 gate (PR-5)
…end) `/stats` needs a count of acks across users, which the key shape does not support: acks live under `USER#<id>` partitions, so counting them per announcement means a GSI on `announcementId` or a scan. The spec ranks those second and third and says start with atomic counters on the announcement item (§9). This does. The counters are top-level attributes — `ackCountsR1Seen` and friends — not a nested `ackCounts` map, because DynamoDB's `ADD` only works on top-level attributes and creates a missing one as 0 in the same atomic write. A nested map needs `SET path = if_not_exists(path, :zero) + :one`, which raises ValidationException until the parent exists, so every announcement authored before this shipped would need an init-then-retry branch on the ack hot path. They count users, not clicks. `record_ack` now reads the previous rank via `ReturnValues="UPDATED_OLD"` and bumps only the ranks the write crossed, so `seen` then `dismissed` adds one to each rather than two to `seen`. They are a funnel, not a partition: acknowledged implies dismissed implies seen, so `seen >= dismissed >= acknowledged` holds without ever reading them back. Keyed by revision, because "Show again" (§D4) is a deliberate re-broadcast and rolling its acks into the previous revision's totals would inflate them and make the numbers lie about the version people actually saw. **The bug worth reading twice:** every admin mutation — `update_announcement`, `set_state`, `bump_revision` — is a full `put_item` of the `Announcement` dataclass, so any attribute the model does not carry is destroyed by it. Publishing an announcement, the most common admin action there is, silently zeroed every counter. `Announcement.ack_counts` now carries them through read → write. Four regression tests cover publish, archive, edit, and continued accrual afterwards. `targeted` is answerable only for a `"*"` audience, via a COUNT query on the users table's StatusLoginIndex. That index is projected INCLUDE without `roles`, so a role-filtered count has nothing to evaluate against, and the alternatives are worse than an honest null: replacing a GSI on the users table (CFN reports green well before an index is ACTIVE), or the scan the spec ranks last. Nor is there a membership list to count — roles arrive as JWT claims mapped at login. Null means "not estimated", never zero. Increments are best-effort by design: a second write after the ack is already durable, logged and swallowed on failure. An under-counted stat beats turning a successful acknowledgement into a 500. 18 new tests; full backend suite 2329 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes PR-6. The admin list now carries a reach line per announcement — "2 seen · 0 dismissed — of ~68 targeted (estimate)" — which is the point of the whole surface: it tells you whether any of this works. Rendered as a funnel, not a partition. "12 seen · 8 dismissed" means 8 of those 12, because the stored rank only ever rises through them (§D2). `acknowledged` appears only where one was actually asked for; on an announcement without `requiresAck` the number is real but meaningless, and showing a third figure that is always equal to the second reads as a bug. Two cases render nothing rather than a zero: - **A draft.** Nothing has been shown, so "0 seen" would read as "nobody engaged" instead of "not sent yet". `hasReach` gates on published/archived, which also keeps the fetch off every row an admin is still writing. - **A role-scoped audience.** `targeted` is null there — the users table's StatusLoginIndex does not project `roles` — and "of ~0" would imply nobody is targeted. It says "audience not estimated" instead. Stats are a second endpoint per announcement, so they load after the list rather than blocking it, and only for rows that have been live. The cache is keyed by **id plus revision**: "Show again" restarts the counters, so an entry from the previous revision would report stale reach for a broadcast that has only just gone out. A failed fetch is dropped from the requested set so the next pass retries, and leaves the row without a reach line rather than blanking the list — the page's actual job is CRUD. The hover text and the "(estimate)" suffix carry the §11 caveat. One more is now documented on the response model: **nothing is backfilled.** The counters are incremented by the ack write path, so acks recorded before this ships are invisible — an existing environment starts every announcement at zero on deploy day even where people have already read and dismissed it. Verified against dev, where four ack rows predate the counters and only the two written since are tallied. 7 new service specs, 8 new page specs; full frontend suite 2486 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dismissing the banner pulled the whole view up by its height. It was a flex child of the shell's `<main>`, so appearing and disappearing reflowed everything below it — the jump was the bug, and reserving the space forever would have been a worse fix. It is now positioned `absolute` against a `relative` `<main>`: a rounded, shadowed pill floating over the content rather than a full-bleed strip displacing it. Measured before and after a dismissal, every content element — scroll container, greeting, composer — moves by exactly 0px in both axes. The overlay removes the reason anything had to know the banner's size, so this deletes more than it adds: - `--announcement-banner-height`, its `ResizeObserver`, the height signal, and the `DOCUMENT`/`ElementRef`/`DestroyRef` injections all go - `.chat-topnav-wrapper` goes back to `top: 0` - `.chat-container-empty.full-page` goes back to `inset: 0` - both floating sidenav control clusters go back to `top-4` `top-16` is the one constant that replaces all of it, and it is not arbitrary. On a chat route it lands the pill immediately below the fixed topnav — the placement §D1 asks for — and everywhere else it clears the shell's floating sidebar buttons, which sit at `top-4` and would otherwise be overlapped by a centred pill on any viewport narrow enough for the two to meet. Verified at 375px: the controls end at y=56 and the pill starts at y=64. The positioning strip spans the full content width, so it is `pointer-events-none` with `pointer-events-auto` on the pill alone — otherwise an invisible band would swallow clicks aimed at the topnav and the sidebar buttons beneath it. Verified: a click 30px outside the pill lands on the chat container, not the banner. `relative` on `<main>` is load-bearing. Without it the pill anchors to the viewport and drifts out from under the sidenav's padding transition. Browser-verified against dev data in light and dark and at 375px, with no horizontal overflow. Full frontend suite 2474 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-stats
feat(announcements): reach stats — counters, GET /{id}/stats, admin list (PR-6)
…-wrong blend `--rates-only` could never have produced a usable number. Three defects, all found by actually running it against dev-ai: 1. It filtered usage types on the substring `gpt-5.6`. No usage type contains a model id, so the filter matched nothing and the script reported "Cost Explorer lags ~24h" — a lag message for a search that was never going to match, which is the worst possible failure mode for a tool whose whole job is to answer "have the numbers landed yet?". 2. It multiplied every rate by 1000 to convert from 1K-token units. These models bill through AWS Marketplace in units of **1M tokens**, and Cost Explorer declares the unit in its own `Unit` field. Every derived rate was overstated 1000x. It now reads the declared unit and converts accordingly. 3. It read MONTHLY. Daily rows come back as exact round numbers; a multi-day window silently blends models into an average that looks like a rate. The blend is not hypothetical, and it is why this needed a guard rather than a fix. Marketplace usage types carry the token bucket and the service tier but never the model, so every OpenAI-family model in the account shares the same four rows — verified against USAGE_TYPE grouped by OPERATION and by BILLING_ENTITY; no finer dimension exists. August shows two distinct price cards ($5.50/$27.50 and $2.20/$11.00) and 2026-08-31 is visibly a blend of the two. A rate is therefore only a given model's rate on a day when it was the sole OpenAI-family model to run, so `--table` now reconciles against what we recorded in sessions-metadata and refuses to vouch for a number otherwise. I nearly shipped the mistake this guard prevents: a first read of Aug 20-31 gave a cache-read rate matching gpt-5.4's 0.1x to four decimals, and a reconcile then showed zero GPT calls in that window. The match was coincidence. This also closes off the spec's Option 1. The Price List API has no Marketplace service code at all (all 269 enumerated), and the Marketplace Catalog API is seller-side. These rates are not unpublished-yet; they are unpublishable through any pricing API while they bill this way, so waiting will not produce them. Bearing on the tier/long-context modelling gap PR-3 must resolve: every row ever seen in this account is `_standard` and no `-long-ctx` usage type has appeared, so a flat standard rate is correct for current traffic and a change would show up as a new usage type. That makes the gap monitorable rather than blocking. Controlled window claimed 2026-09-06 for `us.openai.gpt-5.6-sol` (dev had zero recorded calls beforehand); expected token totals are recorded in the spec so the read is a verification rather than a guess. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner-overlay fix(announcements): float the banner instead of occupying layout
…odel cards
I concluded yesterday that these rates existed in no source and had to be
derived empirically. That was wrong, and the error was one of scope: the search
ran against pricing *APIs* — Price List, then Marketplace Catalog — and stopped
there. AWS publishes them in prose on each model's card in the Bedrock User
Guide, alongside caching support, context windows, service tiers and endpoint
support. Absence from an API is not absence from the docs.
Every dev GPT-5.6 row was wrong, and every error over-charged by exactly 20%
(corrected in the dev catalog 2026-09-06T15:59Z):
sol output 26.40 -> 22.00
terra in/out/cache read/write 2.64 / 15.84 / 0.264 / 3.30
-> 2.20 / 13.20 / 0.22 / 2.75
luna in/out/cache read/write 0.264 / 1.584 / 0.0264 / 0.33
-> 0.22 / 1.32 / 0.022 / 0.275
The 1.2x is not coincidence: Terra and Luna were sourced wholesale from the
GovCloud Price List rows, which are exactly 1.2x commercial. Sol's output was
the one figure with no source at all — a 6x input ratio inferred from GovCloud,
where the real ratio is 5x. `openai.gpt-5.4` was already correct, empty
cache-write cell included, so yesterday's prod fix is confirmed by the card.
This also resolves the tier/long-context gap PR-3 was blocked on, rather than
merely downgrading it as the previous commit claimed:
- Service tiers do not apply. Every card says Priority and Flex are not
supported for these models, so the 0.5x/2x dimension does not exist here.
- Long context is real, and the spec's "2x twin" was wrong: above the 272K
threshold input is 2x but output is only 1.5x. A flat 2x would have
over-priced long-context output by a third.
- We do not reach it. All rows carry maxInputTokens 272000, pinned at the
short-context boundary, and compaction runs at 100K — so one short-context
rate is correct, and that cap is what keeps it correct.
Noted for the prod rows: `global.openai.gpt-5.6-*` prices 9.1% below the `us.*`
Geo CRIS card across every bucket, and prod already runs Claude on `global.*`.
Prod should not be a copy of the dev rows.
The empirical work in the previous commit is not wasted — it is now the audit
of these published numbers instead of the source of them, and the 2026-09-06
Sol window should reproduce 4.40 / 0.44 / 5.50 / 22.00 rather than discover it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `CURATED_BEDROCK_RESPONSES_MODELS` behind a new "Bedrock Responses" catalog tab, so the three GPT-5.6 models are one-click-creatable instead of requiring the escape-hatch form. Rates are the published Geo CRIS short-context row from each AWS model card — Geo CRIS is the tier the `us.*` inference profiles resolve to, and these models are inference-profile-only. Two values are pinned by test because they are pricing correctness, not preference: - `supportsCaching: true`. These models cache implicitly server-side with no way to turn it off, so `false` is not a preference but a false statement, and its only effect is to clear the cache-rate fields — pricing cached tokens at $0.00 while AWS bills them in full. On a warm conversation nearly every input token is a cached one. - `maxInputTokens: 272_000`. These have a 1M window but AWS prices them on two cards: above 272K, input costs 2x and output 1.5x. A CuratedModel holds one flat rate per bucket, so this cap is what keeps that single rate honest. Raising it silently opens the second price card. Fixes the curated `openai.gpt-5.4` Mantle entry in the same pass. It inherited `mantleDefaults()`' `supportsCaching: false`, so one-click-creating it produced exactly the mis-priced row that had to be repaired by hand in prod last night. Its card publishes a cache-read rate at 0.1x input and an em dash for cache write, so caching is on with a literal 0 write rate — 0 is the correct value rather than a missing one, because it makes `compute_wasted_usd` see a non-positive premium and return $0 instead of inventing waste. The `mantleDefaults()` comment claiming Bedrock caching is model-bound to Claude+Nova was simply wrong and is corrected. `claudeRates` becomes `ratesWithDerivedCache`: the 1.25x write / 0.1x read multipliers are not Claude-specific. The GPT-5.6 cards publish the same two, and commercial Cost Explorer billing reproduces them to four decimals — two model families, two independent sources, same ratios. `supportedParams` is deliberately absent from the new entries. AWS publishes no parameter table for GPT-5.6 (`model-parameters-openai.html` covers only the open-weight gpt-oss family), and a declared spec flips the #915 guard from permissive to restrictive — so an invented one would silently block parameters the model actually accepts. Better none than a guess. Not browser-verified: the page is admin-gated against the dev backend, so an unmerged frontend change cannot be signed in to. Layout risk is low — the tab strip is `flex-wrap` and the card grid is unchanged — but the visual check is worth doing on dev after merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…erivation-method GPT-5.6: correct every rate from the model cards, curate the models (PR-3), and fix the tool that missed them
The banner sat at the top of the shell. What it announces — a new model, a new capability — is acted on in the composer, so the notice now lives where the decision is made rather than in a corner the eye has already left. It mounts from `chat-input` beside `quota-warning-banner`, which is where users already look for ambient notices. It still floats rather than stacking in flow. `bottom-full` against a `relative` chat-input host puts it clear of the quota tabs, which stay attached to the input, and keeps the property from the previous change: measured before and after a dismissal, the composer, greeting and scroll container all move by exactly 0px. Restyled to match the sibling it now sits beside — a compact shrink-to-fit pill rather than a bar spanning the composer, which also shrinks how much it overlays. **It is now a chat-view surface only.** That is the real consequence of the move and it is deliberate: What's New remains the everywhere-record, which is why `panel` is forced onto every announcement server-side. The spec's §D1 is updated to say so rather than leaving the doc describing a placement that no longer exists, and the two admin help strings that told authors "a strip below the top nav" now describe where a banner actually appears. `chat-input` is reused by the agent-preview and marketplace test-drive panes, where a platform-wide notice would read as a bug rather than an announcement. A `showAnnouncements` input gates it, following the same opt-out shape as the `show*` controls beside it: default true, explicitly false at those two call sites, and threaded through `chat-container` so its embedded mode is off too. The shell mount and its `isAuthenticated()` gate are gone with it — the composer only exists inside an authenticated chat route, so the 401-on-login hazard that gate existed for is now structural rather than guarded. Three real test failures found and fixed on the way: two `chat-container` specs stub `app-chat-input` and needed the new input added to the stub, and one of the banner's own assertions was stale after the restyle. Full frontend suite 2486 passed, only the known `submission-review` flake outstanding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The composer is not always in the same place. A conversation pins it to the bottom of the viewport, where a pill below it would be off the edge. The empty state centres it with the greeting immediately above, where a pill above it floats over that greeting — visibly so at 375px, where the greeting wraps and the pill covered its second line. So the placement follows the composer: `below` on the empty state, `above` otherwise. Derived, not measured. `isEmptyState()` is the same computed that already picks which layout branch renders — the centred composer or the bottom-pinned one — so reading it makes the two impossible to drift apart. Measuring the composer's viewport position would re-derive that same fact less reliably and would have to be recomputed on resize, on scroll, and when the artifact pane opens. The banner takes a `placement` input and swaps `bottom-full`/`mb-2` for `top-full`/`mt-2`; `chat-container` supplies it through `chat-input` alongside the `showAnnouncements` gate. Verified on the empty state at desktop and 375px: the pill sits below the composer in clear space and `coversGreeting` is false in both, where it was true before. Full frontend suite 2490 passed — a clean run, including the `submission-review` spec that has been flaking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ment-banner-near-composer feat(announcements): move the banner to the chat composer, on whichever side it leaves free
Groundwork for serving the library's user-wide listing from an index
instead of a base-table Query. Nothing reads it yet — the query switch
ships separately, for the deploy-ordering reason below.
## Why an index at all
The base table is already partitioned by user, so this was never about
reachability: `list_for_user` can Query PK=USER#{uid} today. It is about
what that Query has to read. HEAD and version rows share the partition,
so it spans roughly 3x the rows it returns, then filters and date-sorts
them in memory — which is also what makes it unpaginable, since the page
boundary would fall in the wrong place. Only HEAD rows carry GSI2PK, so
the index holds one row per artifact, already newest-first.
## The backfill is the load-bearing half
`UserArtifactsIndex` is sparse. A HEAD row without GSI2PK is not stale
in it — it is absent from it forever, silently. The writer began
stamping the keys on 2026-09-04; every row written before that has
neither attribute, so switching the query without backfilling would drop
every older artifact from its owner's library with no error anywhere.
`backfill_artifact_user_index_keys.py` stamps them. Dry-run by default,
idempotent (`attribute_not_exists(GSI2PK)`, so it also yields to the
writer), and it never resurrects a row deleted mid-run
(`attribute_exists(SK)`).
It reads `updated_at` to build GSI2SK and never assigns it — that
attribute is embedded in both GSI sort keys and is writer-owned, the
same restraint `rename` observes. A HEAD row lacking `updated_at` is
reported by name rather than stamped with a fabricated timestamp that
would sort it wrongly forever.
Run on dev: 22 HEAD rows, 22 stamped, 0 skipped, 0 failed; re-run
reported 22 already-stamped, 0 stamped. Verified independently
afterwards: 0 rows missing GSI2PK, 0 version rows wrongly stamped, and
GSI1SK == GSI2SK on all 22 — GSI1SK is the *writer's* own
`ARTIFACT#{updated_at}#{aid}`, so that agreement checks the backfill's
format against production-written data rather than against a reading of
the code.
## Deploy notes
Adding this is one `UpdateTable`, and only one GSI may be added per
`UpdateTable` — the committed `gsi-inventory.json` gains exactly one
line here, so no release split is needed.
CFN reporting UPDATE_COMPLETE does NOT mean the index is ACTIVE;
DynamoDB backfills it asynchronously. That is why the query switch is a
separate PR: platform.yml and backend.yml share a concurrency group but
their order is not enforced, so shipping both together risks app-api
querying an index that does not exist yet.
Infrastructure: 784 passed. Backend: 186 passed across the artifact
suites, including 10 new tests for the script.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…evelop-1.18.0 # Conflicts: # backend/src/apis/app_api/fine_tuning/routes.py # backend/tests/fine_tuning/test_job_guards.py
…ze-skill-detail feat(customize): add a skill detail view
Imported from an in-flight worktree (claude/style-consistency-views-7231a9) so the follow-on radius sweep can build on it in a single branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-on to the Agents/Customize/Artifacts pass. Settles the radius split: `rounded-2xl` is now the single button radius app-wide — user-facing pages, admin lists and forms, and dialogs alike. The previous two-radius-by-surface convention is what let pre-redesign `rounded-sm`/`rounded-md`/`rounded-lg` buttons drift in between the two documented idioms. - 45 off-token primary buttons -> rounded-2xl - 3 more found where the radius and fill were not adjacent in the class string (login, first-boot, user-detail) and so missed a naive grep - 6 dialog Cancel/secondary buttons -> rounded-2xl, plus the shared ConfirmationDialog confirm/cancel pair - retargets the 6 buttons the prior pass had set to rounded-xl Also folds in the contrast-safe fill alias: solid `bg-primary-500|600` with white text becomes `bg-primary-accessible` + `hover:brightness-95`, dropping the now-redundant `dark:` fill pairs. Identical hex today, but only the alias is AA-guaranteed after a rebrand. Deliberately unchanged: segmented-control shells and their `rounded-lg` children, `rounded-full` chips/badges/search/pill CTA, `rounded-2xl` cards, the user-message bubble geometry, and two `bg-primary-50` tints that are a drag overlay and a "Latest" badge rather than buttons. Frontend suite: 239 files / 2824 tests passing, unchanged from develop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At the lg breakpoint with the sidenav open at 1280px each card lands at
roughly 340px. The customize-card spends 36px on the monogram, 44px on the
fixed-width toggle plus gaps, leaving ~230px for the title — so real tool
names truncate ("PowerPoint Prese…", "Student MyBoiseS…", "Salesforc…").
Found and fixed by the session behind claude/style-consistency-views-7231a9;
reproduced here at 1280px against dev data before applying, and confirmed the
three names render in full afterwards. Only visible against real data, which
is why the isolated-token screenshots missed it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pass swept primary fills, which the handoff inventory had listed. Their bordered partners were never inventoried, and the browser preview made the gap obvious: on the 404 page "Return Home" rendered at 16px next to a "Go Back" still at 8px. That one escaped the source grep because it uses a translucent `bg-white/60` rather than a flat `bg-white`. Sweeps the remaining 73 button-like call sites (59 <button>, 14 <a> used as buttons, 3 <label> file-pickers) onto rounded-2xl. Excluded, deliberately, after checking each element's tag and role: - two <input> and one <select> that matched only on font-weight - the sidenav and settings nav items (routerLinkActive highlights, not buttons — same family as the sidenav item left alone in the first pass) - the role="radio" icon-picker segment in model-form Verified in the browser against dev data in both themes: every button-like element on Agents, Artifacts, Customize, Fine-Tuning and the 404 page now computes a 16px radius, with no off-token hits. Frontend suite: 239 files / 2824 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-shtern-9df427 style(spa): one page-chrome idiom and one button radius across the app
An MCP server exposes three listings — tools, prompts and resources. We discover and display all three, but prompts were inventory only: `prompts/get` was never called anywhere in the stack, so a prompt could be read about and not used. This makes them usable at the smallest surface that proves the path. On a tool's detail page each prompt becomes "Try it" → a field per argument → "Compose" → the server's composition rendered inline, with copy. Argument capture was lossy. `_prompt_entries` flattened MCP's `PromptArgument` to names alone, dropping `required` and `description` — enough to describe a prompt, not enough to fill one in. `MCPPromptArgument` now carries all three. `from_dict` accepts a bare string so snapshots taken before this rehydrate rather than break, and the SPA normalizes the same shape because the two packages deploy on separate workflows whose order is not enforced. Resolution is deliberately live and never persisted: the result depends on arguments typed a moment ago and, for a 3LO server, on a token only the caller holds. It is capped at 20k chars / 20 messages. Non-text content (image, audio, binary resource) is named by kind rather than dropped silently; an embedded *text* resource still flattens to readable text. The route lives on app-api, not inference-api: this is user-facing CRUD, not part of the AgentCore Runtime invocation path, and a route added there would 404 in cloud before reaching the container. No composer change. Wiring a composed prompt into a conversation needs a prefill path the SPA does not have; that is out of scope here. Nothing this adds reaches the system prompt, `toolConfig` or history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removes ConversationModePickerComponent from the composer and deletes it. The placement works — verified end to end on dev — but a permanent composer slot is a bigger commitment than the evidence currently supports, so it comes out until there is user feedback to decide the location on. Only the control is removed. SystemPromptsService, GET /system-prompts/, selected_prompt_id on SessionPreferences and the admin CRUD are all untouched, so restoring this is a revert rather than a rebuild. Release gate, recorded in the spec: main still carries the model-settings drawer, which is how prod selects Guided Learning today (~60 sessions/month and growing). develop deletes that drawer and now has no picker, so the first release carrying both leaves no way to select a mode at all. Restore the picker, land a replacement placement, or take the regression knowingly before that release ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mpt-try-it feat(tools): try an MCP server's prompts from the tool detail page
…nversation-mode-picker feat(composer): park the Conversation Mode picker
`/my-skills` was a top-level route reachable only by a link-out from
`/customize/skills`, so the same noun lived in two places with two different
answers to "what skills do I have?" — one page listed what you authored, the
other what you could turn on, and neither showed the whole set.
Both now live under `/customize/skills`, split by a `scope` query param:
- **Yours** (default) — skills you authored at any status, plus catalog skills
you have turned on. Dense rows, with edit/delete on the ones you own.
- **Discover** — catalog skills your roles grant that are still off. Browse
cards with a switch.
Turning a skill on is this platform's analogue of "installing" one: access is
RBAC and the only state a user owns is the enablement preference.
An `Add` menu replaces the old "New skill" button and the link-out, offering
*Upload skill* (`?import=1`) and *Create a skill*. It is gated on the same
404-from-`/skills/mine` signal that used to hide the whole `/my-skills` page.
No backend change. The page merges two endpoints that already existed:
`GET /skills/` (accessible + ACTIVE, with the preference) and
`GET /skills/mine` (the authored tier at every status). The merge is what keeps
a DRAFT skill visible to its author — widening `GET /skills/` to carry drafts
would surface them in the composer picker, which the runtime refuses to
activate. A draft therefore has no toggle at all rather than a dead one.
The authoring form moved to `/customize/skills/{new,:id/edit}` (git mv); the
three old `/my-skills` paths stay as redirects.
⚠️ `customize/skills/new` must stay declared above `customize/skills/:skillId`
or the parameterised route swallows it.
⚠️ `setScope` needs `relativeTo` on `router.navigate([])`, or the empty command
list resolves against the root and the query params are silently dropped. Found
in the browser, not by the tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…date-skills-surface feat(customize): consolidate skills into one surface with Yours/Discover
Typing `/web-research` in the composer invokes that skill for that message — the sibling of the `@`-mention, with the same menu shape, keyboard and "rides one turn" semantics. The menu's last row is "Browse skills →". Scope is the skills the user already has turned on, which is what makes it cheap: the invoked skill is already in `enabled_skills`, so the system prompt, toolConfig and `<available_skills>` block are byte-identical whether or not a command was used. The cacheable prefix is untouched; the whole cost is one line appended to the turn's user message. The text is the binding. Unlike the `@` menu there is no remembered pick — the invoked set is derived from the composer text, so a hand-typed command works like a menu pick and the chip cannot disagree with what is sent. The chip's ✕ edits the text, because that is where the binding lives. `/` is ordinary punctuation, so a command must start a word AND not be followed by another `/`. That second clause is what keeps `/usr/bin/env` prose: an absolute path starts a word exactly like a command does. The rule is implemented three times (composer token, `findSkillCommands`, thread renderer) and all three must agree. Backend: `GET /skills/` now serves the runtime activation `slug` rather than letting the SPA re-derive it; `invoked_skills` on the invocation request is intersected against the turn's effective set (re-run after Agent bindings can replace it) and becomes a directive appended last, riding `original_message` so the thread shows only what the user typed. Contrast: the chip and menu tile use neutral surfaces with the brand blue in the text. `bg-primary-50` is not a tint — the primary scale offsets lightness only and keeps full chroma, so it resolves to rgb(118,179,255). Measured at 10.60/4.74 (light/dark) for the label, all elements above their AA bar. Spec: docs/specs/skill-slash-commands.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `primary` scale is generated from #0033a0 by lightness offset alone and keeps full chroma at every step, so `primary-50` is not the pale wash its name implies — it resolves to rgb(118, 179, 255), a saturated mid-blue. Used as a chip, badge, icon tile or selected-row fill it reads as a blue blob behind small text, and it fails WCAG AA. The `state-*` scales ARE real tints (`state-success-50` = rgb(240, 253, 244)), which is why the pattern looked safe by analogy and wasn't. Measured against the app's own resolved tokens: text-primary-accessible on bg-primary-100 4.13:1 FAIL ...with hover:bg-primary-200 3.52:1 FAIL text-gray-500 sub-label on bg-primary-50 2.23:1 FAIL text-primary-accessible-dark on the dark half 2.63:1 FAIL Replaced 47 opaque `bg-primary-50/100/200` fills and `hover:` affordances across 25 files with the neutral-surface pattern already established by the skill-command chip: `bg-gray-100` + `text-primary-accessible` in light, `bg-gray-700` + `text-primary-50` in dark (9.63:1 / 4.74:1). Sub-labels that the new fills brought closer together were stepped with them — `text-gray-500` on `bg-gray-100` is 4.39:1, so those move to `text-gray-600`/`dark:text-gray-300`. `hover:brightness-90` goes too; it only ever existed to tame the blue. Left alone deliberately: fractional washes (`bg-primary-50/40` composites to rgb(200, 225, 255), a genuine pale tint) on large transient drag-and-drop surfaces, and every solid `bg-primary-500`+ fill with white text. Guardrails so this does not come back: branding/README.md recommended `bg-primary-50` as the decorative-tint utility — that row is what propagated the pattern, and it now documents the neutral one. The tailwind-ui skill's app-conventions reference gains the same rule with the measured numbers. Verified in both themes with computed styles: all 17 shipped pairings pass. 2868 SPA tests pass; development build is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lash-commands feat(composer): slash commands to invoke enabled skills
…-tint-fills fix(spa): stop using the primary scale as a light-tint background fill
Left over from #200. Every runtime consumer already imports apis.shared.sessions.metadata; the copy still wrote the legacy rotating S#ACTIVE# session sort key that the SessionRecencyIndex (GSI4) listing cannot see. Repoint test_cache_savings.py and three spec citations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ad-app-api-metadata chore(app-api): remove dead sessions/services/metadata.py duplicate
`@` and `/` are the two shortcuts nothing on the page advertises: each one only reveals itself once you have already typed the character that opens its menu. The empty composer is where a user looks when they do not yet know what to type, so that is where the hint goes. Three rules keep it from being the kind of animation people file bugs about: - It is decoration, not information. The native `placeholder` attribute never rotates, so assistive tech reads one stable string; the visible line is an `aria-hidden` overlay painted over a placeholder that is transparent but still there. A placeholder that re-announced itself every few seconds would be a screen-reader defect, not a feature. - It stops. Three passes and it comes to rest on the idle line, and the first keystroke settles it on the spot. Nothing auto-updates indefinitely, so WCAG 2.2.2 asks for no pause control we would then have to fit into the composer's chrome. - It honours `prefers-reduced-motion`. Reduce means no rotation at all, not the same rotation with the fade taken off. The rotation comes to rest *in the overlay* rather than unmounting back to the native placeholder: unmounting would exit-animate a copy of the idle line straight off the placeholder underneath, which spells the same words — a ghost double-image on the one transition every user sees. Hints are offered only for surfaces this composer actually has, so an environment with Agents switched off, or a user with no skills enabled, is never told to type a character that opens an empty menu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds twenty greetings bucketed into morning (05:00-11:59), afternoon (12:00-16:59), evening (17:00-21:59) and night (22:00-04:59), each with a no-name fallback for signed-out users. They pool *with* the existing five rather than replacing them, so a morning visitor draws from morning plus the any-time list: about half of what anyone sees knows what time it is, and half is the familiar copy. Replacing instead of pooling would leave the app with exactly one thing to say each morning. The clock is the viewer's own, read once at resolve time, so "Good evening" means evening where the person actually is and the heading does not re-write itself out from under someone mid-conversation. `DEFAULT_GREETING_TEMPLATES` and `DEFAULT_FALLBACK_GREETINGS` are left untouched: `brand.defaults.golden.spec.ts` pins them verbatim as the pre-branding-refactor greetings, and that guard is worth more than tidiness. The new copy lives in its own constants. Greetings are Brand_Config territory, so this stays rebrandable. `timeOfDayGreetings` / `timeOfDayFallbackGreetings` are optional: omit either, or any single bucket, and the defaults apply. An explicitly empty bucket is honoured as "stay quiet at that hour" — the one place these differ from the flat lists, where empty means "have the defaults" — and each bucket normalizes independently, so one bad hour never costs a rebrand the other three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r-hints-and-greetings feat: rotating composer hints and time-of-day greetings
Global preferences get a home. Customize (/customize/{tools,skills,connectors})
replaces the composer's settings drawer, which had been presenting durable,
account-wide state as "settings for this conversation." Tools and skills gain
detail pages, an MCP server's sub-tools can be switched one at a time, and
/skill-name in the composer invokes a skill for a single message.
Chat stops guessing about itself: a four-tool answer renders as one card
instead of five, the loading indicator states what the agent is actually doing
from a new agent_status event, and each finished tool batch gets a
model-written summary from a Nova Micro side-channel that never touches the
cacheable prefix.
Three cost reductions land on the same request path — the tool catalog moves
off a full-table Scan onto EntityTypeIndex, four tenant-global catalogs gain a
TTL + single-flight cache, and the per-request user-profile upsert is throttled.
- Requires a CDK deploy: one GSI operation on the existing app-roles table,
sagemaker:CreateModel on the app-api task role, a CloudFront
x-forwarded-prefix header, and one new CloudWatch alarm.
- Requires a backfill: backfill_tool_catalog_index.py, in every environment,
after the index reports ACTIVE and before the catalog read is trusted.
- BREAKING (UX): the settings drawer is deleted and the Conversation Mode
picker was parked before release, so this release leaves no way to select a
mode. Prod uses Guided Learning (~60 sessions in the first 12 days of
September). The backend is untouched; restoring the control is a revert.
- Fixes blocked mixed content on every conversation page load, and the agent
designer preview silently dropping tool calls.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment on lines
+129
to
+132
| - name: Install | ||
| working-directory: frontend/ai.client | ||
| run: npm ci --prefer-offline | ||
| - name: Run tests with coverage |
| try: | ||
| key = store.put(model_id=model_id, content=data, ext=ext, content_type=content_type) | ||
| except IconStoreError as e: | ||
| logger.error(f"Icon storage unavailable for model {model_id}: {e}") |
| if previous and previous != key: | ||
| store.delete(previous) | ||
|
|
||
| logger.info(f"🖼️ model-icons: uploaded icon for model {model_id}") |
| if previous: | ||
| get_model_icon_store().delete(previous) | ||
|
|
||
| logger.info(f"🖼️ model-icons: removed icon for model {model_id}") |
| user: User = Depends(get_current_user_from_session), | ||
| ) -> SkillDetailResponse: | ||
| """Read one skill the current user can reach, catalog or self-authored.""" | ||
| logger.info(f"User {user.name} reading skill '{skill_id}'") |
| return await asyncio.to_thread(_get) | ||
| except Exception as exc: # noqa: BLE001 - surfaced as a 502 by the route | ||
| logger.warning( | ||
| "prompts/get failed for %s/%s: %s", tool.tool_id, prompt_name, exc |
| import argparse | ||
| import sys | ||
| from dataclasses import dataclass, field | ||
| from typing import Any, Dict, List, Optional |
Comment on lines
+27
to
+38
| from apis.shared.images.icons import ( # noqa: F401 - re-exported for existing importers | ||
| ICON_MAX_BYTES, | ||
| ICON_MIN_SOURCE, | ||
| ICON_SIZE, | ||
| ICON_SQUARE_TOLERANCE_PX, | ||
| IconError, | ||
| IconStore, | ||
| IconStoreError, | ||
| content_digest, | ||
| key_version, | ||
| normalize_icon, | ||
| ) |
Comment on lines
+42
to
+52
| from apis.shared.images.icons import ( # noqa: F401 - part of this module's surface | ||
| ICON_MAX_BYTES, | ||
| ICON_MIN_SOURCE, | ||
| ICON_SIZE, | ||
| IconError, | ||
| IconStore, | ||
| IconStoreError, | ||
| content_digest, | ||
| key_version, | ||
| normalize_icon, | ||
| ) |
| # A batch cannot realistically fan out past this; the cap keeps the id list | ||
| # from turning a tiny row into a large one. | ||
| _MAX_TOOL_USE_IDS = 24 | ||
| _KEY_ATTRS = ("PK", "SK", "GSI_PK", "GSI_SK", "ttl") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Release 1.21.0
58 PRs since the 1.20.0 cut. Full detail in RELEASE_NOTES.md and CHANGELOG.md.
Customize (
/customize/{tools,skills,connectors}) replaces the composer's settings drawer, which had been presenting durable, account-wide state as "settings for this conversation." Tools and skills gain detail pages, an MCP server's sub-tools can be switched one at a time, and/skill-namein the composer invokes a skill for a single message the way@agentalready did.Chat stops guessing about itself. A four-tool answer renders as one card instead of five; the loading indicator states what the agent is actually doing (
Running browse_web · 4s) from a newagent_statusevent; each finished tool batch gets a model-written summary from a Nova Micro side-channel that never touches the conversation or the cacheable prefix.Three cost reductions on the same request path. The tool catalog moves off a full-table Scan onto
EntityTypeIndex(95 items read to return 24, before); four tenant-global catalogs gain a TTL + single-flight cache; the per-request user-profile upsert is throttled, removing ~24 DynamoDB writes from every SPA first load.This release removes the only way to select a Conversation Mode. The settings drawer is deleted (#1079) and the picker meant to replace its Mode control was parked before release (#1088) pending feedback on its placement.
Prod carries one enabled mode — Guided Learning, a Socratic tutoring prompt — and use is accelerating: 1 session in July, 20 in August, 60 in the first 12 days of September.
The backend is entirely untouched (
SystemPromptsService,GET /system-prompts/,selected_prompt_id, admin CRUD), so restoringConversationModePickerComponentis a revert rather than a rebuild. Sessions that already carry a mode keep applying it; no user can select or change one.Three options: restore the picker before merging, land a replacement placement, or take the regression knowingly. This was recorded as a release gate in
docs/specs/customize-surface.mdat the time the picker was parked.Pre-merge gates
check-gsi-update-limit.mjsPASSED. Exactly one GSI operation (EntityTypeIndex) on the existing{prefix}-app-rolestable; 27 tables compared.check-pending-backfills.mjsPASSED.backfill_tool_catalog_index.pyis named inRELEASE_NOTES.mdwith its command and verification step.sync-version.sh --checkPASSED.VERSION1.20.0 → 1.21.0, all manifests, README and three lockfiles regenerated.Deploy order
platform.yml→ wait forEntityTypeIndexto reportACTIVE→ run the backfill →backend.yml→frontend-deploy.yml.The catalog read moves onto that sparse index in this same release. A sparse index answers "nothing matched", not "something is wrong" — an unrun backfill looks like a short tool list, not an outage. There is a zero-result Scan fallback that logs an ERROR naming the script, but it is a safety net, not the plan.
Infrastructure
EntityTypeIndexon the existing{prefix}-app-rolestable (ProjectionType.ALL, sparse)sagemaker:CreateModelon the app-api task role — Batch Transform inference had never worked from the deployed appx-forwarded-prefix: /api— fixes blocked mixed content on every conversation page loadagentcore-runtime-active-sessionsalarm, threshold 75 over a 60-minute windowBreaking
/settings/connectorsand the three/my-skillspaths are now redirectsdark:text-primary-400andbg-primary-50|100|200banned as text/tint tokens (both fail WCAG AA; theprimaryscale keeps full chroma at every step)last_login_ataccurate to withinUSER_SYNC_THROTTLE_SECONDS(default 300) rather than to the last request🤖 Generated with Claude Code