Skip to content

Add career-coach scenario: Career Coach autopilot using Node.js SDK - #338

Open
Akshit-MSFT (Akshit0022) wants to merge 9 commits into
microsoft:mainfrom
Akshit0022:scenario/career-coach
Open

Akshit-MSFT (Akshit0022) wants to merge 9 commits into
microsoft:mainfrom
Akshit0022:scenario/career-coach

Conversation

@Akshit0022

@Akshit0022 Akshit-MSFT (Akshit0022) commented Aug 23, 2026 •

Copy link
Copy Markdown

Add career-coach scenario: Career Coach autopilot using Node.js SDK

What this adds

A scenario extension under scenarios/career-coach/ - a private AI Career Coach built on the Microsoft Agent 365 SDK + OpenAI Agents SDK that runs an employee's growth journey end-to-end as an Agent 365 AI Teammate (its own M365 identity and mailbox), in Microsoft Teams and by email: goal setting, skill-gap mapping, course recommendations, proactive post-course quizzes, milestone nudges, and a manager wrap-up email. Complements the Chief-of-Staff (#333) and Scrum-Master (#334) scenarios by covering the talent-development / employee-growth side of leader / squad workflows.

Capabilities

Each flow is a deterministic handler in src/handlers.ts, with focused LLM sub-calls in src/llm-tasks.ts:

  • Set goals - user names a target role, then gets a skill-path Adaptive Card with per-skill self-assessment dropdowns
  • Map skills - deterministic gap analysis against CompetencyFramework_v2 + course recommendations from LearningCatalog_v2 (planReview card)
  • Save plan - writes the plan (goals, skills, course list) to UserState, one row per goal (Active / Completed / Replaced) so finished goals are kept as history
  • Sync progress - diffs LearningPortalStatus against the plan and recomputes progress
  • Proactive quiz - a Microsoft Graph change-notification subscription fires when the portal marks a course complete, and the agent DMs a 5-question quiz (3 MCQ graded in code, 2 short-answer graded by a focused LLM call)
  • 80% milestone - one-shot card surfacing the weakest topic areas aggregated across quiz attempts
  • 100% completion - completion email from the agent's mailbox (To: user, Cc: manager); the goal is marked Completed only after a successful send
  • 1:1 prep brief - STAR-format wins + talking points on demand, including for finished goals
  • Email channel - the same coach by email: thread-scoped history, plans as HTML tables, reply "Save it" to save (same code as the Teams button); org members only
  • WorkIQ MCP tools - Mail and Calendar (V2) for user-requested actions ("email me my plan", "find time for my 1:1")

Full flows, data model and design decisions: scenarios/career-coach/docs/design.md and AGENT-CODE-WALKTHROUGH.md.

Design principles

  1. The LLM talks; code decides. The LLM is gated to the paths that genuinely need language understanding (conversation, quiz-question generation, short-answer grading, completion-email prose). Every card submit, data write, MCQ grade, milestone rule, and email dispatch is plain TypeScript.
  2. Rules are enforced, not requested. Tool-level guards reject disallowed writes: the model cannot mark a course Complete, record a passed quiz, raise a skill level, edit a finished goal, create a second active goal, or switch role on a goal with progress; stale writes can't undo earned progress and plan courses must exist in the catalog. SharePoint tools are scoped to the caller's own rows.
  3. One source of truth. State lives in five SharePoint lists; Teams and email read and write the same rows.
  4. Card actions never double-fire. Every Action.Execute handler acks fast and runs deferred; cards lock after use and in-flight claims absorb double clicks.
  5. Never go silent; degrade, don't die. A failed background step tells the user what failed and unlocks the card only when retrying is safe. Optional capabilities (MCP servers, telemetry, the webhook) can fail without stopping a turn; A365 lifecycle events are consumed by a top-priority route.
  6. Least privilege. The agent acts as its own agentic user, including for background work; WorkIQ SharePoint is read-only; Mail/Calendar recipients are limited in code to the requesting user (and their manager only when the user's own message asks for the manager). Completion mail goes to the user, cc their manager.

What's included

scenarios/career-coach/
  README.md                     - Prereqs, install, SharePoint setup, dev tunnel, register/publish, E2E walkthrough, troubleshooting, auth, deploy
  AGENT-CODE-WALKTHROUGH.md      - Source-level tour in request order
  docs/
    design.md                   - Principles, architecture, data model + goal lifecycle, flows, security, reliability, observability, decisions
  src/                          - agent, cards, handlers, service layer, llm-tasks, graph-service, sharepoint tools + guards, email channel
    telemetry.ts                - @microsoft/opentelemetry bootstrap (imported first)
    observability/
      app-token-resolver.ts     - App-only S2S export token for the runtime agent identity
      conversation-id.ts        - Stable span conversation id (email threads)
    scripts/
      setup-sharepoint.ts       - Idempotent provisioning of all 5 lists (adds new lifecycle columns on re-run)
      seed-reference-data.ts    - Competency framework + learning catalog from CSVs
      mark-courses-complete.ts  - Simulate portal completions (drives Feature 1)
      reset-milestones.ts       - Re-arm the 80% / 100% cards
      backup-and-reset-user.ts  - Snapshot + clear a user's write-lists for a clean re-run
      clear-list.ts             - Empty a list
  ToolingManifest.json          - WorkIQ MCP servers (Mail, Calendar)
  SharePoint Data/              - Seed CSVs (19 competencies + 31 courses)
  manifest/                     - Teams app + agentic-user templates (placeholder GUIDs)
  images/thumbnail.png
  package.json                  - Scripts: dev, build, test-tool, setup:sharepoint, seed:reference, mark:complete, reset:milestones, backup:user
  tsconfig.json
  .env.template                 - Every env var with WHY comments
  .gitignore

Local testing

The conversational path (goal setting, skill ratings, gap analysis, 1:1 prep) runs locally against the Agents Playground: npm install, npm run dev, npm run test-tool. The full data-backed loop (save plan, proactive quiz, milestones, completion email, email channel) requires:

  • Azure OpenAI or OpenAI API key (gpt-4o recommended)
  • A SharePoint site with the five lists (npm run setup:sharepoint), and the agent user added as a site Member
  • A public dev-tunnel URL for the messaging endpoint and the Graph change-notification webhook (Feature 1)
  • An Agent 365 registration: a365 setup all --aiteammate, a365 setup blueprint --update-endpoint <url>/api/messages --m365, a365 publish --aiteammate, then an approved, licensed agent instance

Auth model

  • Runtime workload - agentic auth: the A365 platform hands the agent a delegated Graph token for its own agentic user per turn (/me is the Career Coach agent user and mailbox). SharePoint, the completion email and WorkIQ MCP use this identity. No application-permission client secret is used for Graph.
  • Background work (no user turn) - the Graph change subscription, the notification row scan and startup warm-up also run as the agent user: the hosting connection's getAgenticUserToken(...) with the tenant / agent instance / agentic-user ids captured from the first message (or AGENT_INSTANCE_ID / AGENT_USER_ID). The developer device-code cache is only a local fallback for scripts and for the moments before the agent has seen its own ids. With NODE_ENV=production and a webhook configured, the agent refuses to start without a valid PORTAL_EVENT_SECRET and the service connection. The notification read is filtered server-side to recently updated rows.
  • Telemetry - exported over the S2S route with an app-only token for the runtime agent identity (blueprint FMI assertion → agent identity → Observability API), validated and never logged. gen_ai.agent.id is the runtime identity from the turn; the blueprint id is metadata only.
  • Setup / seed / helper scripts - MSAL device-code + delegated scopes (Sites.Manage.All, Sites.ReadWrite.All, User.Read.All, Mail.Send, offline_access). Sites.Manage.All and User.Read.All are admin-restricted, so a tenant/SharePoint admin performs - or pre-consents - the one-time sign-in. The blueprint's Sites.ReadWrite.All grant and the WorkIQ MCP grants (a365 setup permissions mcp) also need a Global Administrator.

Observability

@microsoft/opentelemetry with the Agent 365 exporter on the S2S route. Every path - LLM turns, card actions, typed shortcuts, email and proactive turns - emits invoke_agent (input/output messages), chat (model/provider, finish reason) and execute_tool spans with caller, channel, session and conversation id. OpenAI Agents auto-instrumentation adds token usage; no fabricated token counts.

Verification

  • Set up on a fresh M365 tenant (new blueprint, AI Teammate instance, SharePoint site); the agent responds in Teams over a dev tunnel with SharePoint state, WorkIQ MCP and the S2S telemetry token working. Live-tenant evidence follows in the validation-evidence comment.
  • Clean npm install (no lockfile) → npm run build and npx tsc --noEmit pass.
  • Behaviour checks against a fake Graph (ownership, stale-write merge, catalog/courseId guard, quiz for an off-plan course, grading outage, one completion email per goal, recipient allowlist, email fencing, subscription retry, secret validation, log redaction): all pass. Live: with the developer sign-in expired, the agent created its Graph subscription as the agent user after its first message.
  • a365-code-validator static scan: 0 findings.
  • Secret scan clean - no tenant IDs, no client secrets, no PATs, no persona bleed. All placeholders are 00000000-..., contoso.sharepoint.com, <CHANGE_ME_...> etc.
  • All source has MIT copyright headers.
  • CLA signed.

Placement - why scenarios/

Placed at the repo root under scenarios/career-coach/ rather than under a language tier (e.g. nodejs/openai/) because a career-coach workflow doesn't map cleanly to a single SDK - it composes the Agent 365 SDK, OpenAI Agents SDK, SharePoint, and Microsoft Graph. This mirrors how the Chief-of-Staff (#333) and Scrum-Master (#334) samples are placed. Happy to relocate to nodejs/openai/scenarios/career-coach/ if reviewers prefer a language-tier scoping.

Not included / future work

  • Shared storage for multi-instance hosting. The quiz answer-key cache is in memory, and the proactive map, email-plan store and subscription state are local files - fine for a single instance; move them to shared storage (e.g. Redis / Cosmos DB) before scaling out.
  • Production Key Vault-backed MSAL cache for the setup scripts. Local dev uses a file cache (.mstoken-cache.json, gitignored).
  • E2E test suite under tests/e2e/. Recommend a follow-up PR once initial review lands.

Related

@Akshit0022
Akshit-MSFT (Akshit0022) marked this pull request as ready for review August 23, 2026 21:58
@Akshit0022
Akshit-MSFT (Akshit0022) requested a review from a team as a code owner August 23, 2026 21:58
Copilot AI lite review requested due to automatic review settings August 23, 2026 21:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Career Coach scenario under scenarios/career-coach/, expanding the repo’s scenario samples with a Node.js/TypeScript Agent 365 agent that uses SharePoint Lists + Microsoft Graph for durable plan/progress state and proactive flows.

Changes:

  • Introduces a full Career Coach agent implementation (LLM path + deterministic handlers) with SharePoint-backed state, quizzes, milestones, and completion email flow.
  • Adds SharePoint provisioning/seed/reset scripts plus reference CSV datasets for competency framework and learning catalog.
  • Adds scenario documentation and Teams manifest/assets for sideloading/testing.

Reviewed changes

Copilot reviewed 36 out of 39 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
scenarios/career-coach/.env.template Environment variable template for OpenAI/Azure OpenAI, A365 identity/auth, SharePoint, and webhook settings
scenarios/career-coach/.gitignore Scenario-local ignore rules for build artifacts, caches, state, and logs
scenarios/career-coach/AGENT-CODE-WALKTHROUGH.md Walkthrough documentation for the scenario code
scenarios/career-coach/README.md End-to-end setup and testing instructions for the scenario
scenarios/career-coach/ToolingManifest.json MCP server manifest for tooling integration
scenarios/career-coach/package.json Node/TS dependencies and scripts for dev/build and SharePoint helpers
scenarios/career-coach/tsconfig.json TypeScript compiler configuration
scenarios/career-coach/verify-userstate.ps1 PowerShell helper to inspect UserState list contents via Graph
scenarios/career-coach/SharePoint Data/CompetencyFramework_v2.csv Seed data: role → competency framework
scenarios/career-coach/SharePoint Data/LearningCatalog_v2.csv Seed data: course catalog mapped to competencies
scenarios/career-coach/docs/design.md Design/architecture documentation for the scenario
scenarios/career-coach/images/thumbnail.png Scenario thumbnail asset
scenarios/career-coach/manifest/manifest.json Teams app manifest (placeholder IDs)
scenarios/career-coach/manifest/agenticUserTemplateManifest.json Agentic user template manifest
scenarios/career-coach/manifest/color.png Teams icon asset
scenarios/career-coach/manifest/outline.png Teams icon asset
scenarios/career-coach/src/index.ts Express host: health check, /api/messages, proactive /api/portal-event, startup wiring
scenarios/career-coach/src/agent.ts AgentApplication wiring, lifecycle routing guard, message + notification handlers, adaptive card action handlers
scenarios/career-coach/src/client.ts OpenAI Agents client wrapper, system prompt, SharePoint function-tools integration, observability scopes
scenarios/career-coach/src/cards.ts Adaptive Card rendering utilities and payload helpers
scenarios/career-coach/src/career-coach-types.ts Shared data model types + SharePoint configuration constants
scenarios/career-coach/src/career-coach-service.ts Deterministic service layer: SharePoint CRUD, progress logic, grading logic, milestone aggregation
scenarios/career-coach/src/handlers.ts Deterministic handlers for card submits, progress sync, quiz flows, milestones, and completion email
scenarios/career-coach/src/llm-tasks.ts Focused LLM subcalls for quiz generation, short-answer grading, and completion email prose
scenarios/career-coach/src/openai-config.ts OpenAI vs Azure OpenAI client configuration
scenarios/career-coach/src/graph-service.ts Graph client setup (MSAL device-code for scripts + agentic token exchange for runtime), subscriptions, mail helpers
scenarios/career-coach/src/sharepoint-tools.ts Graph-backed SharePoint tools exposed as OpenAI function tools (site/list ID resilience, column mapping)
scenarios/career-coach/src/sharepoint-column-map.ts SharePoint display-name ↔ internal-name mapping and defensive field coercions
scenarios/career-coach/src/subscription-manager.ts Create/renew Graph list change-notification subscription with local persisted state
scenarios/career-coach/src/proactive-refs.ts Disk-backed mapping of AAD object IDs to proactive conversation IDs
scenarios/career-coach/src/file-storage.ts Disk-backed Storage implementation for Proactive subsystem restart resilience
scenarios/career-coach/src/quiz-cache.ts In-memory cache of generated quiz questions/answer keys for deterministic grading
scenarios/career-coach/src/token-cache.ts Custom in-memory token cache + token resolver for observability exporter
scenarios/career-coach/src/scripts/setup-sharepoint.ts Script: idempotent provisioning of SharePoint lists + columns
scenarios/career-coach/src/scripts/seed-reference-data.ts Script: seed reference lists from CSVs
scenarios/career-coach/src/scripts/mark-courses-complete.ts Script: simulate portal completions by inserting LearningPortalStatus rows
scenarios/career-coach/src/scripts/reset-milestones.ts Script: reset milestone guards for re-demos
scenarios/career-coach/src/scripts/backup-and-reset-user.ts Script: snapshot + delete a user’s rows across write-lists
scenarios/career-coach/src/scripts/clear-list.ts Script: delete all rows from a specified list

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread scenarios/career-coach/src/agent.ts Resolved
Comment thread scenarios/career-coach/package.json Resolved
Comment thread scenarios/career-coach/docs/design.md Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 39 changed files in this pull request and generated 1 comment.

Suppressed comments (8)

Previously missed (4) — in code that hasn't changed since the last review.

scenarios/career-coach/verify-userstate.ps1:40

  • The Graph list-items request uses expand=fields, but Microsoft Graph expects $expand=fields. As written, item.fields won't be expanded and the script will fail when it tries to read $item.fields.
    scenarios/career-coach/src/token-cache.ts:30
  • The TokenCache comment says it has "expiration handling", but the implementation never expires entries. This is misleading for readers and callers (especially since the resolver may be hit frequently).
    scenarios/career-coach/src/token-cache.ts:42
  • TokenCache.set() logs on every cache write. Since the observability SDK may refresh tokens frequently, this can create noisy logs and unnecessary overhead. Gate this behind an env var (or remove it).
    scenarios/career-coach/src/token-cache.ts:53
  • TokenCache.get() logs on every cache miss, which can be very frequent and flood logs in normal operation. Gate the miss log behind an env var (or remove it).

scenarios/career-coach/src/career-coach-types.ts:4

  • This new TypeScript source file is missing the required Microsoft copyright header, and the opening comment still references an MCP server even though the scenario now uses Graph-backed tools.
// Career Coach data types
// These types define the structure of data in SharePoint Lists
// accessed via the SharePoint MCP Server

scenarios/career-coach/package.json:45

  • This package uses Express v5 (express: ^5.1.0) but pins @types/express to v4. That mismatch can cause incorrect typings and TypeScript compile errors. Use the Express v5 type package version (as other samples in this repo do).
  "devDependencies": {
    "@microsoft/m365agentsplayground": "^0.2.18",
    "@types/express": "^4.17.21",
    "@types/node": "^20.14.9",

scenarios/career-coach/docs/design.md:5

  • This design doc appears to be the generic OpenAI sample-agent template (title and overview) rather than Career Coach–specific content. Since the PR description points readers here for Career Coach architecture/flows, the opening section should be updated to match this scenario.
# OpenAI Sample Agent Design (Node.js/TypeScript)

## Overview

This sample demonstrates an agent built using the official OpenAI Agents SDK for Node.js. It showcases TypeScript patterns, MCP server integration, notification handling, and Microsoft Agent 365 observability.

scenarios/career-coach/AGENT-CODE-WALKTHROUGH.md:22

  • The file-structure diagram is still for sample-agent/ and omits many Career Coach modules (handlers/service layer/SharePoint scripts). This makes the walkthrough misleading for contributors trying to navigate the scenario code.
sample-agent/
├── src/
│   ├── agent.ts               # Main agent implementation (~60 lines)
│   ├── client.ts              # OpenAI client wrapper with observability
│   └── index.ts               # Express server entry point

Comment thread scenarios/career-coach/src/index.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 38 changed files in this pull request and generated 16 comments.

Suppressed comments (2)

scenarios/career-coach/src/career-coach-types.ts:3

  • This new TypeScript source file is missing the required Microsoft copyright and MIT license header.
// Career Coach data types
// These types define the structure of data in SharePoint Lists
// accessed via Microsoft Graph (agentic delegated auth)

scenarios/career-coach/src/scripts/seed-reference-data.ts:147

  • Skipping whenever any item exists makes the seeder non-resumable. If one row fails after earlier rows were created, the next run sees a non-empty list and skips it, leaving the reference catalog permanently incomplete unless the developer deletes all successful rows. Seed missing rows by the stable role/course key, or verify the full expected key set before declaring the list complete.

Comment thread scenarios/career-coach/src/llm-tasks.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/index.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/client.ts Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 38 changed files in this pull request and generated 1 comment.

Suppressed comments (14)

Previously missed (9) — in code that hasn't changed since the last review.

scenarios/career-coach/src/handlers.ts:623

  • This automatically Cc's the manager with the employee's target role, courses, and time investment as soon as progress reaches 100%, without any consent state or confirmation action. That contradicts the agent's own rule in src/client.ts:101 (“Never share with managers without explicit consent”) and exposes private career data. Gate manager delivery on an explicit, persisted user choice; otherwise send only to the employee.
    if (managerEmail && managerEmail.toLowerCase() !== (userEmail ?? '').toLowerCase()) cc.push(managerEmail);
    // Fallback: if we somehow lack a userEmail, still send to the manager only.
    if (to.length === 0 && cc.length > 0) { to.push(cc[0]); cc.length = 0; }

    if (to.length === 0) {
        note = 'No manager or user email found — the email was drafted but not sent.';
    } else {
        try {
            await sendMail({ to, cc, subject, htmlBody, fromUserId: userAADId, graph });

scenarios/career-coach/src/agent.ts:147

  • The quiz work is started without an in-flight/idempotency guard. Two rapid clicks or a retried invoke can both read the cache before clearQuiz, then append duplicate QuizResponses, update UserState concurrently, send duplicate cards, and potentially dispatch the completion email twice. Claim an idempotency key (for example user/course plus activity ID) before launching the deferred handler and reject or join duplicates.
        // Fire-and-forget so the invoke returns fast (LLM short-answer grading + writes
        // can take ~5-10s; Teams' invoke timeout is ~10s and shows a red banner if we exceed it).
        handleQuizSubmit(context, this.authorization, {
          userAADId,
          courseId,
          skillId,
          answersByQuestionId,
        }).catch((err) => {
          console.error('[Quiz] Deferred handleQuizSubmit failed:', (err as any)?.message ?? err);
        });

scenarios/career-coach/src/subscription-manager.ts:99

  • Renewal matches only URL and resource, so changing PORTAL_EVENT_SECRET keeps the old subscription and its old clientState. Graph then continues sending the old value while the endpoint compares against the new secret, causing every notification to return 401 until the subscription expires or is manually removed. Persist a non-reversible fingerprint of the configured clientState and recreate the subscription when it changes.
    scenarios/career-coach/.gitignore:24
  • subscription-manager.ts creates .sp-subscription.json, but this generated file is not ignored. It contains the subscription ID, SharePoint resource identifiers, and tunnel URL, so a normal local run can accidentally commit tenant-specific runtime state. Add it to the runtime-state ignore list.
# runtime state + backups
.proactive-storage.json
.proactive-refs.json
backups/

scenarios/career-coach/src/career-coach-types.ts:3

  • This TypeScript source file is the only file in src/ missing the required Microsoft copyright and MIT license header; compare src/agent.ts:1-2 and src/handlers.ts:1-2. Add the standard header before the file description.
// Career Coach data types
// These types define the structure of data in SharePoint Lists
// accessed via Microsoft Graph (agentic delegated auth)

scenarios/career-coach/.env.template:26

  • The Node.js sample configuration is missing the required ENABLE_A365_OBSERVABILITY switch and leaves the required observability log variables blank/commented. The sample template requires active defaults for all four observability settings so local and deployed behavior is explicit.
# --- A365 observability ------------------------------------------------------
# Enable the observability exporter. Default false = console-only exporter.
# MUST be true in any deployed / dev-tunnel environment or MAC Activity stays empty.
ENABLE_A365_OBSERVABILITY_EXPORTER=false
# When true, the sample demos a custom token resolver + token cache; otherwise it
# uses the built-in AgenticTokenCache from the observability hosting package.
Use_Custom_Resolver=true
# Optional exporter logging. One of 'info' | 'warn' | 'error'. Defaults to silent.
# Uncomment OTEL_LOG_LEVEL too when debugging export failures.
A365_OBSERVABILITY_LOG_LEVEL=
# OTEL_LOG_LEVEL=INFO

scenarios/career-coach/verify-userstate.ps1:10

  • The comment says this configuration matches .env, but only the host does; custom SP_SITE_PATH and SP_LIST_USER_STATE values are ignored. Users following the README's supported rename/custom-site path will query the wrong site or list. Read all three values from the corresponding environment variables.
    scenarios/career-coach/README.md:50
  • The PR description says no admin consent is required, but this README correctly requires admin consent for delegated Sites.ReadWrite.All and User.Read.All, and graph-service.ts requests those scopes. These are materially different setup requirements; update the PR description/auth claims to match the implementation and README.
**Microsoft Entra (Azure AD) app registration for the agent**
- The A365 platform provisions one for you when you create a hosted-agent project. That app's client ID + secret + tenant ID go into `.env` under `connections__service_connection__settings__*`.
- **Required delegated Graph scopes (with admin consent):**
  - `Sites.ReadWrite.All`
  - `User.Read.All`

scenarios/career-coach/src/index.ts:155

  • Every notification rescans a ten-minute window and treats all rows in that window as affected. If user A completes a course and user B completes one five minutes later, B's notification causes both A and B to be messaged again; Graph retries produce the same duplication. Track the actual changed item/resource data when available, or persist a per-item LastUpdated watermark/idempotency key so each portal update is processed once.
  const usersToNotify = new Map<string, any>();
  for (const row of rows) {
    const f = row.fields || {};
    const lastUpdated = f.LastUpdated ? new Date(f.LastUpdated) : null;
    if (!lastUpdated || isNaN(lastUpdated.valueOf())) continue;
    if (lastUpdated < cutoff) continue;
    const aad = String(f.UserAADId || '').trim();
    if (!aad) continue;
    // Keep the newest row per user (avoid double-DM if a user has multiple recent changes).
    const existing = usersToNotify.get(aad);
    if (!existing || new Date(existing.LastUpdated ?? 0) < lastUpdated) usersToNotify.set(aad, f);

scenarios/career-coach/src/career-coach-service.ts:53

  • These runtime read helpers receive an agentic Graph client, but site/list resolution calls getSiteId() and getListIdByName(), which each create the file-backed MSAL developer client. Consequently normal and proactive turns still require .mstoken-cache.json, fail after that cache expires or in deployment, and resolve metadata under the developer rather than the current agentic identity. Thread the supplied client through all site/list lookup helpers so the runtime path is actually agentic-only.
export async function readCompetencyFramework(graph: MsGraphClient, siteId?: string): Promise<CompetencyFrameworkRow[]> {
    const site = siteId ?? await getSiteId();
    const listId = await getListIdByName(site, SP_CONFIG.lists.competencyFramework);

scenarios/career-coach/src/llm-tasks.ts:100

  • The prompt requests five questions, but this code only truncates the response; it accepts zero to four questions, invalid MCQ choice counts, and missing answer keys. Since passing is hard-coded as score >= 4, a short model response can make the quiz impossible to pass. Validate exactly five questions (including the 3/2 type split, four choices, and nonempty keys) and retry or use a deterministic fallback before caching/rendering it.
    const json = await chatJson<{ questions: QuizQuestionWithKey[] }>(system, user, { temperature: 0.7 });
    const questions = Array.isArray(json?.questions) ? json.questions : [];
    // Normalize/fill missing fields defensively.
    return questions.slice(0, 5).map((q, i) => ({

scenarios/career-coach/src/scripts/backup-and-reset-user.ts:106

  • Each backupAndClearList call deletes rows immediately, while the backup file is not written until all three calls finish. A later Graph error, process interruption, or filesystem failure therefore leaves already-deleted data with no backup, contrary to the command's stated guarantee. Read all three datasets and atomically persist the snapshot first; only then begin deletions, reporting any partial cleanup separately.
        lists: {
            userState:            await backupAndClearList(graph, siteId, SP_CONFIG.lists.userState,            userAADId),
            learningPortalStatus: await backupAndClearList(graph, siteId, SP_CONFIG.lists.learningPortalStatus, userAADId),
            quizResponses:        await backupAndClearList(graph, siteId, SP_CONFIG.lists.quizResponses,        userAADId),

scenarios/career-coach/README.md:17

  • The sample README does not follow the repository README template: it starts with a table of contents and omits or reorders required sections such as “What This Sample Demonstrates,” “Authentication + Identity,” “Environment Configuration,” “Deploying the Agent,” “Observability,” and “Additional Resources.” Restructure it to the mandated section order while preserving the scenario-specific setup and walkthrough details.
## Table of contents

1. [Prerequisites](#1-prerequisites)
2. [Clone + install](#2-clone--install)
3. [Configure `.env`](#3-configure-env)

scenarios/career-coach/src/index.ts:21

  • Continuing after an uncaughtException is unsafe because Node may be left with partially mutated application or storage state; this handler also prevents a process supervisor from restarting the service cleanly. Log the exception and terminate with a nonzero status instead of claiming the server remains usable.
process.on('uncaughtException', (err) => {
  console.error('Uncaught exception (ignored, server stays up):', err?.message ?? err);
});

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 38 changed files in this pull request and generated 5 comments.

@Akshit0022 Akshit-MSFT (Akshit0022) left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 38 changed files in this pull request and generated 4 comments.

Suppressed comments (8)

Previously missed (1) — in code that hasn't changed since the last review.

scenarios/career-coach/src/subscription-manager.ts:176

  • After a subscription expires or is deleted, renewal returns 404 forever because each interval retries the same persisted ID. The proactive flow remains disabled until the process restarts. Detect terminal renewal failures, discard the stale state, and create a fresh subscription while retaining retry behavior for transient failures.

scenarios/career-coach/src/career-coach-service.ts:53

  • This read path receives an agentic Graph client, but resolves both IDs through helpers that construct the file-cached MSAL client. A deployed runtime without .mstoken-cache.json therefore fails before the agentic client is used. Pass the supplied client through ID resolution (and do the same in the other runtime CRUD helpers) so runtime access is actually agentic-only.
export async function readCompetencyFramework(graph: MsGraphClient, siteId?: string): Promise<CompetencyFrameworkRow[]> {
    const site = siteId ?? await getSiteId();
    const listId = await getListIdByName(site, SP_CONFIG.lists.competencyFramework);

scenarios/career-coach/src/sharepoint-tools.ts:238

  • These optional filters make the generic tool capable of returning every employee's UserState or QuizResponses row. Prompt instructions are not an authorization boundary, so a manipulated model call can expose private career data. Derive the caller's AAD ID from RunCtx and enforce owner scoping for sensitive-list reads and writes in code.
    scenarios/career-coach/src/agent.ts:270
  • The save action has no in-flight or idempotency guard. Two clicks can run concurrent read-then-create operations, both observe no existing row, and create duplicate UserState records; later reads then select an arbitrary duplicate. Add a server-side operation/idempotency key and disable or deduplicate repeated submissions before starting this detached work.
        handleSavePlanSubmit(context, this.authorization, {
          userAADId, displayName, roleTitle, targetRoleId, groups, ratings,
        }).catch((err) => {
          console.error('[SavePlan] Deferred save failed:', (err as any)?.message ?? err);

scenarios/career-coach/src/index.ts:150

  • Every notification received during this ten-minute window selects the same recently updated rows again. Graph retries or another user's update therefore resend quizzes to users already notified, while their plan remains quiz-pending. Persist a per-row/version watermark (or otherwise deduplicate notification processing) instead of using a sliding wall-clock window.
    const lastUpdated = f.LastUpdated ? new Date(f.LastUpdated) : null;
    if (!lastUpdated || isNaN(lastUpdated.valueOf())) continue;
    if (lastUpdated < cutoff) continue;

scenarios/career-coach/src/scripts/backup-and-reset-user.ts:74

  • Rows are deleted before any backup file is written. If fetching a later list, deleting a later row, creating the directory, or writing the JSON fails, earlier data has already been removed with no snapshot on disk. Fetch all three lists and atomically persist the complete backup before beginning deletion.
    // Delete each row.
    let ok = 0, fail = 0;
    for (const row of mine) {
        try {
            await graph.api(`/sites/${siteId}/lists/${listId}/items/${row.id}`).delete();

scenarios/career-coach/src/llm-tasks.ts:100

  • Slicing only caps the response; it does not enforce five valid questions, three MCQs, four choices, unique IDs, or nonempty answer keys. A malformed/short LLM response can render an impossible quiz while passing still requires four correct answers. Validate the complete schema and retry or fail gracefully before caching/rendering it.
    const json = await chatJson<{ questions: QuizQuestionWithKey[] }>(system, user, { temperature: 0.7 });
    const questions = Array.isArray(json?.questions) ? json.questions : [];
    // Normalize/fill missing fields defensively.
    return questions.slice(0, 5).map((q, i) => ({

scenarios/career-coach/README.md:18

  • The sample README does not follow the repository README template: it omits the required standalone “What This Sample Demonstrates,” “Authentication + Identity,” “Environment Configuration,” “Deploying the Agent,” “Observability,” “Additional Resources,” “Trademarks,” and “License” sections and their prescribed order. Restructure the document while preserving the scenario-specific setup and walkthrough details.
## Table of contents

1. [Prerequisites](#1-prerequisites)
2. [Clone + install](#2-clone--install)
3. [Configure `.env`](#3-configure-env)
4. [First-time SharePoint setup](#4-first-time-sharepoint-setup)

Comment thread scenarios/career-coach/src/agent.ts Resolved
Comment thread scenarios/career-coach/.gitignore Resolved
Comment thread scenarios/career-coach/src/index.ts Resolved
Comment thread scenarios/career-coach/src/agent.ts Resolved
An Agent 365 AI Teammate that helps employees set a target role, maps skill gaps, recommends courses, quizzes them proactively via Graph change notifications, and drafts the manager wrap-up email. Hybrid pro-code: deterministic TypeScript for all card submits/writes/business rules; focused LLM sub-calls for conversation, quiz generation, grading, and email prose. SharePoint Lists for state, Microsoft Graph (agentic delegated) for triggers + mail. Includes Copilot review fixes: idempotent quiz submit, no-tools email summarization (prompt-injection safe), real token usage, admin-consent scope docs. Placeholder GUIDs; no secrets or tenant-specific values.

@Akshit0022 Akshit-MSFT (Akshit0022) left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’ve updated the PR based on all the review points and addressed the comments.

…, WorkIQ tools

Brings the career-coach scenario up to the current Agent 365 SDK and
observability pattern (matches microsoft#339), and adds the features built since
the first revision.

Observability
- Migrate from the preview @microsoft/agents-a365-observability* packages
  to the @microsoft/opentelemetry distro (src/telemetry.ts).
- Export over the S2S route with an app-only token for the runtime agent
  identity (src/observability/app-token-resolver.ts: blueprint FMI
  assertion -> agent identity -> Observability API; validated, cached,
  never logged). Removes the delegated token cache (src/token-cache.ts).
- gen_ai.agent.id comes from activity.recipient.agenticAppId; blueprint id
  is metadata only. invoke_agent / chat / execute_tool spans on every
  path (LLM turns, card actions, typed shortcuts, email, proactive),
  with channel, caller and a stable conversation id (email threads).

Features
- Email channel: agent converses by email (org members only, bounces
  ignored), thread-scoped history, "Save it" saves the proposed plan via
  the same code as the Teams button (email-conversation.ts,
  email-plan-store.ts).
- Goal lifecycle: one UserState row per goal (Active / Completed /
  Replaced) with history; completion marks a goal Completed only after the
  email is sent; "show my previous goals" tool.
- WorkIQ MCP (Mail, Calendar V2 servers in ToolingManifest.json); WorkIQ
  SharePoint is read-only so list writes stay behind the code guards.
- Write guards: LLM cannot mark progress, edit finished goals, create a
  second active goal, or switch role on a goal with progress; SharePoint
  tools are owner-scoped to the caller.
- Reliability: card actions run deferred with user-visible failure
  handling and safe card unlock; typed shortcuts for sync / completions;
  LLM retries with history rollback.

Packages: @microsoft/agents-a365-* 2.x, @microsoft/opentelemetry,
@openai/agents 0.7, openai 6, zod 4; npm overrides no longer needed.

Docs: README keeps the template sections and is updated for the new
architecture (auth, deploy, troubleshooting); docs/design.md and the
code walkthrough rewritten; .env.template regenerated.

Removed: verify-userstate.ps1 (debug helper no longer used).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Akshit0022

Akshit-MSFT (Akshit0022) commented Oct 22, 2026 •

Copy link
Copy Markdown
Author

The recent update brings the Career Coach up to the current Agent 365 SDK and observability pattern, and adds the features built since the first revision. The PR description has been refreshed to match.

1. Observability — S2S route with an app-only token (matches #339)

Change Where
Migrated from the preview @microsoft/agents-a365-observability* packages to the @microsoft/opentelemetry distro, initialised before the agent/LLM modules load so OpenAI Agents auto-instrumentation can patch them. src/telemetry.ts, src/index.ts
Export uses the S2S route with an app-only token for the runtime agent identity: the blueprint credential issues an FMI assertion for the agent identity, which exchanges it for an Observability API token. The token is validated (no scp, matching client / tenant / audience), cached until near expiry, shared across concurrent calls, backs off after failure, and is never logged. src/observability/app-token-resolver.ts
Removed the delegated token cache. src/token-cache.ts (deleted)
gen_ai.agent.id = runtime agent identity from activity.recipient.agenticAppId; the blueprint id is carried only as blueprint metadata, and an env fallback equal to the blueprint id is ignored. src/agent.ts, src/client.ts
invoke_agent / chat / execute_tool spans on every path — LLM turns, card actions, typed shortcuts, email and proactive turns — with input/output messages, caller, channel, session label and a stable conversation id (one email thread = one conversation). src/agent.ts (runObserved, startInvokeAgentScope), src/client.ts (InferenceScope), src/observability/conversation-id.ts

2. New / upgraded features

  • Email channel — the AI Teammate converses by email. Only organisation members get a reply (guests, external senders and bounces are ignored); history is per email thread; replying "Save it" saves the proposed plan through the same savePlan code as the Teams button. Quizzes stay in Teams. (src/email-conversation.ts, src/email-plan-store.ts
  • Goal lifecycle — one UserState row per goal (Active / Completed / Replaced), so finished goals are kept as history instead of being overwritten. A goal is marked Completed only after the completion email is sent, so a failed send can be retried. New listPreviousGoals tool and 1:1-prep support for finished goals. Legacy rows map automatically; npm run setup:sharepoint adds the new columns.
  • WorkIQ MCP — Mail and Calendar (V2 servers) in ToolingManifest.json, attached per turn and never blocking it. The WorkIQ SharePoint server, if attached, is filtered to read-only tools so every list write still goes through the guarded tools.
  • Write guards (src/sharepoint-tools.ts — the model cannot mark a course Complete, record a passed quiz or raise a skill level (only the quiz grader can); lifecycle columns are stripped from model writes; finished goals are read-only; no second active goal; no role switch on a goal with progress. SharePoint tools are scoped to the caller's own rows (activity.from.aadObjectId).
  • Reliability — card actions acknowledge immediately and run deferred; on failure the user is told what failed and the card is unlocked only when resubmitting is safe. Typed shortcuts (check my progress, I completed …) run deterministically. LLM calls retry transient network errors and roll back the unanswered message from history.

3. Package upgrades

Package Before Now
@microsoft/agents-a365-notifications, -runtime 0.1.0-preview.125 ^2.0.0
@microsoft/agents-a365-tooling, -tooling-extensions-openai — ^2.0.0 (WorkIQ MCP)
@microsoft/agents-a365-observability, -extensions-openai, -hosting 0.1.0-preview.125 replaced by @microsoft/opentelemetry ^1.4.0
@openai/agents ^0.1.11 ^0.7.2
openai ^4.77.0 ^6.26.0
zod, axios, @opentelemetry/resources — added

The npm overrides block is no longer needed and was removed.

4. Docs and config

  • README keeps the template sections added in review (What this sample demonstrates, Authentication + identity, Deploying the agent, Additional resources, Support · Contributing · Trademarks · License) and is updated for the new architecture: three-credential auth model (agentic workload / app-only telemetry / device-code scripts), registration with a365 setup all --aiteammate, endpoint update with --update-endpoint … --m365, agent-user licensing, email walkthrough (§8.8), goal lifecycle (§8.7) and new troubleshooting rows. Package links point at npm (per Replace links to SDK GitHub repositories with links to specific packages used by samples #347); Learn links have no language code (per Don't include language code to links to LMC #348).
  • docs/design.md rewritten: purpose, principles, components, data model + goal lifecycle, key flows, security and trust boundaries, reliability, observability, identity, deployment, decisions.
  • AGENT-CODE-WALKTHROUGH.md rewritten in request order.
  • .env.template regenerated: S2S observability settings and the runtime-agent-id vs blueprint-id distinction documented; stale Use_Custom_Resolver and BEARER_TOKEN_MCP_SHAREPOINTREMOTESERVER removed.
  • .gitignore also ignores .email-plans.json and .a365-workspace-detection.local.json.
  • Removed verify-userstate.ps1 (debug helper no longer used).

5. Note on an earlier review thread

The email-injection thread (src/agent.ts, "email body is untrusted") was resolved with a no-tools summarisation path. Email is now a full conversation channel, so that path is replaced by these controls: sender must be an organisation Member (Graph lookup), bounces ignored, all SharePoint tools owner-scoped to the sender, progress/lifecycle writes enforced by code guards rather than the prompt, and Mail/Calendar rules that forbid acting on instructions embedded in emails, documents or tool results and limit recipients to the requesting user (plus their manager only when explicitly asked). Happy to tighten further if reviewers prefer.

Not included (local / generated)

.env, a365.config.json, a365.generated.config.json, .a365-workspace-detection.local.json, runtime state (.mstoken-cache.json, .proactive-*.json, .sp-subscription.json, .email-plans.json), logs, package-lock.json, manifest/manifest.zip, editor/agent-skill folders. manifest/ keeps placeholder IDs.

Verification

  • ✅ Clean npm install (no lockfile, no overrides, no --legacy-peer-deps) → npm run build and npx tsc --noEmit pass.
  • ✅ a365-code-validator static scan: 0 findings (exporter on, S2S route, app-only token resolver, runtime identity binding, invoke_agent / chat / execute_tool / output messages).
  • ✅ Docs: 12/12 relative links and all README TOC anchors resolve.
  • ✅ git diff --check clean.
  • ✅ Diff scan: no tenant / blueprint / agent / user identifiers, secrets or tokens. The only GUIDs are public Microsoft resource IDs (Graph, Messaging Bot API, Observability API, WorkIQ Mail/Calendar audiences).

Live-tenant validation evidence (E2E, Defender, Purview, admin center) will follow in a separate comment.

@Akshit0022

Copy link
Copy Markdown
Author

Validation evidence: Career Coach scenario (A365 observability + WorkIQ MCP)

The agent under test ran locally behind a dev tunnel from a build of the same src/ that is in fc93651 (byte-for-byte; verified before writing this comment), registered as an AI Teammate on a fresh blueprint and instance.


1. E2E playbook (AutoQAgent): 5 / 5 passed

image

Run finished 2026-10-22 16:42:23Z (duration 8m 46s).

Test Result Evidence
Agent exists ✅ PASS Teams people search found the Career Coach AI agent and opened the chat.
Agent responds ✅ PASS After "hi", the agent replied "Welcome back, …!" with a career progress summary (active goal read from SharePoint).
Tool execution (playbook label: MCP working) ✅ PASS The agent listed its tools and queried the LearningCatalog list, returning two Natural Language Processing courses. This exercised the agent's guarded SharePoint function tools; the WorkIQ Mail and Calendar MCP servers were attached on every turn of the run but were not invoked by this test.
O11y → Defender ✅ PASS CloudAppEvents query filtered on the runtime Agent Identity returned 84 results.
O11y → Purview ✅ PASS Audit search for the agent UPN (2026-10-21 00:00 → 2026-10-22 23:30 UTC) completed with 135 results. The search is by UPN over a two-day window, and an earlier instance of this agent (deleted the same day) used the same UPN, so not every result is necessarily from the current instance.

2. Runtime exporter evidence (agent log, redacted aggregates)

Playbook window (≈16:33:37Z → 16:42:23Z):

Signal Count
User turns received 3
WorkIQ MCP servers attached per turn (mcp_MailTools, mcp_CalendarTools) 3 / 3 turns, 0 connect failures
SharePoint tool failures 0
export-group succeeded / All spans exported successfully 9 / 9
GenAI spans delivered 30
Export failures (fail, insufficient_scope) 0

Whole agent session (2026-10-22 15:30Z → 18:00Z): 10 user turns; 27 / 27 export groups succeeded, all for the same runtime Agent Identity (no blueprint-tagged groups); 101 genAI spans delivered; 0 export failures; no app-only token acquisition failures. In every batch (36 / 36) the skipped spans are exactly the non-genAI spans the exporter filters out (log: N non-genAI spans filtered out); batches with no genAI spans log No eligible spans to export.

Five AADSTS700016 (application not found) rejections at 15:58Z–16:30Z referenced the identity of the previously deleted first instance (residual platform traffic), not the current Agent Identity. None occurred during the playbook window and none affected export.


3. A365 code validator: ✅ PASS

Check Result
Static validator ✅ 0 findings
Exporter activation ✅ enabled: true + enableObservabilityExporter (on unless ENABLE_A365_OBSERVABILITY_EXPORTER=false), @microsoft/opentelemetry initialised before the agent/LLM modules load
Export transport ✅ useS2SEndpoint: true
Observability token ✅ App-only token for the runtime Agent Identity via the blueprint FMI assertion → agent identity exchange; rejects scp tokens, checks client / tenant / audience, cached with refresh skew and failure back-off, never logged. No delegated (AgenticTokenCache) telemetry path
Identity binding ✅ gen_ai.agent.id = activity.recipient.agenticAppId; Blueprint ID only as blueprint metadata; an env fallback equal to the Blueprint ID is ignored
Semantic spans ✅ invoke_agent (input/output messages), chat (model, provider, finish reasons), execute_tool (code-handled steps) and output messages, on LLM turns, card actions, typed shortcuts, email and proactive turns — with caller, channel, session and conversation id (one email thread = one conversation)
Runtime export evidence ✅ 27 / 27 export groups succeeded for one runtime identity; 0 failures (section 2)
WorkIQ V2 ✅ Mail and Calendar V2 servers attach each turn; WorkIQ SharePoint (if attached) is filtered to read-only tools
Live Blueprint grants ✅ Read-only a365 query-entra inheritance: effective inheritance OK on 6 / 6 resources (Microsoft Graph, Agent Tools, WorkIQ Mail, WorkIQ Calendar, Messaging Bot API, Power Platform API). blueprint-scopes shows the Graph delegated scopes the sample needs, including Sites.ReadWrite.All
Local setup state ℹ️ a365.generated.config.json reports completed: true (local only — not committed)

4. Build and repository checks on fc93651

  • ✅ Clean npm install (no lockfile, no overrides, no --legacy-peer-deps)
  • ✅ npm run build and npx tsc --noEmit
  • ✅ Documentation: 12 / 12 relative links and all README TOC anchors resolve
  • ✅ git diff --check
  • ✅ Diff scan: no credentials, tokens, tenant / blueprint / agent / user identifiers, .env, logs, local state or build output. The only GUIDs are public Microsoft resource IDs (Graph, Messaging Bot API, Observability API, WorkIQ Mail/Calendar audiences)
  • ✅ PR CI: all completed checks pass (26); CLA signed

Defender / Purview CSV exports and Microsoft 365 admin-center Activity metrics are not claimed in this comment. The original screenshot, agent logs and any raw exports contain tenant and user identifiers and are not attached; only aggregate, redacted evidence is included here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

…tions)

SharePoint tools (sharepoint-tools.ts, sharepoint-column-map.ts)
- Restrict chat writes to the configured UserState list; the data tools only
  operate on the configured Career Coach site and only read the five Career
  Coach lists. Reference data, portal completions and quiz audit rows are
  written only by provisioning, the portal and the quiz grader.
- Scope personal lists (UserState, LearningPortalStatus, QuizResponses) to the
  caller's own rows in code; bind new plans to the authenticated caller and
  reject a different UserAADId; verify row ownership fail-closed before an
  update and never allow the owner to change.
- Canonicalize model field names to display names (case-insensitive, internal
  field_N included) before every guard; reject conflicting aliases.
- Make ManagerName / ManagerEmail app-managed so the model cannot redirect the
  completion email.

Concurrency (user-lock.ts, handlers.ts, quiz-cache.ts)
- Serialise every UserState read-modify-write per user: save, sync, quiz
  grading, milestone / completion persistence and the model's create / update.
- Share one in-flight quiz generation per user/course so a card is always
  graded against its own answer key.

Runtime auth and data (graph-service.ts, career-coach-service.ts)
- Pass the runtime Graph client through site / list discovery; the developer
  device-code client is now only used by scripts and background jobs.
- Preserve intentional clears on trusted app updates (null for optional
  columns; required columns are never cleared).

Subscriptions (subscription-manager.ts)
- Store a hash of PORTAL_EVENT_SECRET with the saved subscription and replace
  (delete + create) it when the secret, URL or resource changes.
- Re-create the subscription when renewal reports it gone or its saved expiry
  has passed, instead of retrying the dead id forever.

Prompt (client.ts): the model is told chat writes are limited to UserState and
that the app records quiz results, audit rows and manager details.

Docs: guard tables, security table, file maps and trade-offs updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The dependency-review check flags @azure/msal-node 5.x: "authorization code
flow may disclose parameters to an attacker-controlled endpoint" (high; fixed
in 7.1.0). The sample only uses PublicClientApplication with the device-code
and silent flows for the setup / seed scripts; both work unchanged on 7.1.1
(type-check clean, silent flow and API surface verified).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Akshit0022

Copy link
Copy Markdown
Author

Addressed all 11 open threads from the review of fc93651 in 52223e5, and fixed the dependency-review failure in e8f6d35.

Review threads

# File Concern Fix
1 career-coach-service.ts Unconditional plan PATCH; concurrent turns lose each other's changes Every UserState read-modify-write now runs under a per-user lock (src/user-lock.ts): save, sync, quiz grading, milestone / completion persistence, and the model's own create / update. The read happens inside the lock, so a sync can't overwrite a freshly persisted quiz result and two courses can't clobber each other. The lock is in-process (single-instance); docs/design.md notes that multi-instance needs a shared lock or row-version conditional writes
2 sharepoint-tools.ts ManagerEmail / ManagerName writable by the model, so the completion email could be redirected Both are now app-managed (APP_MANAGED_USERSTATE_FIELDS) and stripped from model writes, including when sent under an internal column name. They are populated only from the directory
3 sharepoint-tools.ts Conversational writes allowed on any list createListItem / updateListItem refuse any list but the configured UserState, and any site but the configured one. Reads are limited to the five Career Coach lists. Reference data, portal completions and quiz audit rows are written only by provisioning, the portal and the deterministic quiz grader. The system prompt no longer tells the model to write QuizResponses
4 sharepoint-tools.ts Guards inspect display names; field_N internal names bypass them Both write tools first rewrite every key to the column's display name (canonicalizeFields: case-insensitive, matches display and internal names), then run the strip / ownership / lifecycle / progress guards. Two keys addressing the same column are rejected
5 sharepoint-tools.ts Model-supplied UserAADId takes precedence on create A new plan is always created for the authenticated caller; a different UserAADId is rejected and the caller's id is persisted (no orphaned rows). With no authenticated user, personal data is refused
6 sharepoint-tools.ts Row ownership not verified before update updateListItem reads the row first and fails closed if it can't be read or isn't the caller's; UserAADId can never be changed; itemId must be numeric
7 graph-service.ts Site / list discovery uses the developer's MSAL client even with a runtime client getSiteId(graph?) and getListIdByName(site, name, graph?) accept the runtime client, and every runtime path in career-coach-service.ts passes it, so save / sync / quiz / previous-goal flows no longer depend on .mstoken-cache.json. The device-code client stays the default only for scripts and explicitly developer-authenticated background jobs (subscription manager, startup cache warm-up, the portal-event handler)
8 quiz-cache.ts Overlapping turns can send different questions; the first card is graded against the second's answers getOrCreateQuiz shares one in-flight generation per user/course and keeps only the first result, so every card sent is graded against its own key. A failed generation doesn't poison the next attempt
9 sharepoint-column-map.ts Dropping every empty value also drops explicit clears (reset:milestones, re-saving a plan) toInternalFields takes { preserveClears }. Trusted app updates send null for an explicit '' / null on known, non-required columns (read from the list schema), so reopening a goal clears PlanCompletedDate and re-saving can clear ManagerAsks. Creates never send clears; model writes keep the old drop-empties behaviour
10 subscription-manager.ts A rotated PORTAL_EVENT_SECRET is ignored because the old subscription is renewed The saved subscription stores a SHA-256 of the secret (never the secret itself). A mismatch, URL / resource change, or a state file with no fingerprint deletes the old subscription and creates a new one instead of renewing it
11 subscription-manager.ts An expired or deleted subscription is retried forever When renewal reports 404 / ResourceNotFound, or the saved expiry has passed, the timer re-enters ensureSubscription() to create a fresh one (guarded against re-entry). Transient errors still retry on the next tick

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found four correctness issues: two P1 issues involving backup data loss and goal lifecycle races, and two P2 issues involving stale quiz submissions and duplicate completion emails. Details are inline. All four were reproduced against unchanged source with mocked I/O; live tenant behavior was not verified.

Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The layering is clear and the SharePoint write guards are in the right place.

18 things to address before this merges; details inline.

Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/.env.template Resolved
Comment thread scenarios/career-coach/.env.template Resolved
Comment thread scenarios/career-coach/README.md Resolved
Comment thread scenarios/career-coach/README.md Resolved
Comment thread scenarios/career-coach/README.md Resolved
Integrity: quiz only counts for courses on the current plan; grading outages abort instead of recording a failed attempt; graded marker set before the result card; stale model writes can no longer undo earned progress; plan courses must carry a catalog courseId; completion email claimed once per goal and closes only the goal it was sent for; minutes summed for the plan's courses only; cached quizzes cleared when a goal is saved/replaced.

Security: Mail/Calendar recipient allowlist enforced in code (mail-guard); email text and quoted history fenced as untrusted; email thread key includes agent instance and verified sender; failed-write logs carry column names only; PORTAL_EVENT_SECRET validated (placeholder/short rejected) and shipped empty; BEARER_TOKEN shipped empty.

Identity/reliability: background Graph work (subscription, notification scan, warm-up) runs as the agent user via getAgenticUserToken with ids captured from the first turn; bounded-backoff subscription retry; server-side LastUpdated filter; production fails fast without a valid secret and service connection.

Scripts/docs: backup writes the file before deleting; helper scripts honour TEST_USER_AAD_ID; prompt course-match rule aligned with the matcher; README, design and walkthrough updated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scenario is well structured: deterministic handlers own the state transitions, SharePoint writes are guarded in one place, and Teams and email share that path. The critical flows are implemented carefully and the sample is close to something operators can run.

13 things to address before this merges; details inline.

Since my last review:

  • Fixed: Reject missing course IDs instead of skipping the progress guard (scenarios/career-coach/src/sharepoint-tools.ts)
  • Fixed: Do not record a grading outage as incorrect answers (scenarios/career-coach/src/handlers.ts)
  • Fixed: Mark an attempt recorded before sending its result card (scenarios/career-coach/src/handlers.ts)
  • Fixed: Limit completion time totals to courses in the completed goal (scenarios/career-coach/src/handlers.ts)
  • Fixed: Remove private career payloads from production error logs (scenarios/career-coach/src/sharepoint-tools.ts)
  • Fixed: Use the same course-matching rule for text ratings and cards (scenarios/career-coach/src/client.ts)
  • Fixed: Include the authenticated sender in email conversation keys (scenarios/career-coach/src/agent.ts)
  • Fixed: A quiz for a course that is not on the active goal still raises skill levels (scenarios/career-coach/src/handlers.ts)
  • Fixed: Shipped PORTAL_EVENT_SECRET placeholder is accepted as a valid webhook secret (scenarios/career-coach/.env.template)
  • Fixed: BEARER_TOKEN placeholder replaces the agentic MCP token, so WorkIQ tools never attach (scenarios/career-coach/.env.template)
  • Fixed: Webhook validation test in Troubleshooting sends a GET, which the endpoint never answers (scenarios/career-coach/README.md)
  • Fixed: TEST_USER_* is documented as the default for all helper scripts, but only mark:complete reads it (scenarios/career-coach/README.md)
  • Still open: Preserve earned progress when applying conversational updates (scenarios/career-coach/src/sharepoint-tools.ts). The new merge at scenarios/career-coach/src/sharepoint-tools.ts:396-401 matches earned records by courseId alone, although handlers.ts:314-325 stores one record per course/skill pair. For passed recor
  • Still open: Retry subscription creation after an initial startup failure (scenarios/career-coach/src/subscription-manager.ts). The new backoff does not cover list-lookup failures. scenarios/career-coach/src/graph-service.ts:379-386 catches every lookup error and substitutes an empty list; subscription-manager.ts:143-148 then
  • Still open: Proactive subscription and portal webhook run as the developer, not the agent (scenarios/career-coach/src/index.ts). The agentic background path is implemented, but scenarios/career-coach/src/graph-service.ts:240-247 still falls back to acquireTokenSilentForGraph whenever the provider or captured identity is missing
  • Still open: Identity section says runtime Graph is agentic, but the portal subscription is not (scenarios/career-coach/docs/design.md). scenarios/career-coach/docs/design.md:173-176 now describes agentic background authentication but restricts the developer-cache fallback to local use. graph-service.ts:240-247 has no production restri
  • Still open: Email body is executable input to Mail tools, with no recipient check in code (scenarios/career-coach/src/agent.ts). A recipient wrapper now exists, but its authorization check remains bypassable. In scenarios/career-coach/src/mail-guard.ts:29 and :72-80, the valid address mallory!alice@contoso.com is matched as the
  • Still open: Deployment docs omit that the real-time trigger runs on the developer's device-code token cache (scenarios/career-coach/README.md). A developer cache is no longer required for deployment: scenarios/career-coach/src/index.ts:28-33 wires the hosting connection's agentic-user token provider, and README.md:163-166, :239-241, and :547

Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/src/index.ts Resolved
Comment thread scenarios/career-coach/src/handlers.ts Resolved
Comment thread scenarios/career-coach/docs/design.md Resolved
Comment thread scenarios/career-coach/.env.template Resolved
Comment thread scenarios/career-coach/README.md Resolved
Comment thread scenarios/career-coach/README.md Resolved
Completion/goals: the completion is recorded before the summary card is rendered, so a card failure can't resend the email; the manager is looked up from the directory at send time (never the cached address) and the card only says the manager was copied if they were; replacing a goal creates the new row first and rolls it back if the old one can't be retired; plans with missing or unknown course ids are rejected before anything changes.

Identity/subscription: background Graph work never uses the developer cache in production (waits for the agent ids and retries); subscription setup is single-flight, each attempt is pinned to one identity, and a developer-made subscription is replaced by an agent-user one (deleted with its creator) once the ids are known; portal notifications are de-duplicated across batches.

Privacy/config: no user message text or display name in logs; observability export is opt-in (template ships false, only "true" enables); seed script adds only missing rows by key and fails non-zero on errors; README/design/walkthrough corrected (device-code prompt, production behaviour).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Akshit0022

Copy link
Copy Markdown
Author

Thanks Rick Brighenti (@rbrighenti) and Krishnadheeraj (@DheerajPannala). All 35 review threads are addressed: 22 in c773016 and 13 in def7eae. Summary below; each point was checked against the code first, and all were valid.

Round 1: 22 threads, fixed in c773016

Topic What was fixed
Backup script deleted rows before writing the backup Reads all lists, writes the backup file, then deletes; exits non-zero if any delete fails
Completion could close a replacement goal The originating goal id is carried through and re-verified before the goal is marked Completed
Quiz for a course not on the plan raised skills (2 threads) A quiz counts only for a course on the current plan, checked before any write; saving or replacing a goal clears cached quizzes
Duplicate completion emails from overlapping syncs In-process claim per user and goal; fresh state re-read; claim released in finally so failures can retry
Empty courseId bypassed the progress guard Id-less records count as unearned; every plan course needs a catalog courseId (fails closed); progress ignores id-less records
Stale model write could undo a passed quiz Passed courses are merged from the fresh row, skill levels never decrease, goals and overall are recomputed
Grading outage recorded a wrong attempt Grading throws on a missing result; nothing is persisted and no attempt is counted; the card is restored with the answers
markGraded ran after the card was sent Marked right after persistence, before sending
Failed first subscription was never retried Bounded backoff (30 s to 10 min) for retryable failures only, plus an immediate retry when the agent identity is captured
Completion minutes summed every portal row Summed for the completing plan's courses only, latest row per course
Failed-write logs dumped field contents Logs operation, status, error code and column names only; user text removed from logs
Prompt course-match rule contradicted the matcher Prompt aligned to the matcher's range-overlap rule and ranking
Email thread key had no sender Key is now agent instance + verified sender + conversation id
Developer-identity subscription and scan (3 threads) Background Graph work runs as the agent user via getAgenticUserToken, with ids captured from the first turn or env; server-side LastUpdated filter
Mail recipient limits were only in the prompt; dead summarizer Recipient allowlist enforced in code (mail-guard); email text and quoted history fenced as untrusted; dead code removed
PORTAL_EVENT_SECRET placeholder shipped Shipped empty; placeholder, empty and short values are rejected; production fails fast
BEARER_TOKEN placeholder shipped Shipped empty, with a local-dev-only note
README curl check used GET Replaced with Invoke-RestMethod -Method Post
TEST_USER_* claim was untrue Helper scripts honour it; mark:complete fails fast if it is unset

Round 2: 13 threads, fixed in def7eae

Topic What was fixed
Email resent if the completion card failed Completion is recorded before the card is rendered, so a card failure can't make the email resendable
Completion copied a stale manager Manager is looked up from the directory at send time; no cached fallback; if the lookup fails, only the employee is mailed
Card claimed the manager was emailed The footer reflects who actually received the mail
Old goal retired before the replacement existed New goal is created first; rolled back if retiring the old one fails
Plan saved with missing or unknown course ids Rejected before any write, with an explicit message (Teams and email)
Subscription setup could overlap or stay on the developer token Single-flight; each attempt pinned to one identity; a developer-made subscription is deleted with its creator and recreated as the agent
Production gate didn't stop the developer fallback (3 threads) Enforced in code: in production the background client waits for the agent ids and retries, never using the developer cache; docs corrected
Portal updates re-sent across webhook batches Per-user checkpoint; the checkpoint advances only after successful handling
Message text and display name in logs Removed
Observability export was on in the template Template ships false; only the exact string true enables export
Partial seed treated as complete Seed is keyed per row, inserts only missing rows, exits non-zero on failure
README overstated the device-code prompt Only setup:sharepoint signs in; the other scripts reuse its cache

Validation

  • tsc and npm run build pass; secret scan clean.
  • Fake-Graph behaviour checks pass for both rounds.
  • Live: after a restart with NODE_ENV=production, the agent renewed its Graph subscription as the agent identity, with no developer sign-in.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deterministic-handler design fits this workflow, and the lifecycle and concurrency code is careful. I would not merge it yet.

Seven things to address before this merges; details inline.

Since my last review:

  • Fixed: Proactive subscription and portal webhook run as the developer, not the agent (scenarios/career-coach/src/index.ts)
  • Fixed: Identity section says runtime Graph is agentic, but the portal subscription is not (scenarios/career-coach/docs/design.md)
  • Fixed: Record successful completion email delivery before sending its card (scenarios/career-coach/src/handlers.ts)
  • Fixed: Keep the old goal active until its replacement has been saved (scenarios/career-coach/src/handlers.ts)
  • Fixed: Refresh the manager recipient before sending the completion report (scenarios/career-coach/src/handlers.ts)
  • Fixed: Subscription setup can overlap, and a developer-token success skips the agent retry (scenarios/career-coach/src/subscription-manager.ts)
  • Fixed: Reject incomplete or unknown courses before saving a plan (scenarios/career-coach/src/handlers.ts)
  • Fixed: Remove private message text from production logs (scenarios/career-coach/src/agent.ts)
  • Fixed: Completion card says the manager was emailed even when they were not (scenarios/career-coach/src/handlers.ts)
  • Fixed: Production startup does not stop the developer Graph fallback (scenarios/career-coach/docs/design.md)
  • Fixed: Checked-in observability config exports career spans unless the flag is the string false (scenarios/career-coach/.env.template)
  • Fixed: Production gate doesn't stop background Graph work from running on the developer's cached sign-in (scenarios/career-coach/README.md)
  • Fixed: Only setup:sharepoint shows the device-code prompt; the other scripts need its cache (scenarios/career-coach/README.md)
  • Still open: Preserve earned progress when applying conversational updates (scenarios/career-coach/src/sharepoint-tools.ts). sharepoint-tools.ts:393-401 still merges earned records by courseId alone. handlers.ts:333-346 creates one record per course/skill pair. For a passed course covering two skills, the second earned reco
  • Still open: Retry subscription creation after an initial startup failure (scenarios/career-coach/src/subscription-manager.ts). graph-service.ts:412-419 still converts every list-lookup exception into an empty result. subscription-manager.ts:186-189 consequently returns retry:false for a transient lookup failure, bypassing the
  • Still open: Email body is executable input to Mail tools, with no recipient check in code (scenarios/career-coach/src/agent.ts). The recipient wrapper remains bypassable: mail-guard.ts:29 and :72-80 parse mallory!alice@contoso.com as the allowed substring alice@contoso.com. recipientViolation then accepts it, and :116 forwards
  • Still open: Deployment docs omit that the real-time trigger runs on the developer's device-code token cache (scenarios/career-coach/README.md). README.md:164-167, :241-249 and :551-557 now describe background agent-user authentication, the identity prerequisites, and the non-production-only developer fallback. Production background work no lo
  • Still open: Deduplicate portal updates across webhook deliveries (scenarios/career-coach/src/index.ts). Sequential deliveries are now deduplicated, but overlapping deliveries are not. index.ts:208 selects against lastNotified before awaiting delivery at :217, and advances the checkpoint only at :225. Tw
  • Still open: A partial seed is treated as complete on the next run (scenarios/career-coach/src/scripts/seed-reference-data.ts). scripts/seed-reference-data.ts:123-134 reads existing row keys, and :165-179 inserts only missing keys rather than skipping a nonempty list. Failed row inserts also produce a nonzero exit status at :2

Comment on lines +761 to +762
const convId = await this.proactive.storeConversation(turnContext);
proactiveRefs.setRef(aad, convId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: Keep group conversations out of the private proactive-reference map

Any message from an employee replaces their proactive destination with the current conversation, including a group chat. If Alice first establishes a personal chat and subsequently messages the coach in a group, her next portal update resumes that group conversation and posts her course or career-progress information to everyone there. Checking the employee's identity does not establish that the destination is private.

Evidence

agent.ts:757-762 checks only the sender's aadObjectId before calling storeConversation(turnContext) and setRef(aad, convId). proactive-refs.ts:49-55 overwrites the existing reference for that employee. agent.ts:781 and :801-819 subsequently resumes exactly that stored conversation and calls handleSyncProgress for the employee. Thus personal conversation D -> Alice sends a message in group G -> portal event produces Alice's progress/quiz in G. There is no conversation-type restriction in this path or the scenario manifests.

Suggested fix: Store proactive references only from personal conversations, and never replace them with group or channel references. Establish a personal conversation before sending career data when no private reference exists.

args: unknown,
resolveAllowed: () => Promise<Set<string>>,
): Promise<string | null> {
if (isReadToolName(toolName)) return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should fix: Restrict reads from the shared coach mailbox to the current employee

Read/search operations bypass authorization here, although every employee uses the same Career Coach mailbox. That mailbox contains other employees' incoming coaching messages and outgoing completion reports. A mailbox search in Bob's conversation can therefore retrieve Alice's private career content; the per-sender conversation key and SharePoint ownership checks do not protect this separate data path.

Evidence

ToolingManifest.json:3-9 registers the Mail server, and client.ts:427-447 attaches it to each conversation with only guardMailRecipients as its restriction. mail-guard.ts:93 returns immediately for read operations, before resolving the caller's allowed addresses; :97 also permits address-free arguments. The shared-mailbox identity is explicit in client.ts:379-382 and README.md:553. graph-service.ts:392-402 sends completion reports through /me/sendMail with saveToSentItems:true. After Alice completes a plan, an unrestricted Mail search/read during Bob's turn can return Alice's saved report without any employee-ownership check.

Suggested fix: Expose only the Mail operations this scenario requires. If mailbox reads are retained, enforce employee ownership on both the requested messages and returned content before passing that content into the conversation.

Comment on lines +465 to +470
async invokeAgent(prompt: string, ctx: RunCtx): Promise<string> {
// Add user message to history (once, before any retries)
this.conversationHistory.push({ role: 'user', content: prompt });

// Build input: format as AgentInputItem array
const input = this.conversationHistory.map(msg => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Serialize invocations of each cached conversation client

Overlapping messages in one conversation share this history and the same mutable connection collection. One invocation can replace or clear connections while another is still running. Its failure cleanup can also remove the other invocation's successful reply rather than its own unanswered message, leaving subsequent turns with incorrect context.

Evidence

client.ts:96-101 returns the same cached Client for a conversation; agent.ts:628-629 and :1053-1054 invoke it without per-client serialization. client.ts:428, :447 and :456 replace or clear the shared agent.mcpServers. Independently, the history race is deterministic: A pushes its message at :467; B pushes its message and successful reply at :494; A exhausts its retries and pop() at :516 removes B's reply. The locks in handlers.ts and sharepoint-tools.ts cover SharePoint writes, not this client lifecycle.

Suggested fix: Queue invocations per cached client, covering history updates, connection attachment, execution, cleanup, and rollback. Use a separate conversation-level queue rather than nesting these runs inside the existing non-reentrant UserState lock.

Comment on lines +132 to +136
res.status(202).json({ ok: true });
// Fire-and-forget: read recently-changed rows and DM affected users.
void handleGraphNotification(body).catch((err) => {
console.error('[Proactive] Async Graph notification handling failed:', (err as any)?.message ?? err);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Retain failed notification work after returning 202

The webhook acknowledges delivery before performing any SharePoint reads or proactive sends, but failures afterward are only logged. There is no queued retry. A final course-completion notification received during a temporary Graph or Teams outage can therefore disappear permanently, even after the dependency recovers; leaving the checkpoint unchanged only helps if another notification happens to arrive.

Evidence

index.ts:132 returns HTTP 202 and :134 starts handleGraphNotification asynchronously. Site/list failures return at :185-188, exhausted row-read failures return at :203-205, and unsuccessful proactive deliveries are only logged at :225-228. No retry is scheduled for any of these paths. Graph has already received a successful acknowledgement, and :173 limits later scans to ten minutes, so an otherwise isolated completion is not recovered automatically.

Suggested fix: Queue notification work before acknowledging it, and retry transient read or delivery failures with bounded backoff and a stable deduplication key. Preserve pending work independently of whether another list update occurs.

Comment on lines +499 to +503
if (OpenAIClient.isTransientNetworkError(error) && attempt < maxAttempts) {
const delayMs = 1000 * attempt;
console.warn(`Transient network error on attempt ${attempt}/${maxAttempts}, retrying in ${delayMs}ms:`, (error as any)?.message || error);
await new Promise(resolve => setTimeout(resolve, delayMs));
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Do not replay an entire conversation run after an external write

This retry restarts the original request even when an earlier step already sent an email or created a calendar event. A network failure while producing the final reply can therefore execute the same outbound action again, up to three times. Restricting recipients does not make these writes idempotent.

Evidence

client.ts:470-480 constructs input once, and :487-503 repeatedly calls run(this.agent, input, { context: ctx }) with that unchanged input. Only a successful final output is added to history at :494; intermediate successful actions are not retained for a retry. The Mail/Calendar capabilities attached at :427-447 include the user-requested sends described at :379-384. Scenario: 'email me my plan' successfully sends, the following inference request fails with ECONNRESET, and the retry starts again without the successful send in its input history.

Suggested fix: Limit retries to operations that are safe to repeat, or resume from recorded run state. Do not restart the whole run after an external write may have executed unless that write has a durable idempotency guard.

Comment on lines +783 to +788
// WRITE #2 — UserState update.
const previousOverall = userRecord.state.OverallProgress;
const nextState = summary.passed
? applyQuizPass(userRecord.state, input.courseId, quizResult, course)
: applyQuizFail(userRecord.state, input.courseId, quizResult);
await upsertUserState(graph, nextState, userRecord);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Reuse the recorded quiz attempt when retrying a failed plan update

The audit row is created before the plan update, but the submitted marker is set only after both operations. If the audit POST succeeds and the UserState PATCH fails, the restored card allows the same answers to be submitted again. That creates another audit row and increments the attempt number for one logical submission, double-counting its assessment and weak-topic history.

Evidence

handlers.ts:755 increments attempts, :768-778 appends QuizResponses, and :788 updates UserState. A failure at :788 skips markGraded at :799. agent.ts:249-262 restores the cached quiz whenever wasGraded is false, preserving its answers. Retrying therefore reaches another append and another bumpAttempts. For a 4/5 submission whose first audit POST succeeds but plan PATCH returns 503, recovery records attempts 1 and 2 for the same submission.

Suggested fix: Give each submission a stable attempt ID and persist its audit record idempotently. On recovery, reuse the recorded assessment and retry the pending plan update instead of appending and grading it again.

Portal row complete, or the user says "I completed X" → server generates 5 questions and keeps the answer key **server-side** → card without answers → submit → deterministic grading (short answers by one narrow LLM call) → pass = 4/5 → course Complete, skill level raised, progress recomputed → milestone / completion cascade → next queued quiz.

### 6.3 Completion
100% → LLM writes the email text, **code** sends it (To: user, Cc: manager) → on success the goal is marked Completed → the next conversation starts with the welcome card. The chat model is instructed never to send this email itself.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Completion email can go only to the manager and still close the goal

Section 6.3 says code sends the completion email To the employee and Cc the manager, and only then marks the goal Completed. If the employee address cannot be resolved, the send path moves the manager from Cc to To, treats that as success, and closes the goal. The employee never receives the wrap-up, and a later progress check will not retry it.

Evidence

design.md:122 says code sends the email "(To: user, Cc: manager)" and "on success the goal is marked Completed". handlers.ts:998-1000 calls getMyProfile and swallows any error, leaving userEmail unset (an empty mail and UPN also leaves it unset). handlers.ts:1009-1012 then runs if (to.length === 0 && cc.length > 0) { to.push(cc[0]); cc.length = 0; }. A manager lookup that already succeeded puts the manager in cc, so this makes them the only recipient. sendMail then sets sent = true (handlers.ts:1018-1019), and handlers.ts:1033 writes Completion100Fired and PlanStatus Completed. The card at handlers.ts:1070 has a branch for "sent to your manager" when userEmail is missing. Example: getMyManager returns manager@contoso.com, getMyProfile throws 429, the career wrap-up is mailed only to the manager, and the goal is closed so "check my progress" will not send the employee their copy.

Suggested fix: Either do not retarget To and do not mark the goal Completed when the employee address is missing, or change section 6.3 and the "manager copied" wording so they describe this fallback and that it still completes the goal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants