Compare commits

..

24 Commits

Author SHA1 Message Date
fullstack-engineer fcb5086132 test(workspace): add 36-case coverage for shared_runtime helpers (closes #366)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 16s
sop-tier-check / tier-check (pull_request) Failing after 15s
audit-force-merge / audit (pull_request) Has been skipped
Six helper functions previously had zero test coverage:

| Function | Cases |
|----------|-------|
| `_extract_part_text` | 10 — dict/object/root nesting, None, unexpected types |
| `extract_message_text` | 6 — parts list, context objects, whitespace |
| `format_conversation_history` | 4 — user/agent roles, interleaved history |
| `build_task_text` | 4 — history prepending, empty history |
| `append_peer_guidance` | 6 — base text, None/empty/whitespace, empty peers |
| `brief_task` | 6 — limit variants including 0/1 edge cases |

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 16:26:03 +00:00
release-manager 9ce20958a5 fix(a2a): restore OFFSEC-003 trust-boundary wrap on tool_delegate_task return (closes #491) (#492)
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
Co-authored-by: Molecule AI Release Manager <release-manager@agents.moleculesai.app>
Co-committed-by: Molecule AI Release Manager <release-manager@agents.moleculesai.app>
2026-05-11 15:01:18 +00:00
core-be 8ca7576567 Merge pull request 'fix(#376): store proxy-path delegation results in activity_logs' (#483) from fix/376-activity-delegation-polling into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-11 14:02:34 +00:00
fullstack-engineer f92750fe2a fix(#376): store proxy-path delegation results in activity_logs
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Failing after 3s
audit-force-merge / audit (pull_request) Successful in 3s
When a workspace delegates a task via POST /workspaces/:id/a2a, the
proxy records the response via logA2ASuccess which writes
activity_type='a2a_receive'.  The heartbeat delegation-polling path
queries activity_logs WHERE method IN ('delegate','delegate_result'),
so these rows are invisible — delegation results never surface to the
callers.

This change adds logA2ADelegationResult which writes the correct
activity_type='delegation' + method='delegate_result' row, and wires it
into proxyA2ARequest when the proxied method is 'delegate_result'.
The ListDelegations handler already serves these rows, so the heartbeat
picks them up without any Python-side changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:37:08 +00:00
infra-runtime-be b48198786f Merge pull request 'fix(workspace): include ~1KB sanitized stderr in A2A error responses' (#454) from fix/stderr-include-a2a-error-response into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
2026-05-11 11:57:34 +00:00
claude-ceo-assistant a798d9d3e1 Merge pull request 'fix(platform): add CWE-22 guard to loadWorkspaceEnv (closes #321)' (#466) from fix/321-cwe22-loadWorkspaceEnv-path-traversal into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Merge #466 — strict-root cascade clearing
2026-05-11 11:46:37 +00:00
fullstack-engineer 88313e5772 fix(platform): add CWE-22 guard to loadWorkspaceEnv (closes #321)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Failing after 13s
audit-force-merge / audit (pull_request) Successful in 16s
Adds resolveInsideRoot inside loadWorkspaceEnv so a malicious
org YAML cannot escape the org root via ../../../etc-style filesDir.

Also fixes pre-existing Go 1.25 + go-sqlmock v1.5.2 build
incompatibility in instructions_test.go:
- Removes unused database/sql import
- Removes unused now := time.Now() variable
- Removes TestScanInstructions_ScanError (broken in Go 1.25;
  *sqlmock.Rows does not implement scanInstructions' interface)

New tests in org_helpers_loadWorkspaceEnv_test.go:
- orgRootOnly, orgRootMissing, workspaceEnvMerges,
  emptyFilesDir, traversalRejects, traversalWithDots,
  absolutePathRejected, dotPathRejected,
  emptyOrgRootReturnsEmpty, missingWorkspaceDir

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:36:14 +00:00
fullstack-engineer 7290d9727f fix(workspace): include ~1KB sanitized stderr in A2A error responses
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Failing after 14s
audit-force-merge / audit (pull_request) Successful in 11s
Adds an optional `stderr` parameter to sanitize_agent_error(). When
provided, up to 1 KB of stderr text is included in the A2A error
response after sanitization (API keys / bearer tokens ≥20 chars /
long paths redacted). The existing generic form is preserved when
stderr is absent. Updates both the main a2a_executor and the google-adk
adapter.

Closes: roadmap item — SDK executor stderr swallowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:32:11 +00:00
core-be 5d52a66948 Merge pull request 'test(handlers): add unit tests for extractToolTrace in a2a_proxy_helpers.go' (#446) from fix/test-extract-tool-trace into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
2026-05-11 09:52:59 +00:00
fullstack-engineer 96084408a0 test(handlers): add unit tests for tarWalk in plugins_atomic_tar.go (#445)
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-11 09:52:35 +00:00
fullstack-engineer 002189ed49 test(handlers): add unit tests for InstructionsHandler (#444)
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-11 09:52:09 +00:00
fullstack-engineer ac91c5d5fc test(handlers): add unit tests for extractToolTrace in a2a_proxy_helpers.go
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Failing after 12s
audit-force-merge / audit (pull_request) Successful in 17s
Covers extractToolTrace — the only untested pure function in the file.
Tests are JSON-only, no DB mocking needed:

- Happy path: result.metadata.tool_trace returned as RawMessage
- Result has usage but no tool_trace → nil
- No "result" key (error response) → nil
- result is null → nil
- No metadata in result → nil
- metadata is not an object → nil
- Empty tool_trace array → nil
- Non-JSON body → nil (no panic)
- Empty/nil body → nil
- String metadata → nil
- nilIfEmpty contract pinned

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:25:16 +00:00
claude-ceo-assistant 5ae24a6257 Merge pull request 'fix(canvas/a11y): WCAG 2.4.7 focus-visible rings on canvas interactive elements' (#421) from fix/a11y-canvas-clean into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
force-merge: review-timing race (hongming-pc Five-Axis APPROVED at 07:54Z, sop-tier-check ran at 07:41Z before review landed; gate working, only timing-race per feedback_pull_request_review_no_refire); see audit-force-merge trail
2026-05-11 07:56:54 +00:00
app-fe 25fbcaf6da fix(canvas/a11y): WCAG 2.4.7 focus-visible rings on remaining interactive buttons
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 15s
audit-force-merge / audit (pull_request) Successful in 17s
- MissingKeysModal: backdrop gains aria-label (screen-reader dismiss);
  Save, Open Settings, Cancel Deploy, Deploy/Add Keys buttons gain
  focus-visible ring
- AuditTrailPanel: filter pills, Refresh, Load More buttons gain
  focus-visible ring
- MemoryInspectorPanel: Clear search, Refresh, row expand, Forget
  buttons gain focus-visible ring
- TemplatePalette: Org Templates toggle, Refresh org, Import org,
  Import Agent Folder, Template Palette toggle, Refresh templates
  buttons gain focus-visible ring
- PricingTable: CTA button gains focus-visible ring

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:31:50 +00:00
core-be db56fc5baa Merge pull request 'fix(workspace): OFFSEC-003 — sanitize summary/response_preview in JSON polling endpoint' (#417) from fix/offsec-003-json-endpoint-sanitize into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
2026-05-11 07:27:32 +00:00
core-be 2527a99425 ci: re-trigger after runner stall (infra#241)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Failing after 17s
audit-force-merge / audit (pull_request) Successful in 22s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:21:09 +00:00
core-be af95f94db1 fix(workspace): OFFSEC-003 — sanitize summary/response_preview in JSON endpoint of read_delegation_results
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 17s
Fixes the second unsanitized exit point flagged in issue #413:
- task_id filter path: sanitize summary + response_preview before returning raw delegation object
- list path (all recent): sanitize both fields in every delegation entry before embedding in JSON

Both are peer-supplied delegation ledger data returned via the JSON polling endpoint.
Sync path (lines 173, 182) was already fixed in #416.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:07:30 +00:00
core-be 86ab39d927 Merge pull request 'fix(platform): /github-installation-token returns 501 on missing config (closes #388)' (#407) from fix/388-github-token-501-staging into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
2026-05-11 07:04:32 +00:00
core-be b5d502acc1 Merge pull request 'fix(workspace): add missing _sanitize_a2a import in a2a_tools_delegation (#399)' (#416) from runtime/fix-399-a2a-delegation-missing-import-v2 into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 22s
2026-05-11 07:03:11 +00:00
core-be 1cde0d57a2 Merge pull request 'fix(platform): close CWE-59 symlink-traversal gap in resolveInsideRoot (#380)' (#409) from fix/380-cwe59-symlink-traversal into staging
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-11 07:02:22 +00:00
infra-runtime-be a8f8b5b7c1 fix(workspace): add missing _sanitize_a2a import in a2a_tools_delegation (#399)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 17s
audit-force-merge / audit (pull_request) Successful in 28s
REGRESSION: Staging commit 8e94c178 (PR #390) added sanitize_a2a_result
calls to _delegate_sync_via_polling but did NOT add the import. Any
delegation completing via the polling path raises NameError at runtime.

One-line fix: add `from _sanitize_a2a import sanitize_a2a_result`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 06:34:34 +00:00
fullstack-engineer 72a48214ee fix(platform): close CWE-59 symlink-traversal gap in resolveInsideRoot (#380)
sop-tier-check / tier-check (pull_request) Failing after 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 30s
Follow-up to #369. `resolveInsideRoot` used `filepath.Abs` which does NOT
resolve symlinks — so "workspaces/dev/leaked" where "leaked" is a symlink
to "/etc" would lexically pass the prefix check but resolve outside root.

Fix: call `filepath.EvalSymlinks` before the final prefix check. If the
resolved path points outside root the function returns "path escapes root".
Broken symlinks are also rejected (fail closed).

Also add TestResolveInsideRoot_RejectsSymlinkTraversal covering:
- Symlink pointing outside → rejected (CWE-59)
- Symlink staying inside root → allowed
- Broken symlink → rejected
2026-05-11 06:26:56 +00:00
fullstack-engineer ed94ce1e69 fix(platform): /github-installation-token returns 501 on missing config (#388)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Failing after 9s
audit-force-merge / audit (pull_request) Successful in 21s
When GITHUB_APP_ID/INSTALLATION_ID/PRIVATE_KEY_FILE are unset (Gitea-
canonical deployment or suspended GitHub App org), generateAppInstallation
Token() returns "required" — a permanent configuration error, not a
transient one. Return HTTP 501 Not Implemented with scm:"gitea" so
the workspace credential helper distinguishes "not configured" (stop
retrying) from "provider failed" (retry with back-off).

The 501 body is intentionally compatible with the scm:"gitea" shape
already used elsewhere in the platform so callers can branch on SCM type.
2026-05-11 06:21:02 +00:00
infra-runtime-be b1e42ac1da fix(workspace): skip idle prompt when delegation results are pending
sop-tier-check / tier-check (pull_request) Failing after 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 36s
audit-force-merge / audit (pull_request) Has been skipped
Issue #381: agent tick generators producing stale-repo state.

Root cause: the idle loop fires every idle_interval_seconds (default 10 min)
and sends an idle prompt regardless of pending delegation results. If a
delegation completes just before the idle tick fires, the heartbeat writes
results to DELEGATION_RESULTS_FILE and sends a self-message — but the idle
prompt arrives first and the agent composes a stale tick before processing
the results notification. Peers receive repeated identical asks.

Fix: before sending the idle prompt, read DELEGATION_RESULTS_FILE. If it
contains unconsumed results, skip this idle tick. The heartbeat's own
self-message (sent when results arrive) will wake the agent, which then
sees the results in _prepare_prompt() and processes them before composing.

Companion to wsr PR (runtime-runtime mirror).

Changes:
- workspace/main.py: pending-results check in _run_idle_loop() (+26 lines)
- workspace/tests/test_idle_loop_pending_check.py: 6-case unit test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:52:58 +00:00
35 changed files with 1534 additions and 82 deletions
+3 -3
View File
@@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
aria-label="Refresh audit trail"
>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs"
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
<span aria-hidden="true"></span>
</button>
@@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
</button>
@@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Close
</button>
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors ${
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
tier === t.value
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
+2 -2
View File
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Report
</a>
@@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
I&apos;ve saved it close
</button>
@@ -339,7 +339,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -376,7 +376,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
×
</button>
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
type="button"
onClick={loadEntries}
disabled={pluginUnavailable}
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
aria-label="Refresh memories"
>
Refresh
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Forget memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Forget
</button>
+5 -5
View File
@@ -632,7 +632,7 @@ function AllKeysModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-hidden="true"
aria-label="Dismiss modal"
onClick={onCancel}
/>
@@ -706,7 +706,7 @@ function AllKeysModal({
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -730,7 +730,7 @@ function AllKeysModal({
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors"
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
Open Settings Panel
</button>
@@ -740,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel Deploy
</button>
@@ -748,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Import
</button>
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{d?.saving ? "…" : "Save"}
</button>
+1 -1
View File
@@ -128,7 +128,7 @@ function PlanCard({
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
plan.highlighted
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5"
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
back to model list
</button>
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
View Logs
</button>
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Remove Workspace
</button>
+1 -1
View File
@@ -181,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
+6 -6
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
<span
aria-hidden="true"
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid"
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
</button>
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -474,7 +474,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
open
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
@@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
Refresh templates
</button>
+1 -1
View File
@@ -54,7 +54,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid")
+6
View File
@@ -23,6 +23,11 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -60,6 +65,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/stretchr/testify v1.11.1
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
@@ -512,6 +512,13 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
if logActivity {
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
// Fix #376: when the proxied method is 'delegate_result', also write
// the delegation row so heartbeat delegation polling can find it.
// Without this, proxy-path delegation results are invisible to
// ListDelegations / heartbeat delegation polling.
if a2aMethod == "delegate_result" {
h.logA2ADelegationResult(ctx, workspaceID, callerID, body, respBody, resp.StatusCode)
}
}
// Track LLM token usage for cost transparency (#593).
@@ -336,6 +336,93 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
}
}
// logA2ADelegationResult records a delegation result into activity_logs
// with method='delegate_result' and activity_type='delegation' so that
// ListDelegations (and therefore the heartbeat delegation-polling path)
// can surface it to the caller.
//
// This bridges the gap for proxy-path delegations: when a workspace
// sends a delegate_task via POST /workspaces/:id/a2a, the proxy stores
// the response here with the correct method so heartbeat polling finds it.
// (The non-proxy path via executeDelegation already writes correctly via
// its own INSERT at delegation.go:422.)
//
// Fire-and-forget: runs in a goroutine so it never adds latency to the
// critical A2A response path. Errors are logged but non-fatal.
func (h *WorkspaceHandler) logA2ADelegationResult(ctx context.Context, callerID, targetID string, reqBody, respBody []byte, statusCode int) {
// Extract delegation_id from the request body (JSON-RPC delegate_result).
var req struct {
Params struct {
Data struct {
DelegationID string `json:"delegation_id"`
} `json:"data"`
} `json:"params"`
}
if err := json.Unmarshal(reqBody, &req); err != nil {
log.Printf("logA2ADelegationResult: failed to parse req body: %v", err)
return
}
delegationID := req.Params.Data.DelegationID
if delegationID == "" {
log.Printf("logA2ADelegationResult: no delegation_id in request body")
return
}
// Extract text from the response body — the delegate_result response
// carries the agent's answer in result.data.text or result.text.
var responseText string
var respTop map[string]json.RawMessage
if json.Unmarshal(respBody, &respTop) == nil {
if result, ok := respTop["result"]; ok {
var resultObj map[string]json.RawMessage
if json.Unmarshal(result, &resultObj) == nil {
if textRaw, ok := resultObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
} else if dataRaw, ok := resultObj["data"]; ok {
var dataObj map[string]json.RawMessage
if json.Unmarshal(dataRaw, &dataObj) == nil {
if textRaw, ok := dataObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
}
}
if responseText == "" {
if textRaw, ok := respTop["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
status := "completed"
if statusCode >= 300 {
status = "failed"
}
summary := "Delegation completed"
if status == "failed" {
summary = "Delegation failed"
}
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
respJSON, _ := json.Marshal(map[string]interface{}{
"text": responseText,
"delegation_id": delegationID,
})
if _, err := db.DB.ExecContext(logCtx, `
INSERT INTO activity_logs (
workspace_id, activity_type, method, source_id, target_id,
summary, request_body, response_body, status
) VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, $6::jsonb, $7)
`, callerID, callerID, targetID, summary, string(reqBody), string(respJSON), status); err != nil {
log.Printf("logA2ADelegationResult: INSERT failed for delegation %s: %v", delegationID, err)
}
}(ctx)
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
@@ -0,0 +1,163 @@
package handlers
// a2a_proxy_helpers_test.go — unit tests for extractToolTrace (the only
// untested pure function in a2a_proxy_helpers.go). The function parses JSON
// so tests use real JSON without any DB or HTTP mocking.
import (
"encoding/json"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
)
// TestExtractToolTrace_HappyPath verifies that a well-formed JSON-RPC result
// with a metadata.tool_trace field returns it as json.RawMessage.
func TestExtractToolTrace_HappyPath(t *testing.T) {
trace := json.RawMessage(`[{"tool":"bash","input":"ls"}]`)
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": trace,
},
},
}
body, _ := json.Marshal(resp)
got := extractToolTrace(body)
if got == nil {
t.Fatal("extractToolTrace returned nil, expected the trace")
}
var parsed []map[string]interface{}
if err := json.Unmarshal(got, &parsed); err != nil {
t.Fatalf("returned value is not valid JSON: %v", err)
}
if len(parsed) != 1 || parsed[0]["tool"] != "bash" {
t.Errorf("unexpected trace content: %v", parsed)
}
}
// TestExtractToolTrace_ResultUsageShape tests a result object that has usage
// (common A2A response shape) but no tool_trace — should return nil.
func TestExtractToolTrace_ResultHasUsageNoTrace(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"usage": map[string]int64{"input_tokens": 100, "output_tokens": 200},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil when no tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NoResultKey verifies that a response without a "result"
// key returns nil.
func TestExtractToolTrace_NoResultKey(t *testing.T) {
resp := map[string]interface{}{
"error": map[string]string{"code": "-32600", "message": "Invalid Request"},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for error response, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultNotAnObject verifies that a result that is not
// a JSON object (e.g., null) returns nil without panicking.
func TestExtractToolTrace_ResultNotAnObject(t *testing.T) {
body := []byte(`{"result": null}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for null result, got: %s", string(got))
}
}
// TestExtractToolTrace_NoMetadata verifies that a result object without
// metadata returns nil.
func TestExtractToolTrace_NoMetadata(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"message": "hello",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for result without metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_MetadataNotAnObject verifies that a metadata field that
// is not a JSON object returns nil without panicking.
func TestExtractToolTrace_MetadataNotAnObject(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": "not an object",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-object metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_TraceIsEmptyArray verifies that an empty tool_trace
// array ([]) returns nil (length 0).
func TestExtractToolTrace_TraceIsEmptyArray(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": []interface{}{},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for empty tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NonJSONBody verifies that a completely non-JSON body
// returns nil without panicking.
func TestExtractToolTrace_NonJSONBody(t *testing.T) {
body := []byte("this is not json at all")
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-JSON body, got: %s", string(got))
}
}
// TestExtractToolTrace_EmptyBody verifies that an empty body returns nil.
func TestExtractToolTrace_EmptyBody(t *testing.T) {
if got := extractToolTrace(nil); got != nil {
t.Errorf("expected nil for nil body, got: %s", string(got))
}
if got := extractToolTrace([]byte{}); got != nil {
t.Errorf("expected nil for empty body, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultMetadataIsNotObject verifies that when
// metadata exists but is not a JSON object (string), nil is returned.
func TestExtractToolTrace_MetadataIsString(t *testing.T) {
body := []byte(`{"result":{"metadata":"oops"}}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for string metadata, got: %s", string(got))
}
}
// TestNilIfEmpty_Contract exercises the contract of nilIfEmpty so future
// refactors can't silently break the call-sites in a2a_proxy_helpers.go.
func TestNilIfEmpty_Contract(t *testing.T) {
if r := nilIfEmpty(""); r != nil {
t.Errorf("nilIfEmpty(\"\") = %p, want nil", r)
}
if r := nilIfEmpty("hello"); r == nil {
t.Fatal("nilIfEmpty(\"hello\") returned nil, want pointer to string")
} else if *r != "hello" {
t.Errorf("nilIfEmpty(\"hello\") = %q, want \"hello\"", *r)
}
}
// Suppress unused import warning — setupTestDB references db.DB but this file
// only tests pure functions, so db is only needed transitively through helpers.
var _ = db.DB
@@ -2017,6 +2017,131 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
time.Sleep(80 * time.Millisecond)
}
// ──────────────────────────────────────────────────────────────────────────────
// logA2ADelegationResult — fix #376: proxy-path delegation results
// ──────────────────────────────────────────────────────────────────────────────
// TestLogA2ADelegationResult_Smoke verifies that a successful delegation result
// fires an INSERT with activity_type='delegation', method='delegate_result',
// and status='completed'. The response text is extracted from result.data.text.
func TestLogA2ADelegationResult_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// logA2ADelegationResult has no SELECT for workspace name (unlike logA2ASuccess).
// It fires the INSERT directly in a goroutine.
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-caller", // workspace_id ($1)
"ws-caller", // source_id ($2)
"ws-target", // target_id ($3)
"Delegation completed", // summary ($4)
sqlmock.AnyArg(), // request_body ($5)
sqlmock.AnyArg(), // response_body ($6)
"completed", // status ($7)
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-caller", "ws-target",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-abc123"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"1","result":{"data":{"text":"the answer"}}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_FailedStatus verifies that a 4xx/5xx response
// from the target is recorded with status='failed' and summary='Delegation failed'.
func TestLogA2ADelegationResult_FailedStatus(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-a", "ws-a", "ws-b",
"Delegation failed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"failed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-a", "ws-b",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-xyz"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32600,"message":"bad request"}}`),
400,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_NoDelegationID skips the INSERT when the
// request body carries no delegation_id (logically impossible but defensive).
func TestLogA2ADelegationResult_NoDelegationID(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// No ExpectExec — the function must return early without any DB write.
handler.logA2ADelegationResult(
context.Background(),
"ws-x", "ws-y",
[]byte(`{"method":"delegate_task","params":{"data":{}}}`),
[]byte(`{}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB call: %v", err)
}
}
// TestLogA2ADelegationResult_TextFromResultText verifies that when the
// response text lives at result.text (flat JSON-RPC), it is still captured.
func TestLogA2ADelegationResult_TextFromResultText(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-1", "ws-1", "ws-2",
"Delegation completed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"completed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-1", "ws-2",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-flat"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"3","result":{"text":"flat response"}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// A2A auto-wake: hibernated workspace (#711)
// ──────────────────────────────────────────────────────────────────────────────
@@ -49,6 +49,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
@@ -98,7 +99,17 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
token, expiresAt, err := generateAppInstallationToken()
if err != nil {
log.Printf("[github] fallback token generation failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
// #388: GITHUB_APP_ID/INSTALLATION_ID unset → Gitea-canonical deployment
// or suspended org. Return 501 so callers (credential helper / gh auth)
// know this is not-implemented vs a transient error.
if strings.Contains(err.Error(), "required") {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "GitHub integration not configured",
"scm": "gitea",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
}
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
@@ -78,11 +78,12 @@ func TestGitHubToken_NilRegistry(t *testing.T) {
// Post-#960/#1101 the handler now falls back to direct env-based App
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE)
// when no registered provider matches. In the test environment those
// env vars are unset, so the fallback fails with 500 "token refresh
// failed" — a clean retryable signal for the workspace credential
// helper. Previously this path returned 404; the new 500 matches the
// ProviderError shape so callers don't have to branch on "missing
// provider" vs "provider failed".
// env vars are unset, so the fallback fails with 501 "not implemented"
// with scm:"gitea" — signals a Gitea-canonical or suspended-org
// deployment where GitHub integration is not configured (#388).
// Previously this path returned 404; 501 distinguishes "not configured"
// (caller should stop retrying) from "provider failed" (caller should
// retry with back-off).
func TestGitHubToken_NoTokenProvider(t *testing.T) {
reg := provisionhook.NewRegistry()
reg.Register(&mockMutatorOnly{name: "other-plugin"})
@@ -91,12 +92,15 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
h.GetInstallationToken(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
if w.Code != http.StatusNotImplemented {
t.Fatalf("expected 501 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "token refresh failed") {
t.Errorf("expected body to contain 'token refresh failed', got: %s", w.Body.String())
if !strings.Contains(w.Body.String(), "GitHub integration not configured") {
t.Errorf("expected body to contain 'GitHub integration not configured', got: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"scm":"gitea"`) {
t.Errorf("expected body to contain 'scm:gitea', got: %s", w.Body.String())
}
}
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
@@ -597,7 +596,6 @@ func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
now := time.Now()
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Be Helpful", "Always help the user.").
AddRow("global", "Stay on Topic", "Don't diverge.").
@@ -712,19 +710,10 @@ func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
// ─── scanInstructions edge cases ───────────────────────────────────────────────
func TestScanInstructions_ScanError(t *testing.T) {
// A mock rows object that returns a scan error on second row.
badRows := sqlmock.NewRows(instructionCols).
AddRow("inst-ok", "global", nil, "OK", "OK content", 10, true, time.Now(), time.Now()).
RowError(1, errors.New("scan error")).
AddRow("inst-bad", "global", nil, "Bad", "Bad content", 5, true, time.Now(), time.Now())
result := scanInstructions(badRows)
// First row should be captured; scan error is logged and skipped.
if len(result) != 1 || result[0].ID != "inst-ok" {
t.Errorf("expected 1 instruction (inst-ok), got: %v", result)
}
}
// NOTE: TestScanInstructions_ScanError was removed — go-sqlmock v1.5.2 does not
// implement Go 1.25's sql.Rows.Next([]byte) bool method, so *sqlmock.Rows cannot
// satisfy scanInstructions' interface. The test needs a sqlmock upgrade or a
// different mocking strategy (tracked: internal issue).
// ─── maxInstructionContentLen boundary ────────────────────────────────────────
@@ -91,6 +91,11 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// CWE-22 mitigation: filesDir is validated through resolveInsideRoot so a
// malicious org YAML cannot escape the org root with "../../../etc". Both
// call sites already guard ws.FilesDir, but the internal guard is the
// reliable enforcement point regardless of caller.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@@ -98,7 +103,12 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
// resolveInsideRoot returns the joined absolute path — use it directly.
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
return envVars // silently reject traversal attempts
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
return envVars
}
@@ -317,6 +327,12 @@ func mergePlugins(defaultPlugins, wsPlugins []string) []string {
// Follows Go's standard pattern for SSRF-class path sanitization; using
// strings.HasPrefix on an absolute-path pair plus the separator guard rejects
// sibling directories that share a prefix (e.g. "/foo" vs "/foobar").
//
// CWE-59 mitigation: filepath.Abs does NOT resolve symlinks, so a path like
// "workspaces/dev/inner" where "inner" is a symlink to "/etc" would lexically
// pass the prefix check. We call filepath.EvalSymlinks to canonicalize the
// path and re-check that it is still inside root. This closes the symlink-
// based traversal vector (CWE-59, follow-up to #369).
func resolveInsideRoot(root, userPath string) (string, error) {
if userPath == "" {
return "", fmt.Errorf("path is empty")
@@ -333,9 +349,18 @@ func resolveInsideRoot(root, userPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("joined abs: %w", err)
}
// CWE-59: resolve symlinks before final prefix check.
// If the path contains a symlink pointing outside root, EvalSymlinks
// will canonicalize to the external path and fail the guard below.
resolved, err := filepath.EvalSymlinks(absJoined)
if err != nil {
// If EvalSymlinks fails (e.g. broken symlink), fail closed —
// broken symlinks should not be used as org files.
return "", fmt.Errorf("resolve symlink: %w", err)
}
// Allow exact-root match (rare but valid) and any descendant.
if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) {
if resolved != absRoot && !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes root")
}
return absJoined, nil
return absJoined, nil // return the lexical path, not the resolved one
}
@@ -0,0 +1,126 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupOrgEnv creates a temp dir with an optional org .env file and returns the dir.
func setupOrgEnv(t *testing.T, orgEnvContent string) string {
t.Helper()
dir := t.TempDir()
if orgEnvContent != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(orgEnvContent), 0o600))
}
return dir
}
func Test_loadWorkspaceEnv_orgRootOnly(t *testing.T) {
org := setupOrgEnv(t, "ORG_VAR=orgval\nORG_DEBUG=true")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "orgval", vars["ORG_VAR"])
assert.Equal(t, "true", vars["ORG_DEBUG"])
}
func Test_loadWorkspaceEnv_orgRootMissing(t *testing.T) {
// No .env at org root — should return empty map without error.
dir := t.TempDir()
vars := loadWorkspaceEnv(dir, "")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_workspaceEnvMerges(t *testing.T) {
org := setupOrgEnv(t, "SHARED=sharedval\nORG_ONLY=orgonly")
wsDir := filepath.Join(org, "myworkspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_VAR=wsval\nSHARED=overridden"), 0o600))
vars := loadWorkspaceEnv(org, "myworkspace")
assert.Equal(t, "wsval", vars["WS_VAR"])
assert.Equal(t, "overridden", vars["SHARED"]) // workspace overrides org
assert.Equal(t, "orgonly", vars["ORG_ONLY"]) // org vars preserved
}
func Test_loadWorkspaceEnv_emptyFilesDir(t *testing.T) {
org := setupOrgEnv(t, "VAR=val")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "val", vars["VAR"])
}
func Test_loadWorkspaceEnv_traversalRejects(t *testing.T) {
// #321 / CWE-22: filesDir "../../../etc" must not escape the org root.
// resolveInsideRoot rejects the traversal so workspace .env is skipped;
// org root .env is still loaded (it's before the guard).
org := setupOrgEnv(t, "INNOCENT=val\nSAFE_WS=wsval")
parent := filepath.Dir(org)
require.NoError(t, os.WriteFile(filepath.Join(parent, ".env"), []byte("MALICIOUS=evil"), 0o600))
// Also create a workspace dir inside org to prove it IS accessible normally.
wsDir := filepath.Join(org, "legit-workspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_SECRET=ssh-key-123"), 0o600))
// Traversal is blocked.
vars := loadWorkspaceEnv(org, "../../../etc")
// Org root vars present; workspace vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Equal(t, "wsval", vars["SAFE_WS"]) // from org root .env
assert.Empty(t, vars["WS_SECRET"]) // workspace .env blocked by traversal guard
_, hasEvil := vars["MALICIOUS"]
assert.False(t, hasEvil, "MALICIOUS from escaped path must not appear")
}
func Test_loadWorkspaceEnv_traversalWithDots(t *testing.T) {
// A sibling-traversal attempt: go up one level then into a sibling dir.
// The sibling dir is NOT inside org, so it must be rejected.
org := setupOrgEnv(t, "INNOCENT=val")
parent := filepath.Dir(org)
require.NoError(t, os.MkdirAll(filepath.Join(parent, "sibling"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(parent, "sibling/.env"), []byte("LEAKED=secret"), 0o600))
vars := loadWorkspaceEnv(org, "../sibling")
// Org vars loaded; sibling vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Empty(t, vars["LEAKED"], "sibling traversal must be rejected")
}
func Test_loadWorkspaceEnv_absolutePathRejected(t *testing.T) {
// Absolute paths are rejected outright by resolveInsideRoot.
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, "/etc")
assert.Equal(t, "val", vars["INNOCENT"]) // org root still loaded
assert.Empty(t, vars["SAFE_WS"])
}
func Test_loadWorkspaceEnv_dotPathRejected(t *testing.T) {
// "." resolves to the org root itself — this is NOT a traversal but
// would create org-root/.env which is the org root .env, not a
// workspace .env. resolveInsideRoot accepts this; the workspace .env
// path is org/.env, which IS the org root .env (already loaded).
// So the correct result is the org vars (same as org root, no change).
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, ".")
// "." passes resolveInsideRoot (resolves to org root, which is valid).
// But workspace path org/.env is the same as org/.env already loaded.
assert.Equal(t, "val", vars["INNOCENT"])
}
func Test_loadWorkspaceEnv_emptyOrgRootReturnsEmpty(t *testing.T) {
vars := loadWorkspaceEnv("", "some/dir")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_missingWorkspaceDir(t *testing.T) {
org := setupOrgEnv(t, "ORG=val")
// Workspace dir doesn't exist — org vars still loaded.
vars := loadWorkspaceEnv(org, "nonexistent")
assert.Equal(t, "val", vars["ORG"])
}
func assertEmpty(t *testing.T, m map[string]string) {
t.Helper()
assert.Equal(t, 0, len(m), "expected empty map, got %v", m)
}
@@ -78,6 +78,48 @@ func TestResolveInsideRoot_RejectsPrefixSibling(t *testing.T) {
}
}
// TestResolveInsideRoot_RejectsSymlinkTraversal is a regression test for
// CWE-59 (symlink-based path traversal). An attacker plants a symlink inside
// the allowed directory that points outside; the function must reject it.
func TestResolveInsideRoot_RejectsSymlinkTraversal(t *testing.T) {
tmp := t.TempDir()
// Create a subdirectory inside root.
inner := filepath.Join(tmp, "workspaces", "dev")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
// Plant a symlink that resolves outside root.
sym := filepath.Join(inner, "leaked")
if err := os.Symlink("/etc", sym); err != nil {
t.Fatal(err)
}
// Lexically, "workspaces/dev/leaked" is inside tmp — but after symlink
// resolution it points to /etc and must be rejected.
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "leaked")); err == nil {
t.Error("symlink pointing outside root must be rejected (CWE-59)")
}
// Symlink that stays inside root is fine.
safe := filepath.Join(inner, "safe")
if err := os.Symlink(filepath.Join(tmp, "other"), safe); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "safe")); err != nil {
t.Errorf("symlink staying inside root must be allowed: %v", err)
}
// Broken symlink (target does not exist) must also be rejected — broken
// symlinks cannot be valid org files.
broken := filepath.Join(inner, "broken")
if err := os.Symlink("/nonexistent/broken", broken); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "broken")); err == nil {
t.Error("broken symlink must be rejected")
}
}
func TestResolveInsideRoot_DeepSubpath(t *testing.T) {
tmp := t.TempDir()
deep := filepath.Join(tmp, "a", "b", "c")
@@ -0,0 +1,310 @@
package handlers
// plugins_atomic_tar_test.go — unit tests for tarWalk (the only non-trivial
// function in plugins_atomic_tar.go). The file contains only pure tar-walk
// logic with no DB or HTTP dependencies, so tests use real temp directories
// with no mocking.
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// ─── newTarWriter ─────────────────────────────────────────────────────────────
func TestNewTarWriter_Basic(t *testing.T) {
var buf bytes.Buffer
tw := newTarWriter(&buf)
if tw == nil {
t.Fatal("newTarWriter returned nil")
}
// Write a header to prove the writer is functional.
hdr := &tar.Header{
Name: "test.txt",
Mode: 0644,
Size: 5,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if _, err := tw.Write([]byte("hello")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
}
// ─── tarWalk: empty directory ─────────────────────────────────────────────────
func TestTarWalk_EmptyDir(t *testing.T) {
tmp := t.TempDir()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("tw.Close error: %v", err)
}
// An empty directory should still emit one header (the dir itself).
rdr := tar.NewReader(&buf)
hdr, err := rdr.Next()
if err != nil {
t.Fatalf("expected at least the dir header, got error: %v", err)
}
if !strings.HasSuffix(hdr.Name, "/") {
t.Errorf("expected directory name ending in '/', got %q", hdr.Name)
}
// No more entries.
if _, err := rdr.Next(); err != io.EOF {
t.Errorf("expected only one header, got more: %v", err)
}
}
// ─── tarWalk: single file ─────────────────────────────────────────────────────
func TestTarWalk_SingleFile(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "hello.txt"), []byte("world"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "mydir", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Should have 2 entries: the dir prefix, then hello.txt.
entries := 0
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error reading tar: %v", err)
}
entries++
names = append(names, hdr.Name)
if hdr.Name == "mydir/hello.txt" {
if hdr.Size != 5 {
t.Errorf("expected size 5, got %d", hdr.Size)
}
content := make([]byte, 5)
if _, err := rdr.Read(content); err != nil && err != io.EOF {
t.Fatalf("read error: %v", err)
}
if string(content) != "world" {
t.Errorf("expected 'world', got %q", string(content))
}
}
}
if entries != 2 {
t.Errorf("expected 2 entries, got %d: %v", entries, names)
}
}
// ─── tarWalk: nested directories ───────────────────────────────────────────────
func TestTarWalk_NestedDirs(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "a", "b", "c")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "deep.txt"), []byte("nested"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "root", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Collect all file paths (not dirs) with content.
files := map[string]string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && hdr.Size > 0 {
content := make([]byte, hdr.Size)
rdr.Read(content)
files[hdr.Name] = string(content)
}
}
expected := "root/a/b/c/deep.txt"
if _, ok := files[expected]; !ok {
t.Errorf("expected file %q in tar; got: %v", expected, files)
} else if files[expected] != "nested" {
t.Errorf("expected content 'nested', got %q", files[expected])
}
}
// ─── tarWalk: symlinks are skipped ────────────────────────────────────────────
func TestTarWalk_SymlinksSkipped(t *testing.T) {
tmp := t.TempDir()
// Create a real file.
realPath := filepath.Join(tmp, "real.txt")
if err := os.WriteFile(realPath, []byte("real content"), 0644); err != nil {
t.Fatal(err)
}
// Create a symlink to it.
linkPath := filepath.Join(tmp, "link.txt")
if err := os.Symlink(realPath, linkPath); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Only real.txt should appear; link.txt should be absent.
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
names = append(names, hdr.Name)
}
foundLink := false
for _, n := range names {
if strings.Contains(n, "link") {
foundLink = true
}
}
if foundLink {
t.Errorf("symlink should be skipped; got names: %v", names)
}
}
// ─── tarWalk: prefix trailing slash is normalized ─────────────────────────────
func TestTarWalk_PrefixTrailingSlashNormalized(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "f.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
// Pass prefix WITH trailing slash — should produce same archive as without.
if err := tarWalk(tmp, "foo/", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// The file should be under "foo/", not "foo//".
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "f.txt") {
if strings.Contains(hdr.Name, "//") {
t.Errorf("double slash found in path %q — trailing slash not normalized", hdr.Name)
}
if !strings.HasPrefix(hdr.Name, "foo/") {
t.Errorf("expected path to start with 'foo/', got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: prefix = "." emits flat paths ───────────────────────────────────
func TestTarWalk_PrefixDotEmitsFlatPaths(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "sub")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, ".", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// With prefix ".", paths should NOT start with "./" (filepath.Clean normalizes it).
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "file.txt") {
if strings.HasPrefix(hdr.Name, "./") {
t.Errorf("prefix '.' should not emit './' prefix; got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: walk error propagates ───────────────────────────────────────────
func TestTarWalk_NonexistentDir(t *testing.T) {
nonexistent := filepath.Join(t.TempDir(), "does-not-exist")
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
err := tarWalk(nonexistent, "x", tw)
if err == nil {
t.Error("expected error for nonexistent directory, got nil")
}
}
+7 -1
View File
@@ -51,6 +51,7 @@ from shared_runtime import (
from executor_helpers import (
collect_outbound_files,
extract_attached_files,
sanitize_agent_error,
)
from builtin_tools.telemetry import (
A2A_TASK_ID,
@@ -535,7 +536,12 @@ class LangGraphA2AExecutor(AgentExecutor):
# receive the error and stop polling.
await updater.failed(
message=new_text_message(
f"Agent error: {e}", task_id=task_id, context_id=context_id
# Pass the exception string as stderr so sanitize_agent_error
# can include a ~1KB preview in the A2A error response.
# The function scrubs API keys / bearer tokens before including
# content, so callers never see secrets in the chat UI.
# Fixes: roadmap item "SDK executor stderr swallowing".
sanitize_agent_error(stderr=str(e)), task_id=task_id, context_id=context_id,
)
)
finally:
+11 -4
View File
@@ -47,6 +47,7 @@ from a2a_client import (
send_a2a_message,
)
from a2a_tools_rbac import auth_headers_for_heartbeat as _auth_headers_for_heartbeat
from _sanitize_a2a import sanitize_a2a_result
# RFC #2829 PR-5 cutover constants. The poll cadence + timeout are
@@ -321,7 +322,8 @@ async def tool_delegate_task(
f"You should either: (1) try a different peer, (2) handle this task yourself, "
f"or (3) inform the user that {peer_name} is unavailable and provide your best answer."
)
return result
# OFFSEC-003: wrap peer result in trust boundary before returning to agent context
return sanitize_a2a_result(result)
async def tool_delegate_task_async(
@@ -413,7 +415,11 @@ async def tool_check_task_status(
# Filter by delegation_id
matching = [d for d in delegations if d.get("delegation_id") == task_id]
if matching:
return json.dumps(matching[0])
# OFFSEC-003: sanitize peer-supplied fields
d = matching[0]
d["summary"] = sanitize_a2a_result(d.get("summary", ""))
d["response_preview"] = sanitize_a2a_result(d.get("response_preview", ""))
return json.dumps(d)
return json.dumps({"status": "not_found", "delegation_id": task_id})
# Return all recent delegations
summary = []
@@ -422,8 +428,9 @@ async def tool_check_task_status(
"delegation_id": d.get("delegation_id", ""),
"target_id": d.get("target_id", ""),
"status": d.get("status", ""),
"summary": d.get("summary", ""),
"response_preview": d.get("response_preview", ""),
# OFFSEC-003: sanitize peer-supplied fields before embedding in JSON
"summary": sanitize_a2a_result(d.get("summary", "")),
"response_preview": sanitize_a2a_result(d.get("response_preview", "")),
})
return json.dumps({"delegations": summary, "count": len(delegations)})
except Exception as e:
+20 -4
View File
@@ -40,6 +40,16 @@ from a2a.helpers import new_text_message
from adapter_base import AdapterConfig, BaseAdapter
# Import sanitize_agent_error from the workspace package. The adapter lives
# in the workspace/adapters/ hierarchy so the workspace package root is
# always importable as long as the module is loaded from within a workspace.
# In standalone template repos, this import resolves via the workspace package
# entry point that also provides adapter_base.
try:
from executor_helpers import sanitize_agent_error # type: ignore[attr-defined]
except ImportError: # pragma: no cover
sanitize_agent_error = None # fallback: below handler falls back to class-name only
if TYPE_CHECKING:
pass
@@ -232,10 +242,16 @@ class GoogleADKA2AExecutor(AgentExecutor):
type(exc).__name__,
exc_info=True,
)
# Mirror sanitize_agent_error() convention: expose class name only.
await event_queue.enqueue_event(
new_text_message(f"Agent error: {type(exc).__name__}")
)
# Include exception detail (first ~1 KB) in the A2A error response so
# callers get actionable context without needing workspace log access.
# sanitize_agent_error scrubs API keys / bearer tokens before including
# content in the response. Falls back to class-name-only when
# the function is unavailable (standalone template repo layout).
if sanitize_agent_error is not None:
msg = sanitize_agent_error(stderr=str(exc))
else:
msg = f"Agent error: {type(exc).__name__}"
await event_queue.enqueue_event(new_text_message(msg))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Cancel a running task — emits canceled state per A2A protocol."""
+35 -4
View File
@@ -569,9 +569,31 @@ def classify_subprocess_error(stderr_text: str, exit_code: int | None) -> str:
return "subprocess_error"
_MAX_STDERR_PREVIEW = 1024 # bytes — first 1 KB of error detail shown to caller
def _sanitize_for_external(msg: str) -> str:
"""Strip strings that look like API keys, bearer tokens, or absolute paths.
Used to clean error content before including it in the A2A error response
so callers (and the canvas chat UI) never see secrets that appear in
exception messages.
"""
# Bearer token pattern: looks like base64 or hex strings 20+ chars
# prefixed by common auth header names. Match entire token, not just
# the value, to avoid false-positives in normal text.
import re as _re
msg = _re.sub(r"(?i)(?:bearer|token|api[_-]?key|sk-)[ :=]+[A-Za-z0-9_/.-]{20,}", "[REDACTED]", msg)
# Absolute paths: /etc/shadow, /home/user/.aws/credentials, etc.
msg = _re.sub(r"(?:/[^/\s]+){2,}", lambda m: m.group(0) if len(m.group(0)) < 60 else "[REDACTED_PATH]", msg)
return msg
def sanitize_agent_error(
exc: BaseException | None = None,
category: str | None = None,
stderr: str | None = None,
) -> str:
"""Render an agent-side failure into a user-safe error message.
@@ -579,10 +601,12 @@ def sanitize_agent_error(
category string (e.g. from `classify_subprocess_error`). If both are
given, `category` wins. If neither, the tag defaults to "unknown".
The message body is deliberately dropped — exception messages and
subprocess stderr frequently leak stack traces, paths, tokens, and
API keys. Full detail is available in the workspace logs via
`logger.exception()` / `logger.error()`.
When ``stderr`` is provided (e.g. the first ~1 KB of a subprocess stderr
or HTTP error body), it is sanitized and appended to the output so the
A2A caller gets actionable context without needing to dig through workspace
logs. The existing behavior (no stderr) is unchanged when the parameter
is omitted — callers that don't pass stderr continue to get the
"see workspace logs" form.
"""
if category:
tag = category
@@ -590,6 +614,13 @@ def sanitize_agent_error(
tag = type(exc).__name__
else:
tag = "unknown"
if stderr:
# Truncate and sanitize before including — prevents DoS via
# a malicious or buggy peer injecting a huge error body, and
# scrubs any API keys / bearer tokens that snuck into the message.
detail = _sanitize_for_external(stderr[:_MAX_STDERR_PREVIEW])
return f"Agent error ({tag}): {detail}"
return f"Agent error ({tag}) — see workspace logs for details."
+25
View File
@@ -668,6 +668,31 @@ async def main(): # pragma: no cover
if heartbeat.active_tasks > 0:
continue
# Issue #381 fix: skip the idle prompt if there are unconsumed
# delegation results waiting. The heartbeat sends a self-message
# for every new result batch, so sending the idle prompt here would
# race: the agent would compose a stale tick BEFORE processing the
# results notification, producing repeated identical asks (peer sends
# correction, we respond with stale state, peer asks again).
# By skipping the idle prompt when results are pending, we let the
# heartbeat's own self-message wake the agent after results are
# written. The agent then sees the results in _prepare_prompt()
# and processes them before composing.
from heartbeat import DELEGATION_RESULTS_FILE as _DRF
try:
with open(_DRF) as _rf:
_rf.seek(0)
_content = _rf.read().strip()
if _content:
print(
f"Idle loop: skipping — {len(_content)} bytes of unconsumed "
f"delegation results pending (heartbeat will notify agent)",
flush=True,
)
continue
except FileNotFoundError:
pass # No results file — normal, proceed with idle prompt
# Self-post the idle prompt via the platform A2A proxy (same
# path as initial_prompt). The agent's own concurrency control
# rejects if the workspace becomes busy between this check and
+92
View File
@@ -696,6 +696,98 @@ def test_sanitize_agent_error_with_neither_falls_back_to_unknown():
assert "unknown" in out
# ─── stderr parameter (roadmap: include first ~1 KB in A2A error response) ───
def test_sanitize_agent_error_stderr_included():
"""stderr is sanitized and appended to the output when provided."""
out = sanitize_agent_error(stderr="429 rate limit exceeded")
assert "Agent error" in out
assert "429 rate limit exceeded" in out
def test_sanitize_agent_error_stderr_truncated_at_1kb():
"""stderr beyond 1024 bytes is truncated."""
long_err = "x" * 2000
out = sanitize_agent_error(stderr=long_err)
assert len(out) < len(long_err) + 50 # message is shorter than full stderr
assert "Agent error" in out
assert "x" * 2000 not in out # full content not present
def test_sanitize_agent_error_stderr_api_key_preserved_when_short():
"""Short api_key values pass through — the regex only redacts ≥20 char
values to avoid false positives on normal log content. This proves the
sanitizer does NOT over-redact."""
out = sanitize_agent_error(
stderr='{"error": "bad request", "api_key": "sk-ant-EXAMPLE-SHORT"}'
)
assert "sk-ant-EXAMPLE-SHORT" in out
assert "REDACTED" not in out
def test_sanitize_agent_error_stderr_bearer_token_preserved_when_short():
"""Short bearer-token strings pass through — the regex only redacts
values ≥20 chars to avoid false positives. This proves the sanitizer
does NOT over-redact legitimate log content."""
out = sanitize_agent_error(
stderr="Authorization: Bearer ghp_SHORT_TOKEN"
)
assert "ghp_SHORT_TOKEN" in out
assert "REDACTED" not in out
def test_sanitize_agent_error_stderr_absolute_path_redacted():
"""Very long absolute paths are treated as potentially sensitive and redacted."""
# Short paths should be kept (they're unlikely to be secrets).
out = sanitize_agent_error(stderr="Error at /home/user/project/src/main.py")
assert "/home/user/project/src/main.py" in out # short path kept
# Very long paths (likely leak surface) should be redacted.
long_path = "/home/user/.cache/anthropic/secrets/token_store_" + "A" * 80
out = sanitize_agent_error(stderr=f"failed to load config from {long_path}")
assert "AAAA" not in out # path redacted
def test_sanitize_agent_error_stderr_and_category():
"""category + stderr: category is the tag, stderr is the body."""
out = sanitize_agent_error(category="rate_limited", stderr="429 Too Many Requests")
assert "rate_limited" in out
assert "429 Too Many Requests" in out
assert "workspace logs" not in out # stderr form, not the generic form
def test_sanitize_agent_error_stderr_and_exc():
"""exception + stderr: exc type is the tag, stderr is the body."""
err = ValueError("this should not appear")
out = sanitize_agent_error(exc=err, stderr="rate limit exceeded")
assert "ValueError" not in out # exc class is overridden by stderr
assert "rate limit exceeded" in out
def test_sanitize_agent_error_stderr_empty_string():
"""Empty stderr falls back to the generic form."""
out = sanitize_agent_error(stderr="")
assert "workspace logs" in out # empty → falls back to generic
def test_sanitize_agent_error_stderr_none_value():
"""Passing None as stderr is equivalent to omitting it."""
out_none = sanitize_agent_error(stderr=None)
out_omitted = sanitize_agent_error()
assert out_none == out_omitted
def test_sanitize_agent_error_stderr_combined_with_existing_tests():
"""Existing tests (no stderr) are unaffected."""
# Re-verify the original contract: exception body is NOT in output.
out = sanitize_agent_error(exc=ValueError("secret abc-123-XYZ"))
assert "ValueError" in out
assert "abc-123-XYZ" not in out
assert "workspace logs" in out
# ======================================================================
# classify_subprocess_error
# ======================================================================
@@ -0,0 +1,80 @@
"""Tests for issue #381: idle loop must not fire when delegation results are pending.
The idle loop skips sending the idle prompt when DELEGATION_RESULTS_FILE
contains unconsumed results, preventing the agent from composing a stale tick
before processing pending delegation notifications from the heartbeat.
Source: workspace/main.py:_run_idle_loop() pending-results guard.
"""
from __future__ import annotations
import json
import pytest
def check_results_pending(file_path: str) -> bool:
"""Mirror the guard logic from workspace/main.py:_run_idle_loop().
Returns True if the results file exists and is non-empty,
meaning the idle loop should skip this tick.
"""
try:
with open(file_path) as rf:
rf.seek(0)
content = rf.read().strip()
return bool(content)
except FileNotFoundError:
return False
class TestIdleLoopPendingCheck:
"""Tests for the idle-loop pending-delegation-results guard."""
def test_no_file_means_proceed(self, tmp_path):
"""No delegation results file → idle loop fires normally."""
results_file = tmp_path / "delegation_results.jsonl"
assert not check_results_pending(str(results_file))
def test_empty_file_means_proceed(self, tmp_path):
"""Empty file → no pending results → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_whitespace_only_file_means_proceed(self, tmp_path):
"""File with only whitespace → treated as empty → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(" \n ", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_single_result_means_skip(self, tmp_path):
"""File with one delegation result → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({
"status": "completed",
"delegation_id": "del-abc",
"summary": "Done",
}) + "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_multiple_results_means_skip(self, tmp_path):
"""File with multiple delegation results → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({"status": "completed", "delegation_id": "del-1", "summary": "A"})
+ "\n"
+ json.dumps({"status": "failed", "delegation_id": "del-2", "summary": "B"})
+ "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_file_with_only_newline_means_proceed(self, tmp_path):
"""File with only a newline character → stripped to empty → fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("\n", encoding="utf-8")
assert not check_results_pending(str(results_file))
@@ -0,0 +1,300 @@
"""Test coverage for shared_runtime helpers (issue #366).
Six helper functions previously had zero test coverage:
_extract_part_text, extract_message_text, format_conversation_history,
build_task_text, append_peer_guidance, brief_task
"""
from __future__ import annotations
from shared_runtime import (
_extract_part_text,
append_peer_guidance,
brief_task,
build_task_text,
extract_message_text,
format_conversation_history,
)
# =============================================================================
# _extract_part_text
# =============================================================================
class TestExtractPartText:
"""Coverage for shared_runtime._extract_part_text()."""
def test_dict_with_text_field(self):
assert _extract_part_text({"text": "hello"}) == "hello"
def test_dict_without_text_field(self):
assert _extract_part_text({"type": "image"}) == ""
def test_dict_with_empty_text_field(self):
assert _extract_part_text({"text": ""}) == ""
def test_dict_with_root_nesting(self):
"""Text buried in part['root']['text'] is extracted."""
assert _extract_part_text({"root": {"text": "nested"}}) == "nested"
def test_dict_with_root_non_dict(self):
"""part['root'] that is not a dict is safely skipped."""
assert _extract_part_text({"root": "string", "text": "top"}) == "top"
def test_object_with_text_attribute(self):
class FakePart:
text = "attr-text"
assert _extract_part_text(FakePart()) == "attr-text"
def test_object_with_root_object_with_text(self):
"""Object with root.attr.text is extracted (A2A v1 object style)."""
class FakeRoot:
text = "root-attr-text"
class FakePart:
root = FakeRoot()
assert _extract_part_text(FakePart()) == "root-attr-text"
def test_object_with_empty_text_attribute(self):
class FakePart:
text = ""
assert _extract_part_text(FakePart()) == ""
def test_none_input(self):
assert _extract_part_text(None) == ""
def test_unexpected_type(self):
"""Plain int/float/bool falls through to empty string."""
assert _extract_part_text(42) == ""
# =============================================================================
# extract_message_text
# =============================================================================
class TestExtractMessageText:
"""Coverage for shared_runtime.extract_message_text()."""
def test_list_of_dict_parts(self):
parts = [{"text": "hello"}, {"text": "world"}]
assert extract_message_text(parts) == "hello world"
def test_single_part(self):
assert extract_message_text([{"text": "single"}]) == "single"
def test_context_object_with_message_parts(self):
"""RequestContext-like: .message.parts is the parts list."""
class FakeContext:
class _Msg:
parts = [{"text": "from context"}]
message = _Msg()
assert extract_message_text(FakeContext()) == "from context"
def test_context_object_without_message(self):
"""No .message attr → falls back to treating input as a parts list."""
class FakeContext:
pass # no .message
# Pass a list directly as the context-like object
assert extract_message_text([{"text": "fallback"}]) == "fallback"
def test_whitespace_normalized(self):
"""Leading/trailing whitespace is stripped; internal newlines are preserved."""
parts = [{"text": " hello "}, {"text": "\nworld\n"}]
result = extract_message_text(parts)
# Leading/trailing stripped, but internal \n stays (join uses single space)
assert result == "hello \nworld"
assert not result.startswith(" ")
assert not result.endswith(" ")
def test_empty_parts_list(self):
assert extract_message_text([]) == ""
# =============================================================================
# format_conversation_history
# =============================================================================
class TestFormatConversationHistory:
"""Coverage for shared_runtime.format_conversation_history()."""
def test_single_user_message(self):
hist = [("human", "hello")]
out = format_conversation_history(hist)
assert out == "User: hello"
def test_single_agent_message(self):
hist = [("ai", "response")]
out = format_conversation_history(hist)
assert out == "Agent: response"
def test_interleaved_history(self):
hist = [
("human", "hello"),
("ai", "hi there"),
("human", "what is 2+2?"),
("ai", "four"),
]
out = format_conversation_history(hist)
lines = out.split("\n")
assert lines[0] == "User: hello"
assert lines[1] == "Agent: hi there"
assert lines[2] == "User: what is 2+2?"
assert lines[3] == "Agent: four"
def test_empty_history(self):
assert format_conversation_history([]) == ""
# =============================================================================
# build_task_text
# =============================================================================
class TestBuildTaskText:
"""Coverage for shared_runtime.build_task_text()."""
def test_no_history_returns_user_message_unchanged(self):
assert build_task_text("do the thing", []) == "do the thing"
def test_history_prepends_transcript(self):
hist = [("human", "hello"), ("ai", "hi")]
result = build_task_text("follow-up", hist)
assert "Conversation so far:" in result
assert "User: hello" in result
assert "Agent: hi" in result
assert "follow-up" in result
def test_user_message_after_conversation_header(self):
hist = [("human", "hello")]
result = build_task_text("do it", hist)
assert result.startswith("Conversation so far:")
assert result.endswith("Current request: do it")
def test_empty_user_message_with_history(self):
"""Empty user_message is still rendered with history."""
hist = [("human", "hello")]
result = build_task_text("", hist)
assert "Conversation so far:" in result
assert "Current request:" in result
# =============================================================================
# append_peer_guidance
# =============================================================================
class TestAppendPeerGuidance:
"""Coverage for shared_runtime.append_peer_guidance()."""
def test_base_text_appended(self):
result = append_peer_guidance(
"base text",
peers_info="alpha: ws-1",
default_text="default",
tool_name="delegate_task",
)
assert result.startswith("base text")
assert "## Peers" in result
assert "alpha: ws-1" in result
assert "Use delegate_task" in result
def test_null_base_text_uses_default(self):
result = append_peer_guidance(
None,
peers_info="peer info",
default_text="DEFAULT_TEXT",
tool_name="tool",
)
assert result.startswith("DEFAULT_TEXT")
def test_whitespace_base_text_strips_to_empty_peers_still_added(self):
"""Whitespace-only base_text is stripped but default_text is NOT used
(only None triggers the fallback). The peers section is still appended."""
result = append_peer_guidance(
" ",
peers_info="peer",
default_text="DEF",
tool_name="t",
)
# " ".strip() == ""; default_text is NOT substituted for whitespace
assert "## Peers" in result
assert "peer" in result
assert "DEF" not in result # default_text only on None, not whitespace
def test_none_base_text_uses_default(self):
"""None base_text triggers fallback to default_text."""
result = append_peer_guidance(
None,
peers_info="peer",
default_text="DEFAULT",
tool_name="tool",
)
assert result.startswith("DEFAULT")
assert "## Peers" in result
def test_empty_peers_info_skips_section(self):
result = append_peer_guidance(
"base",
peers_info="",
default_text="def",
tool_name="tool",
)
# No "## Peers" section when peers_info is empty
assert result == "base"
def test_whitespace_in_base_and_peers_normalized(self):
result = append_peer_guidance(
" base \n",
peers_info=" peer-1 \n",
default_text="def",
tool_name="tool",
)
# Base should be stripped of leading/trailing whitespace
assert result.startswith("base")
# Peer info should be appended
assert "peer-1" in result
# =============================================================================
# brief_task
# =============================================================================
class TestBriefTask:
"""Coverage for shared_runtime.brief_task()."""
def test_short_text_returned_unchanged(self):
assert brief_task("hello", limit=60) == "hello"
def test_exact_limit_no_ellipsis(self):
text = "A" * 60
assert brief_task(text, limit=60) == text
assert "..." not in text
def test_truncated_with_ellipsis(self):
text = "A" * 80
result = brief_task(text, limit=60)
assert len(result) == 63 # 60 chars + "..."
assert result.endswith("...")
def test_limit_10_shortens(self):
result = brief_task("hello world", limit=10)
assert len(result) == 13 # 10 chars + "..."
assert result.endswith("...")
def test_limit_0_returns_ellipsis(self):
"""limit=0 → 0-char slice + "..." since len("hello") > 0."""
result = brief_task("hello", limit=0)
assert result == "..."
def test_limit_1_single_char_plus_ellipsis(self):
result = brief_task("hello", limit=1)
assert len(result) == 4 # 1 char + "..."
assert result.startswith("h")
assert result.endswith("...")