Skip to content

feat(msteams): add thread copilot approvals#66210

Closed
sudie-codes wants to merge 3 commits intoopenclaw:mainfrom
sudie-codes:codex/msteams-thread-copilot
Closed

feat(msteams): add thread copilot approvals#66210
sudie-codes wants to merge 3 commits intoopenclaw:mainfrom
sudie-codes:codex/msteams-thread-copilot

Conversation

@sudie-codes
Copy link
Copy Markdown
Contributor

What this changes

This adds a helper for long Teams channel threads so someone can quickly get the summary, decisions, and next steps from a conversation. It can also prepare follow-up actions like posting a summary, creating a poll, pinning something important, or sharing an artifact.

Why it matters

Busy threads become easier to understand and act on. People can get the important outcomes from a conversation quickly, and the assistant can help prepare the next step without acting publicly on its own.

Safety

  • thread-changing actions still require approval before they happen
  • approvals expire instead of hanging around forever
  • repeated requests for the same action are deduplicated so they do not fire twice

Notes

This branch carries the supporting approval groundwork it needs so the feature can be reviewed as a full experience.

Verification

  • targeted Teams thread and approval tests passed locally
  • targeted linting passed locally

@openclaw-barnacle openclaw-barnacle Bot added docs Improvements or additions to documentation channel: msteams Channel integration: msteams scripts Repository scripts agents Agent runtime and tooling size: XL labels Apr 13, 2026
@sudie-codes sudie-codes marked this pull request as ready for review April 13, 2026 23:24
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 13, 2026

Greptile Summary

Adds a durable approval flow for Microsoft Teams channel-thread side effects (post summary, create poll, pin message, upload artifact), backed by a new generic action-approval-runtime SDK module. Actions are gated behind an Adaptive Card sent to explicit approvers, with snapshot-hash–based deduplication and configurable expiry.

Confidence Score: 5/5

Safe to merge; all findings are P2 style and cleanup suggestions that do not affect correctness at runtime.

No P0/P1 issues found. The four inline comments are all P2: auth-check ordering that affects only audit-trail attribution (not security enforcement), an as never type escape, an unused function parameter, and a redundant function call. The core approval logic, deduplication, expiry, and authorization enforcement are sound.

extensions/msteams/src/thread-approval.ts — four P2 style issues noted inline.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 502-526

Comment:
**Authorization check runs after expiry handling**

The `approvalExpired` branch (lines 502–522) runs before the approver-ID guard (line 523), so any sender who clicks an expired card triggers `claimActionApprovalFlow` with their `ctx.senderId` recorded as `actorId` — embedding an unauthorized user's ID in the expiry audit record. Moving the approver check before the expiry block prevents this.

```suggestion
      if (!ctx.senderId || !loaded.snapshot.approverIds.includes(ctx.senderId)) {
        await ctx.respond.reply({ text: "You are not allowed to approve this action." });
        return { handled: true };
      }
      const now = Date.now();
      const approvalExpired =
        loaded.state.status === "expired" ||
        (loaded.state.expiresAt !== undefined && now >= loaded.state.expiresAt);
      if (approvalExpired) {
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 571

Comment:
**`as never` type escape**

`as never` is being used as a shortcut to satisfy the `JsonValue` parameter, but `never` is assignable to every type, making it a silent type bypass that hides future mismatches. Prefer an explicit widening cast or annotate `executeThreadAction`'s return type as `{ ok: true; result: JsonValue }` upstream so no cast is needed here.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 107-112

Comment:
**Unused `toolContext` parameter**

`normalizeApproverIds` accepts `toolContext` in its params object but never uses it — only `rawParams` is forwarded to `readMSTeamsApproverIds`. If context-based approver resolution is not planned, the parameter can be removed to avoid misleading callers.

```suggestion
function normalizeApproverIds(params: {
  rawParams: Record<string, unknown>;
}): string[] {
  return readMSTeamsApproverIds(params.rawParams);
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 279-283

Comment:
**`normalizeMediaLocalRoots` called twice**

`normalizeMediaLocalRoots(params.toolContext)` is invoked twice — once to check truthiness and again to read the value. Extracting it to a variable avoids the redundant call:

```suggestion
    const mediaLocalRoots = normalizeMediaLocalRoots(params.toolContext);
    return {
      ...base,
      operation,
      text,
      mediaUrl,
      ...(readTrimmedString(params.rawParams.filename)
        ? { filename: readTrimmedString(params.rawParams.filename) }
        : {}),
      ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
    };
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(msteams): dedupe and expire thread a..." | Re-trigger Greptile

Comment on lines +502 to +526
const now = Date.now();
const approvalExpired =
loaded.state.status === "expired" ||
(loaded.state.expiresAt !== undefined && now >= loaded.state.expiresAt);
if (approvalExpired) {
const claimed = claimActionApprovalFlow<ThreadActionSnapshot>({
taskFlow,
flowId: decoded.flowId,
expectedRevision: decoded.expectedRevision,
snapshotHash: decoded.snapshotHash,
actorId: ctx.senderId,
now,
});
await editApprovalCard(
ctx,
claimed.code === "expired" || approvalExpired
? "Approval expired."
: `Approval could not be applied (${claimed.code}).`,
);
return { handled: true };
}
if (!ctx.senderId || !loaded.snapshot.approverIds.includes(ctx.senderId)) {
await ctx.respond.reply({ text: "You are not allowed to approve this action." });
return { handled: true };
}
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.

P2 Authorization check runs after expiry handling

The approvalExpired branch (lines 502–522) runs before the approver-ID guard (line 523), so any sender who clicks an expired card triggers claimActionApprovalFlow with their ctx.senderId recorded as actorId — embedding an unauthorized user's ID in the expiry audit record. Moving the approver check before the expiry block prevents this.

Suggested change
const now = Date.now();
const approvalExpired =
loaded.state.status === "expired" ||
(loaded.state.expiresAt !== undefined && now >= loaded.state.expiresAt);
if (approvalExpired) {
const claimed = claimActionApprovalFlow<ThreadActionSnapshot>({
taskFlow,
flowId: decoded.flowId,
expectedRevision: decoded.expectedRevision,
snapshotHash: decoded.snapshotHash,
actorId: ctx.senderId,
now,
});
await editApprovalCard(
ctx,
claimed.code === "expired" || approvalExpired
? "Approval expired."
: `Approval could not be applied (${claimed.code}).`,
);
return { handled: true };
}
if (!ctx.senderId || !loaded.snapshot.approverIds.includes(ctx.senderId)) {
await ctx.respond.reply({ text: "You are not allowed to approve this action." });
return { handled: true };
}
if (!ctx.senderId || !loaded.snapshot.approverIds.includes(ctx.senderId)) {
await ctx.respond.reply({ text: "You are not allowed to approve this action." });
return { handled: true };
}
const now = Date.now();
const approvalExpired =
loaded.state.status === "expired" ||
(loaded.state.expiresAt !== undefined && now >= loaded.state.expiresAt);
if (approvalExpired) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 502-526

Comment:
**Authorization check runs after expiry handling**

The `approvalExpired` branch (lines 502–522) runs before the approver-ID guard (line 523), so any sender who clicks an expired card triggers `claimActionApprovalFlow` with their `ctx.senderId` recorded as `actorId` — embedding an unauthorized user's ID in the expiry audit record. Moving the approver check before the expiry block prevents this.

```suggestion
      if (!ctx.senderId || !loaded.snapshot.approverIds.includes(ctx.senderId)) {
        await ctx.respond.reply({ text: "You are not allowed to approve this action." });
        return { handled: true };
      }
      const now = Date.now();
      const approvalExpired =
        loaded.state.status === "expired" ||
        (loaded.state.expiresAt !== undefined && now >= loaded.state.expiresAt);
      if (approvalExpired) {
```

How can I resolve this? If you propose a fix, please make it concise.

expectedRevision: claimed.flow.revision,
snapshotHash: claimed.snapshotHash,
actorId: ctx.senderId,
result: executed.result as never,
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.

P2 as never type escape

as never is being used as a shortcut to satisfy the JsonValue parameter, but never is assignable to every type, making it a silent type bypass that hides future mismatches. Prefer an explicit widening cast or annotate executeThreadAction's return type as { ok: true; result: JsonValue } upstream so no cast is needed here.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 571

Comment:
**`as never` type escape**

`as never` is being used as a shortcut to satisfy the `JsonValue` parameter, but `never` is assignable to every type, making it a silent type bypass that hides future mismatches. Prefer an explicit widening cast or annotate `executeThreadAction`'s return type as `{ ok: true; result: JsonValue }` upstream so no cast is needed here.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +107 to +112
function normalizeApproverIds(params: {
rawParams: Record<string, unknown>;
toolContext: ThreadToolContext;
}): string[] {
return readMSTeamsApproverIds(params.rawParams);
}
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.

P2 Unused toolContext parameter

normalizeApproverIds accepts toolContext in its params object but never uses it — only rawParams is forwarded to readMSTeamsApproverIds. If context-based approver resolution is not planned, the parameter can be removed to avoid misleading callers.

Suggested change
function normalizeApproverIds(params: {
rawParams: Record<string, unknown>;
toolContext: ThreadToolContext;
}): string[] {
return readMSTeamsApproverIds(params.rawParams);
}
function normalizeApproverIds(params: {
rawParams: Record<string, unknown>;
}): string[] {
return readMSTeamsApproverIds(params.rawParams);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 107-112

Comment:
**Unused `toolContext` parameter**

`normalizeApproverIds` accepts `toolContext` in its params object but never uses it — only `rawParams` is forwarded to `readMSTeamsApproverIds`. If context-based approver resolution is not planned, the parameter can be removed to avoid misleading callers.

```suggestion
function normalizeApproverIds(params: {
  rawParams: Record<string, unknown>;
}): string[] {
  return readMSTeamsApproverIds(params.rawParams);
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +279 to +283
: {}),
...(normalizeMediaLocalRoots(params.toolContext)
? { mediaLocalRoots: normalizeMediaLocalRoots(params.toolContext) }
: {}),
};
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.

P2 normalizeMediaLocalRoots called twice

normalizeMediaLocalRoots(params.toolContext) is invoked twice — once to check truthiness and again to read the value. Extracting it to a variable avoids the redundant call:

Suggested change
: {}),
...(normalizeMediaLocalRoots(params.toolContext)
? { mediaLocalRoots: normalizeMediaLocalRoots(params.toolContext) }
: {}),
};
const mediaLocalRoots = normalizeMediaLocalRoots(params.toolContext);
return {
...base,
operation,
text,
mediaUrl,
...(readTrimmedString(params.rawParams.filename)
? { filename: readTrimmedString(params.rawParams.filename) }
: {}),
...(mediaLocalRoots ? { mediaLocalRoots } : {}),
};
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/thread-approval.ts
Line: 279-283

Comment:
**`normalizeMediaLocalRoots` called twice**

`normalizeMediaLocalRoots(params.toolContext)` is invoked twice — once to check truthiness and again to read the value. Extracting it to a variable avoids the redundant call:

```suggestion
    const mediaLocalRoots = normalizeMediaLocalRoots(params.toolContext);
    return {
      ...base,
      operation,
      text,
      mediaUrl,
      ...(readTrimmedString(params.rawParams.filename)
        ? { filename: readTrimmedString(params.rawParams.filename) }
        : {}),
      ...(mediaLocalRoots ? { mediaLocalRoots } : {}),
    };
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

if (ctx.activity?.type === "invoke" && ctx.activity?.name === "message/submitAction") {
const handled = await handleFeedbackInvoke(ctx, deps);
if (handled) {
return;
}

P1 Badge Re-add Teams SSO invoke handling in monitor loop

The invoke dispatcher now only branches for message/submitAction and adaptiveCard/action, so Teams OAuth callbacks (signin/tokenExchange and signin/verifyState) fall through without token exchange/persistence. Any tenant using Teams SSO login cards will stop completing the sign-in flow.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
Comment on lines +1338 to 1341
"undici": "^8.0.2",
"uuid": "^13.0.0",
"ws": "^8.20.0",
"yaml": "^2.8.3",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0 Badge Restore required OpenAI runtime dependency

The root dependency list no longer includes openai, but production code still imports it (for example src/agents/openai-transport-stream.ts and extensions/memory-lancedb/index.ts). On a clean install this causes runtime/module-resolution failures as soon as OpenAI transport or LanceDB embedding paths are loaded.

Useful? React with 👍 / 👎.

Comment thread package.json
"test:fast": "node scripts/run-vitest.mjs run --config vitest.unit.config.ts",
"test:force": "node --import tsx scripts/test-force.ts",
"test:gateway": "node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts",
"test:gateway": "node scripts/run-vitest.mjs run --config vitest.gateway.config.ts",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use existing Vitest config paths in npm scripts

Several test scripts now point to config files like vitest.gateway.config.ts and vitest.unit.config.ts, but this repo’s configs are under test/vitest/ (for example test/vitest/vitest.gateway.config.ts). As written, these scripts fail before tests run, which breaks local verification and CI lanes that rely on them.

Useful? React with 👍 / 👎.

Comment on lines +141 to 142
const pendingFile = getPendingUpload(uploadId);
if (pendingFile) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore FS-backed lookup for file-consent uploads

File consent handling now reads pending uploads only from in-memory state (getPendingUpload). The proactive CLI path persists uploads to disk via storePendingUploadFs for cross-process delivery, so accepted fileConsent/invoke callbacks after the sender process exits will be treated as expired and the upload will never complete.

Useful? React with 👍 / 👎.

@vincentkoc vincentkoc added the triage: dirty-candidate Candidate: broad unrelated surfaces; may need splitting or cleanup. label Apr 26, 2026
@openclaw-barnacle openclaw-barnacle Bot added the triage: external-plugin-candidate Candidate: plugin/capability may belong on ClawHub. label Apr 26, 2026
@vincentkoc vincentkoc self-assigned this Apr 29, 2026
@vincentkoc
Copy link
Copy Markdown
Member

Thanks for the Teams work. Closing this as a broad dirty external-plugin candidate rather than a safe core PR; please resubmit the smallest current-main Teams approval slice with owner context.

@vincentkoc vincentkoc closed this Apr 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling channel: msteams Channel integration: msteams docs Improvements or additions to documentation scripts Repository scripts size: XL triage: dirty-candidate Candidate: broad unrelated surfaces; may need splitting or cleanup. triage: external-plugin-candidate Candidate: plugin/capability may belong on ClawHub.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants