Skip to content

Latest commit

 

History

History
210 lines (160 loc) · 44.7 KB

File metadata and controls

210 lines (160 loc) · 44.7 KB

Changelog

All notable changes to Azure FinOps Agent are documented here.

The format follows Keep a Changelog and the project uses Semantic Versioning.

Security

  • Job ownership and identity are strictly OID-bound. Scheduled jobs can perform ARM writes on the owner's subscriptions, so every access path is tied to the Entra object id (the tenant-scoped identity): job listing/editing/toggling/deleting match the OID exactly (no derived-userId fallback), and the background scheduler loads the identity record OID-first and refuses to run if the record's OID doesn't match the job's — a mismatch pauses the job with "reconnect Azure" instead of ever hydrating another user's tokens. Cross-user/cross-tenant mixing is structurally impossible: one identity record per OID, refresh tokens tenant-bound by Entra itself, per-OID session workdirs, IDOR-gated session access.
  • Generated deck/script downloads now require an authenticated session and enforce per-user ownership. /api/download/html/{fileId} and /api/download/script/{fileId} previously served any valid fileId to anyone — and fileIds leak into logs and Application Insights telemetry, while decks/scripts contain the caller's tenant cost data. Each artifact now records the generating user's id (resolved from the turn's Activity Baggage) and the endpoints reject a mismatched or unauthenticated caller with the same 404 as a missing file (no ownership oracle).
  • DELETE remains blocked at the code level across every pass-through tool (QueryAzure, BulkAzureRequest, QueryGraph). This is the one hard guarantee — the agent reads and writes but never deletes; removals are emitted as a reviewable GenerateScript for the user to run. Everything else is bounded by the signed-in user's own RBAC / consented scopes.
  • Chat markdown is now fully HTML-escaped before rendering. renderContent escaped table cells but passed the base message text (code fences, inline code, bold/emphasis, headings) straight into v-html. Model output is influenced by fetched web pages, uploaded file contents, and Azure resource names/tags — CSP blocks script execution, but injected anchors/markup were still a phishing/defacement vector. The whole message is now entity-escaped up front (preserving entities the model itself wrote) before any transform; verified live that a returned <img onerror> renders as literal text with no element injected.

Added

  • Scheduled jobs — a recurring prompt that runs in the background, even with the browser closed. A new "Scheduled jobs" pane below Conversations lets an Azure-connected user save a prompt + cadence (every 15 min / hourly / daily / weekly); the backend JobScheduler runs it as a normal agent turn in its own dedicated conversation, so every run's answer (text, tables, charts) is browsable by opening the job. Built for the capacity-hunting use case — "check H200 spot capacity + quota across all my subscriptions and regions every 15 minutes and tell me the moment it's deployable" — but works for any standing task (daily cost report, anomaly sweep, license audit). Background auth is delegated-only: each run re-mints the user's Azure/Graph/Log Analytics/Storage tokens from the persisted MSAL refresh token (no app permissions, no admin consent — the job can never exceed the user's own RBAC), and pauses itself with a "reconnect Azure" status if the refresh token dies. Runs share the one-turn-per-session gate with live chat (a job can never race a chat turn), preserve the user's active conversation (a 3 AM run won't hijack which chat you see next), and keep the same tool surface as chat (writes allowed, DELETE still blocked). Caps: 3 active jobs per user; sub-daily jobs auto-expire after 7 days, daily/weekly after 90; a job auto-pauses after 5 consecutive failures. GET/POST /api/jobs, POST /api/jobs/{id}/run|toggle, DELETE /api/jobs/{id}. The pane is built to feel alive: “+ New job” opens a centered modal sheet (blurred backdrop, Esc/backdrop close) with one-click templates — GPU hunt & reserve (checks quota + capacity every 15 min and, when deployable, secures it by creating an on-demand Capacity Reservation via ARM PUT, idempotent with an explicit bills-while-held warning and a pause-and-cleanup instruction), daily cost digest, anomaly watch, budget guard, idle resource sweep, Advisor watch — plus cadence pills, a "Run immediately, then repeat on the schedule" checkbox (default on: the first result lands in the job's conversation within a minute; unchecked: the first run waits one full interval), and an autofocused prompt; rows show a live countdown ("next in 12 min", 30 s ticker) and status animations — breathing blue = armed, pulsing green = running now, pulsing red = failed/reconnect Azure, gray = paused; clicking a job that has never run shows a "no runs yet — ▶ runs it now" nudge instead of doing nothing; the list auto-sorts (running → failing → soonest next run → paused) so it scales past a handful of jobs; the header shows "N active · M failing" and collapses the pane instantly (sticky per browser); statuses refresh on tab-return plus a 45 s background poll, so returning users immediately see what ran while they were away.
  • A scheduled job can now run on a custom cadence, not just the presets. Alongside the Every 15 min / Hourly / Daily / Weekly pills, an "or run every ___ min" field accepts any interval from 1 minute to 30 days — so "every 1 min" (near-real-time monitoring), "every 45 min", "every 3 h" (180), or "every 2 d" (2880) are all one field away. The pills are now shortcuts that fill the same field; rows render the custom cadence in the largest whole unit ("every 45 min", "every 3 h", "every 2 d"). Out-of-range values are rejected inline (create/save disables with a hint) and by the API. (1 min is the scheduler's tick cadence — the fastest it can fire; a run never overlaps its previous one thanks to the per-session turn gate.)
  • Edit a scheduled job in place. Every job row now has a pencil (✎) that reopens the same modal prefilled with the job's name, prompt, and cadence — in edit mode the template picker and "run immediately" checkbox are hidden, the title reads "Edit scheduled job", and the button saves via a new PATCH /api/jobs/{id} (name / prompt / intervalMinutes each optional, validated the same as create). Changing the cadence recomputes the job's expiry (7 d sub-daily / 90 d daily+weekly) and re-arms the next run one interval out. Previously a job could only be created or deleted, so fixing a typo or retuning a cadence meant delete-and-recreate (losing the run history).
  • A failed job run is now visible at a glance. A job whose last run errored shows "last run failed · next …" in red on its row (instead of the generic cadence line), so a broken job stands out without opening it — the header already counts "N failing".
  • SSE keepalive pings during long silent phases. A turn can emit no bytes for minutes while the model generates a large tool argument (an 800-line deck or script) between the last reasoning delta and tool_start. Intermediate proxies (App Service front end, ~4 min idle) and the client's 3-minute inactivity watchdog would drop a perfectly healthy turn. The backend now emits a {type:"ping"} every 20 s while awaiting completion; the client ignores it.

Changed

  • A job's conversation is now a run log, not a chat. Opening a scheduled job's conversation shows its runs updating live (a follow loop keeps the view fresh while runs land server-side — previously the view silently froze while runs piled up on disk) with a job bar instead of a composer: Build deck, Summarize runs, Edit job, New chat. Free-form typing into a run log muddled the history and silently steered future runs — editing the job's prompt is now the explicit way to steer it, and sidebar titles read "⚙ Job · {name}" so job threads are unmistakable.
  • All transient chat notices now go through one state machine. "Reconnecting…", "Still working…", "busy", and resume fallbacks used to be spliced into the transcript by five independent pollers — they could stack (two contradictory banners at once), stick above real messages forever, and use chat wording ("your last question") for scheduled runs the user never typed. Now a single status pill lives outside the transcript (it can never stack or stick), job conversations get job wording ("⚙ A scheduled run is in progress…"), an empty resumed conversation shows the normal empty state instead of a pointless "Resumed conversation" pill, and the "Still working…" notice actually resolves into the answer when the turn finishes (it previously polled a no-op refresh and never updated).
  • Job rows now use their full width, and show when the job last ran. The action buttons used to permanently reserve ~120px of a ~225px row (invisible but still occupying layout), squeezing the name and status to a third of the row. Actions are now a hover overlay on the right edge — the text owns the whole row until you hover (keyboard focus reveals them too). The status line got richer: every 15 min · ran 2m ago · next in 13 min — cadence, last run, and next run at a glance; failures read failed 2m ago · next in X (the failure timestamp doubles as the last-run time), and the countdowns/ago-times tick live. The row tooltip now includes the total run count.
  • Job templates now lead with capacity — the single most common customer complaint. The grid starts with "Check capacity of X" (read-only: where is this VM size deployable right now, across all my subscriptions and regions — every 15 min) and "Reserve X when available" (the generalized GPU hunt: when the size becomes deployable with quota headroom, secure it via an on-demand Capacity Reservation — idempotent, cost-warned). A new "1-min test" template (one-sentence USD consumption report, every minute) lets anyone see how scheduled jobs work within a minute of creating one.
  • The job row's pause control is now a proper on/off switch. ▶ (run once now) sitting next to ⏸ (pause the schedule) read as contradictory transport controls — like a media player showing play and pause together. The schedule state is now a small toggle switch (blue = armed, gray = paused) that can't be misread, stays visible on paused rows as the state indicator, and announces itself to screen readers as a real switch. ▶ keeps its "run once now" meaning.
  • Local BYOK can pin its token tenant. New optional AzureOpenAI:TenantId setting — set it when the az CLI's default account lives in a different tenant than the Azure OpenAI resource (previously every local AI turn failed with "Token tenant does not match resource tenant" and the only fix was switching the CLI's global default).
  • Deleting a job or a conversation is now immediate — no confirm dialog, and optimistic. The × removes the row instantly (before the server round-trip), so the button always feels alive; if the server call ever fails, the next list refresh restores the row. Deleting a job keeps its conversation and run history.
  • Each right-sidebar pane (Agent, Conversations, Scheduled jobs) can be collapsed to its header via a large rotating chevron, with a smooth slide animation (the content row glides shut instead of snapping) — collapse two and the third takes the whole sidebar, so you can focus on one area (e.g. a long jobs list or a deep tool-call trail). Collapse state is remembered per pane.
  • The Scheduled jobs pane got a little bottom breathing room so the last job row isn't cramped against the sidebar's bottom edge.
  • QueryGraph now allows write methods (GET/POST/PUT/PATCH); DELETE stays blocked. Previously GET-only. This aligns Graph with QueryAzure so the agent can act on Microsoft Graph (e.g. group/license management) when the tenant has consented to write scopes — the standard read-only consent tiers still cause Graph to return 403 for writes, which the tool surfaces rather than retrying. The delete prohibition is unchanged and remains the product's hard guarantee.

Fixed

  • Clicking a sidebar prompt while viewing a job's run log now starts a fresh conversation for it. Crawl/Walk/Run prompts, score buttons, follow-up chips, and in-answer prompt links used to inject straight into whatever conversation was open — including a job's run log, which muddled the log and silently steered the job's future runs. All prompt entry points now hop to a new chat first when a run log is in view.
  • A run with a long tool trail no longer crushes the sidebar. The Agent execution pane sized itself by content, so 80+ tool calls (routine for scheduled jobs) squeezed Conversations and Scheduled jobs into slivers. Panes now share the sidebar by fixed proportion and the tool list scrolls inside its own pane.
  • Deleting conversations can no longer strand a scheduled job on a dead transcript. A job's run log used to appear in the Conversations pane as a regular deletable chat; deleting it left the job pointing at a vanished session ("couldn't load this conversation's history") until the next run replaced it. Run logs now live only under their job row (deleting the job makes its history reappear in Conversations, so nothing is lost), and deleting any conversation server-side detaches it from any job that referenced it — the job behaves as "never ran" and creates a fresh log on its next run.
  • Clicking a job that hasn't run yet now opens its details instead of doing nothing. A job with no run history has no conversation to open, so a row click used to only flash a tiny "no runs yet" hint that was easy to miss. It now opens the job's edit/detail sheet — and that sheet gained a "Run now after saving" option, so you can run a job straight from it. Triggering a run (that option, or the ▶ button) now auto-opens the run's conversation and refreshes it until the answer lands, so you can actually watch the run.
  • A paused job no longer shows a misleading "next in X min." A paused job that had previously failed still rendered a countdown to a run that will never happen. Paused rows now read "paused" (keeping the "last run failed" / "reconnect Azure" reason when relevant); only enabled jobs show a next-run countdown.
  • Resuming a paused job now respects the 3-active-jobs cap. Create enforced "max 3 active" but the pause/resume toggle didn't, so pause-one → create-three → resume yielded four active jobs (contradicting the header count and the create-time limit). The toggle endpoint now rejects a resume that would exceed the cap, and the row shows "Limit reached — max 3 active jobs. Pause or delete one first." for a few seconds instead of silently staying paused.
  • Opening "+ New job" right after cancelling an edit no longer inherits the edited job's name/prompt/cadence. The new-job opener reset the edit flag and the template selection but not the form fields, so a cancelled edit leaked its values into the fresh form. Opening a new job now always starts from a clean slate, and all four close paths (Cancel, ×, Esc, backdrop) clear the edit state.
  • A generated table (or any answer) no longer flashes on screen and then disappears. The chat wiped the streamed answer buffer on every tool_start after text had streamed — a heuristic meant to drop mid-turn narration before the real answer. But the model calls SuggestFollowUp (and sometimes ReportMaturityScore / PublishFAQ) after the final answer, so that wipe deleted the just-rendered table, leaving only a follow-up chip (the answer was still in the persisted transcript, which is why it reappeared on reload). Those post-answer tools are now excluded from the wipe, and a safety net restores the last wiped text if a turn ends with an empty buffer (which also fixes chart-only answers losing their lead-in sentence).
  • A blank white page after a deploy (seen in Edge) is fixed. index.html was served no-store but the content-hashed /assets/*.js|css bundles carried no cache header, so a browser could serve a cached index.html from the previous deploy that referenced the old bundle hashes — which 404 after redeploy, leaving a white screen until a hard refresh. Static file caching is now explicit: hashed /assets/* are immutable (cached a year), while index.html / / are no-cache (always revalidated), making deploys atomic for the browser.
  • A severed stream on a long-running turn no longer gets stuck showing one (or two) permanent "⏳ Reconnecting" banners. On a big turn — "go through all regions × all subscriptions" — the CLI persists nothing until the final answer, so the client's 3-minute recovery poller gave up while the turn was still running (the backend allows turns up to 15 min); worse, early poller exits left the banner on screen and freed the recovery lock, so the next window-focus/visibility event spawned a second poller that stacked a second banner, neither of which ever cleared. Recovery is now robust: the poll deadline covers the backend's full 15-minute turn budget (3 s→10 s backoff), the banner is a single deduped instance that is cleared on every exit path (early return, recovery, timeout, throw), the send-catch fires recovery fire-and-forget (so send() resets the composer immediately instead of blocking up to 15 min and double-posting), and on genuine timeout the dead banner is replaced with an actionable "Connection lost — Retry" message. The recovery view-check also tolerates anonymous users (whose currentSessionId is reset to null after every turn by loadSessions) by confirming the recovering prompt is still the last question on screen rather than a strict session-id equality — otherwise recovery stood down silently with no banner and no answer. Verified live: a real ERR_CONNECTION_RESET mid-turn shows exactly one banner that survives 36 focus/visibility events with zero console errors.
  • Returning to an empty or already-answered conversation no longer shows a phantom "⏳ Reconnecting" notice. The tab-return / window-focus reconcile treated any view that didn't end in a non-empty assistant message as "answer missing", so a brand-new/empty session (or one ending in a system notice) flashed "Reconnecting — your last answer is still being generated" and polled for 3 minutes even though no turn was ever in flight. Reconcile now only recovers when the current view has a genuinely unanswered user turn (a last user message with no real answer after it); a new assistantTurnHasOutput helper counts text, a chart, a generated deck/script, or completed tool calls as a delivered answer (so chart-only answers no longer false-trigger), and the recovery poller bails immediately on a blank prompt.
  • A deck or script finishing in a background conversation no longer pops into whichever chat is in view. html_ready / script_ready events are processed for background streams (so the card is on the committed message when you switch back) but used to write the shared htmlReady / scriptReady refs unconditionally — surfacing another conversation's artifact in the foreground. They're now kept stream-local and only mirrored to the shared refs while that session is the active view.
  • Restored conversations get their follow-up buttons back, and expired artifacts show a clear state instead of dead links. Transcript replay now re-derives the follow-up action buttons from the persisted SuggestFollowUp result (live turns already keep them), and deck/script cards whose 30-minute in-memory artifact has been swept (or lost on restart) render an "Expired — ask to regenerate" hint instead of a Download/Copy button that 404s.
  • New/stub sidebar rows show their timestamp immediately. The session_title stub row set modifiedTime but the template reads modified, so a brand-new conversation showed a blank relative time until the next sidebar refresh.
  • Starting a new conversation no longer wipes a background-streaming conversation's tool list. newSession() cleared every session's live tool/chart buckets; it now drops only the current view's (and the __pending__ sentinel's).
  • Concurrent-turn rejection ("busy") no longer leaves an orphaned user bubble. When the server rejects a second prompt sent to a still-running session, the optimistic user bubble is removed and its text is returned to the composer for an easy retry.
  • System notices (resume / busy / recovery) render as muted centered pills instead of masquerading as AI answers with an avatar.
  • Copying a generated script now shows a "Copied ✓" checkmark for ~1.5 s on the exact button clicked.
  • Deleting a conversation now asks for confirmation — the small × is an irreversible server-side delete and a mis-click shouldn't silently destroy a thread.
  • Per-session context-dedup dictionaries are pruned on session delete (they previously grew unboundedly over the process lifetime), and each mounted chart's ResizeObserver is now disconnected on wipe/unmount instead of observing detached DOM forever.
  • Dead model-selection leftover removed — the frontend no longer tries to auto-pick an "opus" model from /api/models; the backend always chats with its configured deployment and ignores the request's model field.

Added

  • The "Thinking" panel now renders Markdown. The live reasoning stream was shown as raw plain text; it now runs through the same HTML-escaping renderContent renderer as the main answer, so bold headers, bullet lists, inline code, and line breaks in the model's reasoning display properly. Styling is scoped to the panel (.reasoning-md) and kept compact + muted so headings/lists/code/tables don't blow up the small rolling-window box, and the rendered HTML is wrapped in a single inner block so the "pin newest lines to the bottom" effect still works. Verified live: a **bold** reasoning header rendered as <strong> with correct line breaks, no console errors.
  • Sticky auto-scroll + "Latest" pill in the chat. The messages pane now follows new content through EVERY kind of growth — the "Thinking" reasoning panel appearing and growing (previously never scrolled into view), the intent ticker, streamed text, charts mounting asynchronously, and the viewport shrinking when the composer autogrows or a deck/script card appears (ResizeObserver on the scroll container + content column, plus reactive watchers that keep working in hidden tabs where observer/rAF delivery is suspended). Following is sticky: scrolling up to read pauses it (no more being yanked to the bottom mid-read on every delta), scrolling back down or sending a message resumes it, and a floating "Latest" / "New activity" pill appears while paused for a one-click return. Switching or restoring a conversation now always lands on the newest message instantly (same-length transcript swaps previously didn't scroll at all, and long transcripts smooth-crawled from the top). Stream-follow scrolling is instant rather than smooth — restarting a smooth animation on every delta frame lagged behind the bottom — and deliberate jumps use smooth only for short hops in a visible document (smooth scrolling never completes in hidden/embedded views). Verified live: pinned through thinking-panel growth 641→747px, no yank while scrolled up mid-stream, pill jump + re-follow, transcript restore lands at bottom.
  • Conversations now survive leaving the page — for everyone. The active conversation id is persisted (sessionStorage + server-side current-session for Entra users), so a full page reload/navigation restores the transcript automatically — including for anonymous users, whose conversations previously lived only in JS memory. If the page was left mid-answer, the restored view shows a ⏳ reconnecting notice and auto-fills the answer when the server finishes (verified live: reload mid-turn → conversation restored → full answer + table appeared with no user action).
  • Frontend stream-lifecycle telemetry in Application Insights. New customEvents (chat.stream.start/firstDelta/done/severed/stopped/recovered, chat.tab.hidden/visible, chat.reattach.recover, chat.stream.zombieRecover, chat.session.restored) instrument the chat pipeline end-to-end so background-tab and connection-loss issues are diagnosable from production telemetry (window.__trackAppInsightsEvent added in main.js).

Fixed

  • Backgrounding, minimizing, or leaving the window no longer loses the answer. The backend keeps generating and persists the turn when a client disconnects, but the UI could come back to an empty response or a spurious “Connection lost”. Recovery is now bulletproof and non-destructive: (1) a severed stream recovers by polling the persisted transcript for THIS prompt’s answer (tail-match — instant if it landed while away) and repainting, with a brief refinement watch so mid-turn narration converges to the final answer; (2) a silently-dead “zombie” stream is detected on tab return purely from SERVER state (answer persisted but undelivered one confirm-tick later → abort + recover) — never from client-silence timers, which would kill healthy reasoning pauses; (3) GET /api/sessions/{id}/messages now serves anonymous users too (still IDOR-gated per-user), which recovery depends on. Verified live: sever mid-turn → answer recovered; healthy hidden/visible toggle → completes untouched.

Added

  • Paste or upload screenshots into the chat (vision input). Images (PNG/JPG/JPEG/GIF/WebP, ≤ 20 MB) can now be attached via the file picker, drag-drop, or pasted straight from the clipboard (Win+Shift+S → Ctrl+V) into the input box. Pasted clipboard images are auto-named screenshot-<timestamp>.png and shown as a thumbnail chip. Images bypass the Python analysis helper entirely — they ride along as native vision attachments (MessageOptions.AttachmentsAttachmentFile with MIME type) so the model literally sees the screenshot (verified: it read the exact dollar amount off a pasted test image). Each image is consumed by the message it's sent with (delisted server-side after send, chip removed client-side); data files (CSV/XLSX/…) still persist across turns via QueryUploadedFile. CSP img-src gained blob: for the local thumbnail previews.

Changed

  • GitHub Copilot SDK 1.0.6 → 1.0.7 with adoption of two new APIs. (1) ProviderConfig.BearerTokenProvider — the BYOK Azure OpenAI token is now supplied by an on-demand callback the runtime invokes before every model request, replacing the pre-1.0.7 static-token-baked-at-session-creation approach and its ~1h proactive session-recycle workaround (live sessions can now stay up indefinitely; the expiry-based recycle in GetOrResumeAsync was removed). (2) SessionConfig.ToolSearch = { Enabled = true } — explicitly pins tool-search deferral on so the existing DeferredTool (defer=Auto) cold-path tools keep their ~50% per-request input-token savings across SDK/CLI bumps. The bundled CLI runtime is pinned to @github/copilot 1.0.70 (<CopilotCliVersion>) because the SDK's default 1.0.71 is unpublished on npm (only 1.0.71-0 prerelease exists). Also bumped Microsoft.IdentityModel.* 8.19.1→8.19.2 and OpenTelemetry* 1.16.0→1.17.0. Verified live: a plain turn (session+deltas+done, ~3s) and a 2-tool pricing turn (toolStart=2/toolDone=2, streamed, ~14s) both complete on the upgraded stack.

Fixed

  • Chat input box now grows with multi-line content. The auto-grow logic set the textarea height from scrollHeight, but .input-field { flex: 1 } inside the column .input-wrapper (flex-basis:0) made the flex layout ignore that inline height and collapse the field to one line. Changed to flex: 0 0 auto (width preserved via the wrapper's stretch), so the field expands 24px→400px then scrolls (overflow-y:auto). Verified live: 1 line=24px, 8 lines=180px, 60 lines=400px+scroll, cleared=24px.
  • Entra account switch no longer leaks the previous account's state. Signing in with a different Microsoft account on the same browser session used to hand the new account the previous account's ARM/Graph/Log-Analytics/Storage tokens, consent tiers, and active conversation (the anon→Entra in-memory migration also ran on Entra→Entra switches, and per-resource session tokens were never purged). The OAuth callback now detects the OID change, skips the migration, purges every token/tier belonging to the previous account, and repoints (or drops) the finops_id identity cookie so hydration can't resurrect the old identity.
  • Base-tier users no longer fire doomed token exchanges on every message. Every chat request attempted refresh-token exchanges for Graph, Log Analytics, and Storage even when the user only consented to base ARM — each a guaranteed HTTP 400 at login.microsoftonline.com (production telemetry: 18/21 token calls failing) plus up to three wasted Entra round-trips of first-token latency per message. SessionTokenStore now gates refresh attempts on the consented-tier list (all add-on tiers are recorded in graph_tier at consent time, incl. loganalytics/storage) and applies a 15-minute backoff after a failed add-on exchange. Failed exchanges also log the AADSTS error/error_description instead of silently returning null. Note: users who consented to Log Analytics/Storage before this change need to re-click the add-on button once (their persisted consent list predates the tier markers).
  • Streaming no longer freezes when the browser tab is backgrounded. The typewriter effect drained text via requestAnimationFrame, which browsers fully suspend in hidden tabs — the answer looked stalled and crawled at ~600 chars/s after refocus. Text now renders synchronously while the tab is hidden and the pending animation queue is flushed on visibilitychange.
  • Opening a past conversation whose CLI state is gone returns an empty transcript instead of HTTP 500 (GET /api/sessions/{id}/messages threw RemoteRpcException: Session not found when the session listing and on-disk state disagreed).

Changed

  • GitHub Copilot SDK 1.0.5 → 1.0.6 — picks up the newer @github/copilot runtime and the .NET CopilotClient.DisposeAsync graceful-shutdown fix. The CLI runtime is pinned to @github/copilot 1.0.68 in Dashboard.csproj (<CopilotCliVersion>) because the SDK's default 1.0.69 is not published on npm (only 1.0.69-0/-1/-2 prereleases exist) — left unpinned, the build-time DownloadFile 404s both locally and in the ACR/Docker build. Revisit when a later CLI final is published and the SDK bumps.
  • Default reasoning effort highmedium (AzureOpenAI:ReasoningEffort) — GPT-5.6 at medium roughly halves time-to-first-token (the dominant first-response latency) while preserving tool-orchestration and format-following quality. Trivial turns still auto-route to low; set AzureOpenAI__ReasoningEffort=high for a max-depth demo.
  • System prompt — removed a duplicate chart-XOR-table restatement in the Response Shape section (the rule still stands forcefully in Core Rules), trimming per-turn input tokens with no behavior change.
  • gpt-5.4 references swept out everywhere — local dev config (user-secrets, appsettings.Local.json), infra Bicep descriptions, README/docs tables, the frontend model label (was a cosmetic claude-sonnet-4.6; the backend always uses the configured BYOK deployment), and illustrative pricing examples in tool prompts are all gpt-5.6-sol (or model-neutral) now. Production and committed defaults were already on gpt-5.6-sol.

Added

  • Session pre-warming (POST /api/chat/warmup) — the chat UI fires this once when the user's identity resolves, creating/resuming the Copilot session (system-prompt + tool-schema upload, ~300 ms) off the critical path so the first prompt hits the live-session fast path instead of paying session-creation latency. Fire-and-forget and idempotent-cheap on repeat calls.
  • Clickable prompt chips in AI answers — the model marks suggested questions with [label](prompt:full question); the chat renderer turns them into styled chips (inside tables, lists, and prose) that send the underlying question on click. The capability table's Examples column is now fully interactive.
  • Per-turn reasoning-effort routing — a conservative classifier (≤60 chars, no FinOps/data keywords) runs greetings/acknowledgements at low effort (~2–3 s first token) via SetModelAsync, while real questions keep the configured default. Applied effort is tracked per live session to skip redundant RPCs.
  • Live reasoning panel & thinking animation — streaming reasoning SSE events render as a multi-row rolling "Thinking" panel; the block cursor was replaced with animated gradient dots.
  • Turn gate + refresh re-attach — one running turn per session (concurrent sends get a friendly busy notice); GET /api/sessions/{id}/active lets the frontend re-attach after a refresh and auto-load the finished answer.

Fixed

  • Stale-session recovery for chat turns — no more hard error or silent hang. Two related gaps in the SSE chat path when the CLI had evicted a session between turns (the first confirmed via stack trace at ChatEndpoints.cs): (1) the per-turn effort switch (session.model.switchTo) ran before the streaming subscriptions and threw Session not found uncaught, surfacing a raw error to the user; (2) the SendAsync recycle reassigned the session handle but left the SSE subscriptions bound to the dead one, so a recycled turn generated server-side yet never streamed to the browser. Both now recover: the effort switch recycles before subscriptions are wired, and the SendAsync path detaches and rebinds the subscriptions (WireHandlers) onto the live session, re-announcing the session id so the frontend keeps streaming into the right conversation. Verified: happy-path streaming (text, tool calls, chart, first-event timing, completion) is fully regression-clean through the refactored subscription wiring; the recovery branches are correct-by-construction and reuse that same proven wiring, but the underlying staleness is a non-deterministic race (idle-disconnected sessions transparently auto-reconnect from disk) that could not be force-fired in re-tests.
  • Sidebar titles no longer leak prompt scaffolding — when the SDK truncated a long first prompt mid-[CONTEXT: …] block, CleanSummary/StripContextPrefix surfaced the raw injected context (e.g. "[CONTEXT: User is NOT…") as the conversation title. Truncated context blocks are now discarded entirely.
  • Title generation returned empty on reasoning modelsGenerateTitleAsync capped max_completion_tokens at 24, which GPT-5-series models consume entirely on hidden reasoning. Now uses the modern /openai/v1/chat/completions surface with reasoning_effort: "low" and 512-token headroom (verified: 8 completion tokens, 0 reasoning tokens).
  • Capability questions now end with clickable starter actions — "what can you help me with?" answers previously rendered a static table with no follow-up chips. The system prompt now mandates three SuggestFollowUp starter actions (public pricing actions when not connected; scoring/cost/idle actions when connected).

Added

  • EstimateTokenCost tool (CostEstimateTools) — deterministic C# calculator for monthly/volume LLM token costs. The agent looks up per-1M rates, then delegates the arithmetic here so the headline, summary table, and step-by-step always reconcile (components are summed in code and a ready-made per-model breakdown string is returned). Fixes inconsistent monthly totals where the table disagreed with the step-by-step or two token assumptions were blended.
  • Expanded FinOps maturity frameworkReportMaturityScore now enumerates all ~19 capabilities across Crawl (7), Walk (6: reservations & savings plans, right-sizing, dev/test scheduling, tag policy enforcement, hybrid benefit & licensing, storage & lifecycle), and Run (6: executive reporting, chargeback readiness, unit economics, anomaly detection, cost allocation & MG governance, AI/GPU cost) — each with explicit "what to check" guidance, mandatory evidence numbers, and per-subscription spread.
  • Deep maturity report patternGenerateHtmlPresentation gained a "FinOps Maturity Assessment Report" structure (score banner → exec summary → domain scores → all-capability chart → per-capability evidence tables → per-subscription summary → Crawl→Walk→Run roadmap → data-source appendix) for assessments that need full depth instead of a 5-slide summary.

Changed

  • Default model gpt-5.4gpt-5.6-sol (version 2026-07-09, GlobalStandard) with Priority processing (properties.serviceTier: 'Priority') — faster time-to-first-token. The azd Bicep (infra/modules/aoai.bicep) now sets serviceTier (new serviceTier param, default Priority).
  • Reasoning effort is now configurable via AzureOpenAI:ReasoningEffort (default high, was hardcoded xhigh). Production telemetry showed single xhigh LLM round-trips taking 8+ minutes — the dominant cause of slow chats. Set AzureOpenAI__ReasoningEffort=xhigh to opt back in.
  • Excluded the Copilot CLI task sub-agent tool from all sessions (SessionConfig.ExcludedTools) — a single task call spawned a nested agent that looped for 8 minutes in production. FinOps work uses direct tools only.
  • Upgraded GitHub Copilot SDK 1.0.0-beta.41.0.5 — migrated to the GA API surface: namespace GitHub.Copilot.SDKGitHub.Copilot; CopilotClientOptions.CopilotHomeBaseDirectory; OnPermissionRequest now uses PermissionHandler.ApproveAll (replaces PermissionRequestResult); CopilotSession.GetMessagesAsyncGetEventsAsync; SessionListFilter.Cwd and SessionMetadata.Context.CwdWorkingDirectory; tool lists widened from List<AIFunction> to List<AIFunctionDeclaration> (SessionConfig.Tools is now ICollection<AIFunctionDeclaration>). Behaviour-preserving — streaming and session persistence unchanged.
  • Dependency updatesMicrosoft.IdentityModel.JsonWebTokens + Microsoft.IdentityModel.Protocols.OpenIdConnect 8.18.08.19.1; OpenTelemetry + OpenTelemetry.Api 1.15.31.16.0.
  • GetAzureRetailPricing Foundry guidance — the tool description now decodes skuName token-by-token (Direction / Zone / Deployment / Context tier) and makes stating the pricing basis (Standard vs Batch, Global vs Data Zone vs Regional, region, currency) mandatory, so model prices are no longer mis-reported by silently picking the cheapest Batch/cached/Data-Zone row.

0.2.0 - 2026-05-14

Persistent sessions, hardened auth, richer chat UX.

Added

  • Persistent multi-session chat — Conversations survive browser close, page refresh, container restarts, and slot swaps. Each user keeps a sidebar list of past conversations they can resume. Powered by GitHub Copilot SDK 1.0.0-beta.3 with on-disk session state on the App Service /home mount. New SessionEndpoints.cs exposes GET/POST/DELETE /api/sessions.
  • FinOps maturity scoring UI — Crawl / Walk / Run sidebar now ships with score buttons, a playbook section, collapsible maturity cards, and interactive star ratings updated by the agent.
  • Analyze button in the chat input — One-click "find cost waste & recommend actions" that also picks up any attached files.
  • EA / MCA pricesheet support — Sidebar prompts for downloading negotiated pricesheets and running commitment-aware analysis.
  • WebFetchTools — Agent can pull from public web pages (Azure docs, blogs, pricing) to ground answers.
  • Code-level scope-prefix preflight in QueryAzure — rejects bare /providers/Microsoft.CostManagement/... (and /Consumption/budgets, /PolicyInsights/policyStates) calls with HTTP 400 + a corrective grammar message instead of a confusing 404 from ARM.
  • === CRITICAL: SCOPE-PREFIXED ENDPOINTS === section hoisted to the top of the QueryAzure tool description.
  • Microsoft Graph: promoted /v1.0/reports/getMicrosoft365CopilotUsageUserDetail, getMicrosoft365CopilotUserCountSummary, and /v1.0/deviceManagement/managedDevices as primary paths (now GA); /beta/ paths kept as fallbacks.
  • Inline script preview in chat with syntax highlighting, copy button, expand/collapse, and download.
  • JSON syntax highlighting in the tool inspector popover.
  • Pie chart total subtitle.
  • New docs/architecture-and-security.md and a "How it works" section in the README.
  • Demo options for users without an Azure tenant.

Changed

  • Upgraded to GitHub Copilot SDK 1.0.0-beta.3 with new session lifecycle.
  • Tool calls and charts are scoped per-session — switching conversations no longer mixes up the right sidebar.
  • Mid-turn "thinking" narration is cleared before the final answer streams in.
  • "Top 3 fixes" renders as a markdown table with clearer impact details.
  • Ambiguous-affirmative intent-binding rule in the Copilot SystemPrompt — "yes / go ahead / proceed" now resolves against the most recent in-chat offer instead of the loudest queued sidebar action.
  • Local dev secrets migrated to dotnet user-secrets; app fails fast on missing AzureOpenAI:Endpoint.

Fixed

  • RouteHandlerAnalyzer AD0001 NullReferenceException at startup — removed vestigial (Delegate) cast in ChatEndpoints.cs (.NET 10).
  • DefaultAzureCredential 2-5s per-message hang on VisualStudioCredential.RunProcessesAsync — excluded VS / VS Code / Interactive / AzurePowerShell credential providers in CopilotSessionFactory.
  • Microsoft.Migrate resource type corrected from migrateProjects to assessmentProjects (was returning 404 InvalidResourceType).
  • Microsoft.Quota endpoint now documents MissingRegistrationForResourceProvider failure mode + fallback to Microsoft.Compute/locations/{region}/usages.
  • HTTP 429 retry hardening with proper Retry-After handling.
  • Long-running sessions no longer fail with HTTP 401 mid-turn — proactive BYOK bearer recycling and background tenant-token refresh.
  • Bar chart rendering bug.
  • Duplicate AI avatar when resuming a still-streaming session.

Removed

  • ScheduleTools — folded into existing flows.

Security

  • Federated managed identity for Entra OAuth, ID token validation, nonce, and redirect URI allowlists.
  • PublishFAQ is now auth-gated — anonymous users can chat but cannot publish public FAQ pages.
  • Hardened Content Security Policy on the /slides route.

0.1.0 - 2026-05-05

Initial public release.

Added

  • Azure FinOps Agent reference architecture targeting Azure App Service (Linux container).
  • GitHub Copilot SDK 1.0.0-beta.3 backend with shared CopilotClient and per-user CopilotSession. Multi-session per user with on-disk persistence at {CopilotHome}/.copilot/session-state/ (mapped to App Service /home Azure Files mount). Idle sessions auto-disconnect after 30 min via SessionIdleTimeoutSeconds; resume rehydrates on next prompt with a fresh BYOK bearer token.
  • Azure OpenAI BYOK using system-assigned managed identity (DefaultAzureCredential) — no client secret needed for AOAI.
  • Microsoft Entra ID multi-tenant OAuth with incremental consent:
    • Base tier: Azure ARM (user_impersonation)
    • License Optimization: Organization.Read.All, Reports.Read.All
    • Cost Allocation: User.Read.All, Group.Read.All
    • Log Analytics: Data.Read
    • Cost Exports: Azure Storage user_impersonation
  • Custom AI tools: QueryAzure, BulkAzureRequest, QueryGraph, QueryLogAnalytics, ListCostExportBlobs, ReadCostExportBlob, RetailPricing, GetAzureServiceHealth, RenderChart, RenderAdvancedChart, GenerateHtmlPresentation, GenerateScript, ReportMaturityScore, SuggestFollowUp, PublishFAQ, QueryUploadedFile, IdleResource*, Anomaly*, Schedule*.
  • Read + write, never delete: DELETE is blocked at the HTTP helper layer; destructive cleanup goes through GenerateScript so the user reviews before running.
  • FinOps Maturity Framework UI (Crawl / Walk / Run) with per-level scoring.
  • Server-Sent Events streaming (text deltas, tool start/done, charts, scripts, slides, scores).
  • File uploads (CSV/TSV/JSON/TXT/XLSX/PDF/Parquet) with Python-backed inspection.
  • OpenTelemetry end-to-end: .NET app + Copilot CLI subprocess → in-container OTel collector → Azure Application Insights.
  • CI/CD via GitHub Actions: OIDC-based Azure login, ACR Buildx, App Service restart.

Security

  • HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy.
  • PKCE on the OAuth code flow.
  • Origin/Referer CSRF check on every state-changing request.
  • Absolute 8h session lifetime, 1h idle timeout.
  • Crypto-random session user IDs; SameSite=Lax, Secure, HttpOnly cookies.
  • DataProtection keys persisted to /home/dataprotection-keys to survive container restarts.