Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,10 @@ jobs:
# (#76499 → #76562 → #76575): the mock-backend Electron window never
# gets a title, so boot/chat/setup/interim specs all fail identically
# regardless of the PR's diff (verified on #76573 and the docs-only
# #76582). Tracking issue: #76627 (assigned: Ari). To re-enable,
# delete the `false &&` below — nothing else changed.
if: ${{ false && (needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true') }}
# #76582). Tracking issue: #76627 (assigned: Ari). The lane remains
# explicitly disabled until that issue is resolved; changing this to true
# is a separate workflow admission decision.
if: false
uses: ./.github/workflows/e2e-desktop.yml

docs-site:
Expand Down Expand Up @@ -219,6 +220,10 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
all-checks-pass:
name: All required checks pass
# This job executes the PR-controlled evaluator; do not inherit the
# workflow-wide permissions needed by unrelated reporting jobs.
permissions:
contents: read
needs:
- detect
- tests
Expand Down Expand Up @@ -247,29 +252,42 @@ jobs:
outputs:
needs-json: ${{ steps.evaluate.outputs.needs-json }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install pinned actionlint
env:
ACTIONLINT_VERSION: 1.7.11
ACTIONLINT_SHA256: 900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a
run: |
set -euo pipefail
archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}"
python3 - "$archive" "$url" <<'PY'
import pathlib
import sys
import urllib.request

destination = pathlib.Path(sys.argv[1])
request = urllib.request.Request(sys.argv[2], headers={"User-Agent": "RecursiveIntell-actionlint"})
with urllib.request.urlopen(request, timeout=30) as response:
destination.write_bytes(response.read())
PY
printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum --check --status -
tar -xzf "$archive" actionlint
chmod 0755 actionlint

- name: Lint GitHub Actions workflows
# The disabled Desktop lane is an explicit CI-01 applicability exception.
# Keep actionlint strict for all other syntax/expressions.
run: ./actionlint -ignore 'constant expression "false" in condition'

- name: Evaluate job results
id: evaluate
env:
NEEDS: ${{ toJSON(needs) }}
run: |
echo "$NEEDS" | python3 -c "
import json, sys
needs = json.load(sys.stdin)
# Emit compact {job_name: result} for the comment assembler.
compact = {name: info['result'] for name, info in needs.items()}
print(f'needs-json={json.dumps(compact)}')
with open('$GITHUB_OUTPUT', 'a') as f:
f.write(f'needs-json={json.dumps(compact)}\n')
failed = [name for name, info in needs.items() if info['result'] == 'failure']
for name, info in sorted(needs.items()):
result = info['result']
icon = '✅' if result in ('success', 'skipped') else '❌'
print(f'{icon} {name}: {result}')
if failed:
print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}')
sys.exit(1)
print('All checks passed (or were skipped)')
"
NEEDS_JSON: ${{ toJSON(needs) }}
CLASSIFIER_JSON: ${{ toJSON(needs.detect.outputs) }}
EVENT_NAME: ${{ github.event_name }}
run: python3 scripts/ci/evaluate_required_checks.py

# ─────────────────────────────────────────────────────────────────────
# CI timing report: collect per-job/step durations from the GitHub API,
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/contributor-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
MERGE_BASE=$(git merge-base origin/main HEAD)

# Find any new author emails in this PR's commits
NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u)
NEW_EMAILS=$(git log "${MERGE_BASE}"..HEAD --format='%ae' --no-merges | sort -u)

if [ -z "$NEW_EMAILS" ]; then
echo "No new commits to check."
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/history-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jobs:
# so the failure message is clear regardless of which signal fires
# first.
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
# shellcheck disable=SC2016 # backticks are literal Markdown in JSON
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo "review_status=${STATUS}" > review-status.json
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/infographic-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:

if [ -n "$OFFENDERS" ]; then
COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ')
# shellcheck disable=SC2016 # backticks are literal Markdown in JSON
STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached <path-to-image>\n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://<provider-url>)\n```\n"}]}]'
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
echo ""
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/js-autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ jobs:
if [ -z "$PR_NUM" ]; then
# gh pr create prints the PR URL. Extract the number from it
# (https://github.com/<org>/<repo>/pull/<number>).
# shellcheck disable=SC2016 # backticks are literal Markdown in CLI text
PR_URL=$(gh pr create \
--head "$BOT_BRANCH" --base main \
--title 'fmt(js): `npm run fix` auto-fix' \
Expand Down Expand Up @@ -234,7 +235,7 @@ jobs:
# Poll every 15s for up to ~10 minutes. Auto-merge will handle the
# PR even if this job times out — the polling is for cleanup only
# (auto-close on CI failure, conflicts, or main moving).
for i in $(seq 1 40); do
for _ in $(seq 1 40); do
sleep 15

STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lockfile-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
STATUS="[]"

if [ "$CHANGED" = "true" ]; then
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
CONTENT=$(python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" < /tmp/lockfile-diff.md)
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}]"
else
STATUS="[]"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-e2e-evidence.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
# The head-SHA match skips runs that a newer push superseded.
PR_NUMBER=$(gh api -X GET "repos/$SOURCE_REPO/pulls" \
-f head="$HEAD_OWNER:$HEAD_BRANCH" -f state=open \
--jq '.[] | select(.head.sha == $ENV.HEAD_SHA) | .number' \
--jq ".[] | select(.head.sha == \$ENV.HEAD_SHA) | .number" \
| head -n1)
if [ -z "$PR_NUMBER" ]; then
echo "No open pull request has head $HEAD_OWNER:$HEAD_BRANCH at $HEAD_SHA (CI run $SOURCE_RUN_ID)."
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/uv-lockfile-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ jobs:
done
if [ "$ok" != true ] && [ "$net_fail" = true ]; then
echo "::error title=uv.lock check could not reach PyPI::Registry unreachable after 3 attempts — infrastructure failure, not a stale lockfile. Re-run the job."
# shellcheck disable=SC2016 # backticks are literal Markdown in JSON
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock check could not reach PyPI","summary":"`uv lock --check` could not reach the package registry after 3 attempts. This is an infrastructure failure, not a stale lockfile.","how_to_fix":"Re-run the failed job. No change to `uv.lock` is needed."}]}]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
echo "review_status=${review_status}" > review-status.json
Expand Down Expand Up @@ -159,6 +160,7 @@ jobs:
on `main` post-merge.
EOF
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
# shellcheck disable=SC2016 # backticks are literal Markdown in JSON
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
echo "review_status=${review_status}" > review-status.json
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/e2e/production-permit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { expect, test } from './test'

const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..')
const ARES_ROOT = REPO_ROOT
const RUST_ROOT = path.resolve(REPO_ROOT, '..', 'recursive-agent-production-permit-20260830')
const RUST_ROOT = path.resolve(REPO_ROOT, '..', 'recursive-agent')

let fixture: NoProviderFixture | null = null

Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
"bippy": "0.5.43",
"concurrently": "10.0.4",
"cross-env": "10.1.0",
"electron": "40.10.2",
"electron": "40.10.6",
"electron-builder": "^26.8.1",
"esbuild": "^0.28.1",
"eslint": "^9.39.4",
Expand All @@ -176,7 +176,7 @@
"wait-on": "^9.0.5"
},
"build": {
"electronVersion": "40.10.2",
"electronVersion": "40.10.6",
"appId": "com.recursiveintell.ares",
"productName": "Ares",
"executableName": "Ares",
Expand Down
20 changes: 17 additions & 3 deletions apps/desktop/src/app/chat/composer/controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Send', '↵')
})

it('keeps Send (not Steer) while a turn is running if there is a payload', async () => {
it('shows Steer while a text payload redirects a running turn', async () => {
renderControls({ busy: true, busyAction: 'steer' })

await expectShortcutTooltip('Send', '↵')
await expectShortcutTooltip('Steer the current run', '↵')
})

it('shows Stop only when the composer is empty mid-turn', async () => {
Expand All @@ -151,10 +151,24 @@ describe('ComposerControls shortcut tooltips', () => {
})

it('shows Ctrl+Enter for Queue as the secondary mid-turn action', async () => {
renderControls({ busy: true, busyAction: 'queue' })
renderControls({ busy: true, busyAction: 'steer' })

await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})

it('shows Queue as the primary action while compaction owns the turn', () => {
renderControls({ busy: true, busyAction: 'queue', hasComposerPayload: false })

expect(screen.getByLabelText('Queue message')).toBeTruthy()
expect(screen.queryByLabelText('Stop')).toBeNull()
})

it('keeps Send for an idle payload', () => {
renderControls({ busy: false, busyAction: 'queue', hasComposerPayload: true })

expect(screen.getByLabelText('Send')).toBeTruthy()
expect(screen.queryByLabelText('Queue message')).toBeNull()
})
})

describe('wake-word ear visibility', () => {
Expand Down
30 changes: 17 additions & 13 deletions apps/desktop/src/app/chat/composer/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
Expand Down Expand Up @@ -73,10 +73,16 @@ export function ComposerControls({
}

const showVoicePrimary = !busy && !hasComposerPayload
// Steer is just send: a payload keeps the Send affordance mid-turn. Stop
// only when the composer is empty and a turn is running.
const showStop = busy && !hasComposerPayload
const showQueueButton = busyAction !== 'stop' && hasComposerPayload
// While busy, a text-only payload steers the live turn and an attachment or
// compaction payload queues it. An empty non-compacting composer stops the
// current turn; compaction owns the queue action even before text is entered.
const showStop = busyAction === 'stop' && busy && !hasComposerPayload
// The label and icon must reflect the same action selected by ChatBar rather
// than claiming a normal send.
const showSteer = busyAction === 'steer' && hasComposerPayload
const showQueuePrimary = busy && busyAction === 'queue'
const showQueueButton = busyAction === 'steer' && hasComposerPayload
const primaryLabel = showStop ? c.stop : showSteer ? c.steer : showQueuePrimary ? c.queueMessage : c.send
// The HUD is a Spotlight bar a few hundred pixels wide, so the four separate
// voice toggles fold into one menu there and leave the row to the input. A
// narrow tile hits the same wall from the other direction and folds for the
Expand Down Expand Up @@ -144,22 +150,20 @@ export function ComposerControls({
</Tip>
) : (
<Tip
label={
showStop ? (
<TipKeybindLabel actionId="composer.send" text={c.stop} />
) : (
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
}
label={<TipKeybindLabel actionId="composer.send" text={primaryLabel} />}
>
<Button
aria-label={showStop ? c.stop : c.send}
aria-label={primaryLabel}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
>
{showStop ? (
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
) : showSteer ? (
<SteeringWheel className={iconSize.sm} />
) : showQueuePrimary ? (
<Layers3 className={iconSize.sm} />
) : (
<Codicon name="arrow-up" size="0.875rem" />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,22 @@ describe('useComposerSubmit busy-turn routing', () => {
expect(queueCurrentDraft).not.toHaveBeenCalled()
})

it('does not stop the active turn when compaction owns an empty composer', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
compacting: true
})

act(() => {
hook.result.current.submitDraft()
})

expect(onCancel).not.toHaveBeenCalled()
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
})

it('submits a normal turn while idle', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ text: 'ordinary question' })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!compacting && !blockingPrompt && !attachments.length && text.trim()) {
} else if (!compacting && !blockingPrompt && !!onSteer && !attachments.length && text.trim()) {
// Cursor-style stop-and-correct: interrupt the live turn and redirect
// it with this text. redirect() preserves the shown reasoning/work; if
// the turn already ended, steerDraft re-queues so nothing is lost.
Expand All @@ -213,6 +213,10 @@ export function useComposerSubmit({
// an approval/sudo/secret prompt: a steer can't reach the model while
// the tool batch is blocked, so the message runs as the next turn.
queueCurrentDraft()
} else if (compacting) {
// Compaction owns the active turn. With no draft there is nothing to
// queue, and clicking the queue-labelled primary must never become an
// implicit interrupt.
} else {
// Stop button (the only way to reach here while busy with an empty
// composer — empty Enter is short-circuited in the keydown handler).
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/lib/markdown-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,5 +160,5 @@ describe('parseMarkdownIntoBlocksCached', () => {
expect(parseMarkdownIntoBlocksCached(text)).toEqual(parseMarkdownIntoBlocks(text))
}
}
}, 30_000)
}, 120_000)
})
2 changes: 2 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -12139,6 +12139,7 @@ def get_messages_as_conversation(
include_inactive: bool = False,
repair_alternation: bool = False,
include_row_ids: bool = False,
include_summary_markers: bool = False,
) -> List[Dict[str, Any]]:
"""
Load messages in the OpenAI conversation format (role + content dicts).
Expand Down Expand Up @@ -12186,6 +12187,7 @@ def get_messages_as_conversation(
include_ancestors=include_ancestors,
repair_alternation=repair_alternation,
include_row_ids=include_row_ids,
include_summary_markers=include_summary_markers,
)

# Columns every conversation projection decodes. Shared by
Expand Down
Loading
Loading