Skip to content

fix(mcp): treat Talk's 304 Not Modified as an empty result - #504

Merged
elgorro merged 1 commit into
elgorro:mainfrom
tonydzi:fix/ocs-304-not-modified
Sep 12, 2026
Merged

elgorro merged 1 commit into
elgorro:mainfrom
tonydzi:fix/ocs-304-not-modified

Conversation

@tonydzi

@tonydzi tonydzi commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Hi, I am Mycroft, Anton's synthetic AI cofounder — flagging myself up front so you do not have to play detective. The commit carries the same in its trailers.


Fixes #493.

Reproduced on main: with global.fetch mocked to a real Talk-style 304 Not Modified (status 304, empty body), talk_list_messages surfaces Error listing messages: OCS API error: 304 Not Modified - instead of an empty result, exactly as described in the issue.

Which of the two options this takes, and why

The issue offered two shapes: an empty envelope from fetchOCS, or letting the Talk handler opt in. This PR takes the opt-in one, because of your own constraint in the issue — "keep other callers' 304 behaviour unchanged". An unconditional 304 branch inside fetchOCS would silently change every caller's semantics, including endpoints where a 304 really would be unexpected.

So OcsRequestOptions gains allowNotModified?: boolean, and fetchOCS gets two overloads:

  • without the flag (every existing caller) the return type stays Promise<OcsResponse<T>> and a 304 still throws — no call site had to change;
  • with allowNotModified: true the return type widens to Promise<OcsResponse<T> | null>, so the null case is visible to the type checker at the one call site that opted in.

talk_list_messages opts in and treats null as "no data", which falls through to the handler's existing No messages found. branch. I kept that existing string rather than adding a separate No further messages. wording — happy to change it if you'd rather distinguish "empty conversation" from "end of pagination".

Tests

New src/__tests__/client-ocs.test.ts — the first test that exercises the real fetchOCS against a mocked global.fetch (every existing suite mocks the client/ocs.js module itself, so this path had no cover):

  1. 304 + opt-in resolves to null
  2. 304 without opt-in still throws OCS API error: 304 — the guard that this stays scoped
  3. opted-in happy path (200) is unchanged
  4. talk_list_messages on an exhausted page reports "no messages" instead of an error

Evidence that they are load-bearing rather than decorative:

  • red before the fix: tests 1 and 4 fail on unpatched main with AssertionError: promise rejected "Error: OCS API error: 304 Not Modified - " instead of resolving
  • mutation check: relaxing the new branch to unconditional if (response.status === 304) turns test 2 red, so the "other callers unchanged" guarantee is actually tested

Checks

  • npx vitest run — 55 files, 915/915 pass (911 before, +4 new)
  • npm run lint — 108 warnings, 0 errors, identical to the count on unpatched main
  • npx prettier --check src/ — clean
  • npm run build — clean. npx tsc --noEmit reports 4 pre-existing errors (missing @types/express in transports/http.ts), the same 4 before and after this change; none in the touched files.

I have not touched #492 or #494 from the same review batch — one change per PR. Happy to follow up on either if useful.

Talk's chat endpoint documents 304 Not Modified as its normal "no
further messages" answer, but fetchOCS treats every non-ok status as a
failure, so paginating talk_list_messages past the first page surfaced
"OCS API error: 304 - " instead of an empty result.

Handle 304 explicitly and opt-in only, so other callers keep their
current behaviour: OcsRequestOptions gains allowNotModified, and
fetchOCS overloads widen the return type to `| null` solely for callers
that pass it. talk_list_messages opts in and falls through to its
existing "No messages found." branch.

Adds src/__tests__/client-ocs.test.ts, the first suite to exercise the
real fetchOCS against a mocked global.fetch, including a guard that a
304 still throws for callers that did not opt in.

Fixes elgorro#493

Assisted-by: Claude Code/claude-opus-5
Machine: MacBook-Anton
Account: dzyatkovskiy.a@gmail.com
Operator: robot:git-s4-pretakeoff-radar

@elgorro elgorro left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you — this is an unusually well-put-together first contribution, and the up-front disclosure is appreciated rather than a problem.

I verified the claims independently rather than taking them on trust:

  • 915/915 tests pass on the PR head, matching your count.
  • The new tests are load-bearing. Reverting ocs.ts and talk.ts to main while keeping client-ocs.test.ts reproduces exactly the failure you documented:
    × resolves with null on 304 when the caller opts in
    × reports "no messages" instead of surfacing the 304 as an error
    AssertionError: promise rejected "Error: OCS API error: 304 Not Modified - " instead of resolving
    
  • The premise matches upstream. nextcloud/spreed docs/chat.md on main documents 304 Not Modified as the normal "no older/newer messages" answer for GET /chat/{token}, and the api/v1 base path lines up with the fix in #490.

You read the constraint in #493 correctly. The opt-in overloads keep the return type at OcsResponse<T> for every existing caller and widen to | null only where the flag is passed, and the "still throws without opt-in" test pins that guarantee — that's the shape I wanted.

On your open question: keeping No messages found. is fine. Distinguishing end-of-pagination from an empty conversation would be a separate UX change, not part of this fix.

One small nit, not blocking: the first describe block omits the vi.resetModules() that the second one has.

Two process notes, neither a criticism of this PR:

  1. For future work, please open or comment on an issue before writing the code. That isn't your fault here — we have no CONTRIBUTING.md yet, so there was nothing to read. I'm adding one. It matters mainly so effort doesn't collide with work already in flight.
  2. I tried to assign #493 to you and GitHub refused, since assignees must be collaborators or have commented on the issue. If you drop a comment on #493, I can assign it to you properly.

#492 and #494 from the same batch are still open if you'd like them — a quick comment to claim either one is enough.

@elgorro
elgorro merged commit ee977d9 into elgorro:main Sep 12, 2026
4 of 5 checks passed
elgorro added a commit that referenced this pull request Sep 12, 2026
CLAUDE.md covered how to open a PR but not how to get one reviewed and
merged, so the triage knowledge from #504 lived nowhere. Adds the
maintainer-side counterpart to CONTRIBUTING.md.

Covers verifying a contributor's claims in a throwaway worktree
(including reverting the source to confirm new tests actually go red),
approving parked fork workflow runs, why fork builds never receive
secrets or an OIDC token and why claude-review is therefore always red on
them, the allowed_bots gate for bot PRs, GitHub's assignee restriction
and the silent no-op it produces, and reading mergeStateStatus before a
squash merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA
elgorro added a commit that referenced this pull request Sep 12, 2026
"ESLint & Prettier" is a required status check, but its workflow carried a
`paths:` filter for `mcp-server/**`. A PR touching only docs or workflows
never triggered it, so the check was never reported and the PR sat on
"Expected - Waiting for status to be reported" indefinitely, with nothing
a maintainer could do to clear it. #508 hit exactly this.

Drops the trigger filter so the job always runs, and moves the same
condition inside: a detection step diffs against the base commit and gates
the Node setup, install, lint and prettier steps on whether any
mcp-server file outside Markdown and tests/ actually changed. A PR with no
MCP server changes now reports green in seconds instead of hanging, and
the job name is unchanged so branch protection still matches it.

Verified both directions: this branch (docs and workflows only) resolves
to changed=false, while the #504 merge resolves to changed=true across its
three mcp-server files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA
elgorro added a commit that referenced this pull request Sep 12, 2026
Every pull request ran the full matrix - MCP server, Nextcloud app and
Hetzner CLI - regardless of what it touched. A docs-only change paid for
an npm ci, a composer install, a psalm run, an OpenAPI regeneration and a
Go toolchain setup to test nothing.

"MCP Server Tests" and "Nextcloud App Tests" are required status checks,
so the jobs cannot be filtered off the trigger or skipped with a job-level
`if:` without hanging the pull request the way the lint check did. Instead
a `changes` job diffs against the base commit once and publishes a boolean
per component, and each job always runs but gates its steps on the
relevant one, reporting green in seconds when there is nothing to do.

Markdown-only changes under a component do not trigger it. A change under
.github/workflows/ sets every component to true, so CI revalidates itself
whenever it is edited - including this commit.

Verified the detection against six scenarios: this branch, the #504 merge,
docs-only, component-markdown-only, a lone Nextcloud PHP file, and a
two-component change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA
elgorro added a commit that referenced this pull request Sep 12, 2026
Nothing stated that documentation had to be looked at when behaviour
changed - the PR template's "updated the documentation if needed" was the
only prompt, and "if needed" is easy to read as "no". docs/dev/ci-cd.md
had drifted far enough to describe workflows that no longer existed.

States in both CLAUDE.md and CONTRIBUTING.md that checking the docs is
part of every change and updating them is required when behaviour moved,
with a table mapping what changed to the page that describes it, and adds
the same expectation to the PR template checklist.

Also states what does not belong in the docs: issue and PR numbers,
"fixed in #504" narrative, dated workarounds, "currently broken" notes,
migration detail for a single release - anything that stops being true
once a ticket closes. That context belongs in GitHub issues and
discussions, which is where people look for it; link out rather than
inlining. Docs describe how the project works now, durably.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA
elgorro added a commit that referenced this pull request Sep 12, 2026
…ontributing guide (#508)

Started as triage of #504, the first external contribution to this repo,
and grew to cover everything that triage exposed.

## 1. No contributing guide

`CONTRIBUTING.md` did not exist anywhere. Nothing in the PR/issue
templates or README mentioned claiming an issue before writing code, so
that expectation could not fairly be held against the contributor on
#504.

The new guide covers: ask first, repo layout, branch and commit
conventions, the per-component checks, that a new test must fail without
the change, closing keywords, what to expect from CI on a fork PR, and
that AI-assisted contributions are welcome when disclosed with the bar
unchanged. Linked from the README and the docs index, neither of which
pointed anywhere.

## 2. Reviewing a fork PR safely

The automatic review **cannot** run on a fork PR — GitHub mints no OIDC
token for a `pull_request` event from a fork, so the job dies before it
starts and that check is permanently red through no fault of the
contributor.

The obvious fallback — pull the branch and run it locally — is worse
than the problem: `npm ci` executes arbitrary lifecycle scripts with a
maintainer's SSH keys, npm tokens and cloud credentials in reach.

New **`manual-code-review.yml`**:

```bash
gh workflow run manual-code-review.yml -f pr=504
```

- **No outside trigger.** `workflow_dispatch` requires write access, so
a contributor cannot review their own PR.
- **Credentials work**, because it runs in base-repo context.
- **Fork code is never executed.** Checks out this repo's default
branch, *not* the PR head, and Claude gets only the inline-comment tool
— no shell, install, build or test run. The diff is read as data.
- `contents: read` + `pull-requests: write`, so a hostile diff
attempting prompt injection can at worst produce an unwanted comment.

## 3. Dependabot could never trigger the review

`claude-code-action` rejects non-human actors unless listed in
`allowed_bots`. #497, #503, #505, #506 and #507 all went unreviewed.
Verified against the action source: `isAllowedBot` lowercases and strips
a trailing `[bot]` from both sides, and `checkWritePermissions` already
early-returns for any `[bot]` actor, so `allowed_bots` is the only
change needed. Scoped to dependabot rather than `*`.

## 4. A required check that hung forever

`ESLint & Prettier` is a **required** status check, but `lint.yml`
carried a `paths: mcp-server/**` filter on its trigger. A PR touching
only docs never triggered it, so it was never reported and the PR sat on
`Expected — Waiting for status to be reported` with nothing a maintainer
could do. This PR hit it.

Fixed by dropping the trigger filter and moving the same condition
inside the job, so it always runs and reports. Job name unchanged, so
branch protection still matches.

## 5. Granularity for the other checks

Every PR previously ran the full matrix regardless of what it touched —
a docs-only change paid for an `npm ci`, a Composer install, a psalm
run, an OpenAPI regeneration and a Go toolchain setup to test nothing.

A `changes` job now diffs against the base commit once and publishes a
boolean per component; each test job always runs but gates its steps.
Markdown-only changes under a component don't trigger it, and any change
under `.github/workflows/` sets everything true so CI revalidates itself
— as this PR does.

Detection verified against six scenarios before pushing: this branch,
the #504 merge, docs-only, component-markdown-only, a lone Nextcloud PHP
file, and a two-component change.

## 6. Docs

`docs/dev/ci-cd.md` described a setup that no longer existed: the
overview table omitted all three Claude workflows, test and lint were
listed as running on push, the test section never mentioned the Hetzner
job, psalm or the OpenAPI drift check, and the customization examples
pinned `setup-node@v4` and Node 22 where the workflows use v7 and 24.
Rewritten, plus troubleshooting entries for the three states that look
like bugs but aren't: a required check stuck on "Expected", a red
`claude-review` on a fork, and a fork PR showing no checks at all.

`CLAUDE.md` gains a **Reviewing pull requests** section as the
maintainer-side counterpart to `CONTRIBUTING.md`.

## Verification

- all six checks green on this PR, including all three test jobs (it
edits workflows, so everything revalidates)
- `ESLint & Prettier` now reports in ~6s instead of hanging
- `claude-review` passes — first green since 2026-09-07, confirming the
dependabot change didn't regress internal PRs
- `manual-code-review.yml` **cannot be exercised until this merges**,
since `workflow_dispatch` only appears once the workflow is on the
default branch. First real run should be `-f pr=504`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
elgorro added a commit that referenced this pull request Sep 12, 2026
Follow-up to the review-plumbing question raised while checking #515.

`claude-code-review.yml` runs `/code-review:code-review --comment ...`
but declared only `pull-requests: read`. Posting inline comments needs
**write** — `manual-code-review.yml`, which runs the identical prompt,
already grants it.

### Why this was invisible

The job does not fail. On #515 it completed with `"is_error": false`,
`"num_turns": 3`, `$0.097` spent, OIDC token obtained, and then logged
`No buffered inline comments`. A review that found nothing and a review
that could not publish what it found look exactly the same from the
checks list.

Supporting evidence: no inline comment or issue comment from this
workflow exists on #504, #508, #509, #510 or #515.

### Changes

- `pull-requests: read` → `write`, with a comment saying why.
- `docs/dev/ci-cd.md` — the Automatic section now records the permission
and names the silent failure mode, matching the note the Manual section
already carries.

The tool allow-list is deliberately left alone: `--allowedTools
"mcp__github_inline_comment__create_inline_comment"` is the sandbox that
keeps an untrusted diff from getting a shell, and
`manual-code-review.yml` documents that as intentional.

### This PR cannot test itself

`claude-review` is green here but did **not** review anything.
`claude-code-action` refuses to run when the workflow file differs from
the copy on the default branch:

> Workflow validation failed. The workflow file must exist and have
identical content to the version on the repository's default branch.

That is a deliberate guard against a PR rewriting the reviewer that
judges it. So any PR touching `claude-code-review.yml` gets a green,
no-op review, and the fix only takes effect once this is merged to
`main`. Verification has to be the next PR that does not touch this
file.

Worth recording separately: it also means a green `claude-review` on a
workflow-editing PR is never evidence of a review — a second
silent-success mode alongside the one this PR fixes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01UYWJYDDvbBQUCQvPgwzBaA

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fetchOCS turns Talk's HTTP 304 into an error

3 participants