-
Notifications
You must be signed in to change notification settings - Fork 12
Fix blank code blocks in assistant #5772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f6b5566
283ba0e
fe6879a
f3d3f89
38155d6
46d5fc2
ca418d6
3391d1d
f907077
ba5f454
b888ce8
eaed142
904e31d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||||||||||||||||||
| import { registerDestructor } from '@ember/destroyable'; | ||||||||||||||||||||||||||||||
| import { service } from '@ember/service'; | ||||||||||||||||||||||||||||||
| import { tracked } from '@glimmer/tracking'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -27,56 +28,115 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> { | |||||||||||||||||||||||||||||
| @tracked errorMessage: string | undefined | null = null; | ||||||||||||||||||||||||||||||
| codePatchStatus: CodePatchStatus | undefined | null = null; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Untracked on purpose. `modify` runs inside the resource's tracked cache, so | ||||||||||||||||||||||||||||||
| // anything it reads there re-runs it when written — and it does read load | ||||||||||||||||||||||||||||||
| // state: `isDataLoaded` consumes `originalCode` and `modifiedCode`, and | ||||||||||||||||||||||||||||||
| // `errorMessage` is tracked as well. What keeps that entanglement safe is an | ||||||||||||||||||||||||||||||
| // invariant worth stating, because an edit can break it without looking | ||||||||||||||||||||||||||||||
| // wrong: every path in `modify` that reads those returns without writing | ||||||||||||||||||||||||||||||
| // them, so a finished load costs one further `modify` and stops. Keeping the | ||||||||||||||||||||||||||||||
| // mid-flight signal out of the entanglement spares a second invalidation per | ||||||||||||||||||||||||||||||
| // load; a path that read either one and then started a load would re-enter | ||||||||||||||||||||||||||||||
| // without bound. The template reads the task's own `isRunning`, which is | ||||||||||||||||||||||||||||||
| // tracked and safe to render from. | ||||||||||||||||||||||||||||||
| private loadInFlight = false; | ||||||||||||||||||||||||||||||
| private abortController: AbortController | undefined; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @service declare private cardService: CardService; | ||||||||||||||||||||||||||||||
| @service declare private toolService: ToolService; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| constructor(owner: object) { | ||||||||||||||||||||||||||||||
| super(owner); | ||||||||||||||||||||||||||||||
| registerDestructor(this, () => this.abortController?.abort()); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| modify(_positional: never[], named: CodeDiffResourceArgs['named']) { | ||||||||||||||||||||||||||||||
| let { fileUrl, searchReplaceBlock, codePatchStatus } = named; | ||||||||||||||||||||||||||||||
| let fileOrPatchChanged = | ||||||||||||||||||||||||||||||
| this.fileUrl !== fileUrl || | ||||||||||||||||||||||||||||||
| this.searchReplaceBlock !== searchReplaceBlock; | ||||||||||||||||||||||||||||||
| let appliedStateChanged = | ||||||||||||||||||||||||||||||
| this.codePatchStatus === 'applied' || codePatchStatus === 'applied'; | ||||||||||||||||||||||||||||||
| if (fileOrPatchChanged || appliedStateChanged) { | ||||||||||||||||||||||||||||||
| this.originalCode = null; | ||||||||||||||||||||||||||||||
| this.modifiedCode = null; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| this.errorMessage = null; | ||||||||||||||||||||||||||||||
| this.codePatchStatus !== codePatchStatus && | ||||||||||||||||||||||||||||||
| (this.codePatchStatus === 'applied' || codePatchStatus === 'applied'); | ||||||||||||||||||||||||||||||
| let inputsChanged = fileOrPatchChanged || appliedStateChanged; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| this.fileUrl = fileUrl; | ||||||||||||||||||||||||||||||
| this.searchReplaceBlock = searchReplaceBlock; | ||||||||||||||||||||||||||||||
| this.codePatchStatus = codePatchStatus; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!fileUrl) { | ||||||||||||||||||||||||||||||
| // These arguments are recomputed on every invalidation of the room | ||||||||||||||||||||||||||||||
| // resource, which during streaming arrives continuously — so `modify` runs | ||||||||||||||||||||||||||||||
| // far more often than anything about this diff actually changes. Only a | ||||||||||||||||||||||||||||||
| // change to what is being diffed justifies discarding what we have and | ||||||||||||||||||||||||||||||
| // starting again. Restarting on every invalidation cancelled the load | ||||||||||||||||||||||||||||||
| // mid-flight and cleared the error along with it, leaving the resource | ||||||||||||||||||||||||||||||
| // holding neither code nor a message to show, and asking the realm for the | ||||||||||||||||||||||||||||||
| // file again each time it happened. | ||||||||||||||||||||||||||||||
| if (!inputsChanged) { | ||||||||||||||||||||||||||||||
| // `applied` is checked first, and not only for cheapness: it is a | ||||||||||||||||||||||||||||||
| // terminal state with no diff to fetch, and it is the one state a load | ||||||||||||||||||||||||||||||
| // returns from without recording either code or an error. Falling through | ||||||||||||||||||||||||||||||
| // to `load.perform()` in it would write the state this condition just | ||||||||||||||||||||||||||||||
| // read, from inside the same cache computation — dirtying the resource's | ||||||||||||||||||||||||||||||
| // own cache and re-entering `modify` on every read. | ||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| codePatchStatus === 'applied' || | ||||||||||||||||||||||||||||||
| this.isDataLoaded || | ||||||||||||||||||||||||||||||
| this.errorMessage || | ||||||||||||||||||||||||||||||
| this.loadInFlight | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
|
Comment on lines
+75
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] This early return is correct today, and it is one line away from being an infinite invalidation loop. The thing that keeps it safe lives in a different file and is not written down anywhere. Background — why reading tracked state here is different from reading it anywhere else. That combination is safe here for one reason only: every path that reads them returns without writing. Data loaded → return. Error set → return. Load in flight → return. The only way through to Except one. Why it doesn't fire today. I traced every consumer of this resource before writing the above. There are exactly two, both in
So The fix is one clause, and it also makes the dead branch in
Suggested change
With that, Scope. Latent, not live — nothing in the current app reaches it. Non-blocking, but it costs four tokens to remove and the failure mode if someone later renders a loading state for an applied patch is a render loop, not a wrong pixel.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Taken, in ba5f454 — and the trace is right down to the synchronous write, which is the part that makes it a loop rather than a stale read. I confirmed the reachability argument independently before changing anything: Worth naming the shape precisely, because it needs two calls: the first with I ordered the new clause first rather than last: if (
codePatchStatus === 'applied' ||
this.isDataLoaded ||
this.errorMessage ||
this.loadInFlight
) {
I left the |
||||||||||||||||||||||||||||||
| this.originalCode = null; | ||||||||||||||||||||||||||||||
| this.modifiedCode = null; | ||||||||||||||||||||||||||||||
| this.errorMessage = null; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!fileUrl) { | ||||||||||||||||||||||||||||||
| this.errorMessage = 'Missing file URL in the code block'; | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!searchReplaceBlock) { | ||||||||||||||||||||||||||||||
| this.originalCode = null; | ||||||||||||||||||||||||||||||
| this.modifiedCode = null; | ||||||||||||||||||||||||||||||
| this.errorMessage = 'Missing search and replace block'; | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||
| !fileOrPatchChanged && | ||||||||||||||||||||||||||||||
| codePatchStatus !== 'applied' && | ||||||||||||||||||||||||||||||
| this.originalCode != null && | ||||||||||||||||||||||||||||||
| this.modifiedCode != null | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| this.load.perform(); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| get isDataLoaded() { | ||||||||||||||||||||||||||||||
| return this.originalCode != null && this.modifiedCode != null; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // True between starting a load and having something to show for it, so a | ||||||||||||||||||||||||||||||
| // patch whose diff has not arrived can say so rather than render as nothing. | ||||||||||||||||||||||||||||||
| get isLoadingDiff() { | ||||||||||||||||||||||||||||||
| return this.load.isRunning && !this.isDataLoaded && !this.errorMessage; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private load = restartableTask(async () => { | ||||||||||||||||||||||||||||||
| // Cancelling the task does not cancel a request already in flight, so | ||||||||||||||||||||||||||||||
| // without this an abandoned load runs to completion and its answer is | ||||||||||||||||||||||||||||||
| // merely discarded — the work still reaches the realm. | ||||||||||||||||||||||||||||||
| this.abortController?.abort(); | ||||||||||||||||||||||||||||||
| let abortController = new AbortController(); | ||||||||||||||||||||||||||||||
| this.abortController = abortController; | ||||||||||||||||||||||||||||||
| this.loadInFlight = true; | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await this.loadDiff(abortController.signal); | ||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the inputs change after Useful? React with 👍 / 👎.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Claude Code 🤖] Real bug, and precisely diagnosed. Fixed in f3d3f89. You're right about the boundary: Worth naming the exact hole, because it wasn't where I'd assumed. The Took the second of your two options — verify after every await — rather than moving the work into the task body, because it holds regardless of how ember-concurrency cancels an async task, and I didn't want the correctness of this to rest on my reading of those internals. let result = await this.cardService.getSource(new URL(fileUrl), { signal });
if (signal.aborted) return;
originalCode = result.status === 404 ? '' : result.content;…and the same guard on both the resolve and reject paths of The destruction case you mention falls out of the same guard, since the destructor aborts the controller. Added a regression test that forces the ordering deterministically: two loads started with a deferred Not verified locally — Colima is down here, so CI is exercising it. |
||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||
| // Only the newest run owns the flag. A superseded run settling later must | ||||||||||||||||||||||||||||||
| // not report that loading has finished on behalf of the one that replaced | ||||||||||||||||||||||||||||||
| // it. | ||||||||||||||||||||||||||||||
| if (this.abortController === abortController) { | ||||||||||||||||||||||||||||||
| this.loadInFlight = false; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| private async loadDiff(signal: AbortSignal) { | ||||||||||||||||||||||||||||||
| let { fileUrl, searchReplaceBlock, codePatchStatus } = this; | ||||||||||||||||||||||||||||||
| if (codePatchStatus === 'applied') { | ||||||||||||||||||||||||||||||
| this.originalCode = null; | ||||||||||||||||||||||||||||||
|
|
@@ -95,17 +155,32 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> { | |||||||||||||||||||||||||||||
| if (!fileUrl || !searchReplaceBlock) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // This runs outside the task, so cancelling the task does not stop it here: | ||||||||||||||||||||||||||||||
| // every await is a point where a newer load may have taken over, and the | ||||||||||||||||||||||||||||||
| // abort signal only reaches the fetch. Check ownership after each one | ||||||||||||||||||||||||||||||
| // before writing to the resource, or a superseded load resumes later and | ||||||||||||||||||||||||||||||
| // overwrites the state — and the diff on screen — belonging to the patch | ||||||||||||||||||||||||||||||
| // that replaced it. | ||||||||||||||||||||||||||||||
| let originalCode: string; | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| let result = await this.cardService.getSource(new URL(fileUrl)); | ||||||||||||||||||||||||||||||
| if (result.status === 404) { | ||||||||||||||||||||||||||||||
| this.originalCode = ''; // We are creating a new file, so we don't have the original code | ||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| this.originalCode = result.content; | ||||||||||||||||||||||||||||||
| let result = await this.cardService.getSource(new URL(fileUrl), { | ||||||||||||||||||||||||||||||
| signal, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // A 404 means we are creating a new file, so there is no original code. | ||||||||||||||||||||||||||||||
| originalCode = result.status === 404 ? '' : result.content; | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||||||||||||||
| // Superseded by a newer load, or the resource went away. Neither is a | ||||||||||||||||||||||||||||||
| // failure to report. | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| this.errorMessage = `Failed to load code from ${fileUrl}`; | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| this.originalCode = originalCode; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let applySearchReplaceBlockCommand = new ApplySearchReplaceBlockTool( | ||||||||||||||||||||||||||||||
| this.toolService.toolContext, | ||||||||||||||||||||||||||||||
|
|
@@ -114,16 +189,23 @@ export class CodeDiffResource extends Resource<CodeDiffResourceArgs> { | |||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| let { resultContent: patchedCode } = | ||||||||||||||||||||||||||||||
| await applySearchReplaceBlockCommand.execute({ | ||||||||||||||||||||||||||||||
| fileContent: this.originalCode, | ||||||||||||||||||||||||||||||
| // This load's own code, not whatever the resource holds by now. | ||||||||||||||||||||||||||||||
| fileContent: originalCode, | ||||||||||||||||||||||||||||||
| codeBlock: searchReplaceBlock, | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| this.modifiedCode = patchedCode; | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| this.modifiedCode = this.originalCode; | ||||||||||||||||||||||||||||||
| if (signal.aborted) { | ||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| this.modifiedCode = originalCode; | ||||||||||||||||||||||||||||||
| this.errorMessage = | ||||||||||||||||||||||||||||||
| error instanceof Error ? error.message : String(error); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export function getCodeDiffResultResource( | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] The guard is right, and the same raw string still reaches an unguarded
new URLone component away.Confirmation on this getter. The fallback is the correct shape:
new URL('malformed file url')throws, and'malformed file url'.split('/').pop()gives back the whole string, which is what the model wrote and therefore the most useful thing to show. The test pins exactly that.Where it doesn't reach.
this.file(the@cachedgetter above) builds itsFileDefwithsourceUrl: this.sourceUrl ?? '', andsourceUrlreturnsthis.args.codeData.fileUrlverbatim for a non-new file — the same unparseable string. ThatFileDefgoes straight into<AttachedFileDropdownMenu @file={{this.file}} …>, whoseopenInCodeModedoesnew URL(this.args.file.sourceUrl!)(packages/host/app/components/ai-assistant/attached-file-dropdown-menu.gts). The''case is reachable too:sourceUrlreturnsnullfor a new file that isn't applied yet, sothis.file.sourceUrlis''andnew URL('')throws the same way.That is a click-time throw inside an
@action, not a render-time one, so it does not undo what this change fixes — the message renders, which was the point. But the menu item is offered on a header whose file URL this change has just established may not be a URL, and choosing it throws.Scope. Follow-up, non-blocking, and arguably pre-existing rather than introduced here — the
''path predates this change. Worth either guardingopenInCodeModethe same way, or omitting the "open in code mode" item whensourceUrldoesn't parse, so the header degrades consistently instead of half-way.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Claude Code 🤖] The main finding holds and I'm treating it as a follow-up rather than folding it in — but one detail of it doesn't survive checking, and it's the half that would have decided the fix.
Confirmed:
openInCodeModedoesnew URL(this.args.file.sourceUrl!)with no guard, and for a non-new filesourceUrlhands itcodeData.fileUrlverbatim. So the unparseable-but-non-empty case —malformed file url, exactly what this change made renderable — is offered as an enabled menu item that throws on click.Not confirmed: the
''case. The menu item carriesdisabled: !this.args.file?.sourceUrl(attached-file-dropdown-menu.gts), and''is falsy, so the new-file-not-yet-applied path you describe is already disabled rather than reachable. That matters for the remedy: "omit the item whensourceUrldoesn't parse" is the right shape, but it's an extension of a guard that exists, not a missing guard — the existing one just tests presence where it needs to test parseability.Why not here. It's a different component, a click-time throw rather than a render-time one, and the fix wants its own test around the menu rather than the header. This branch has had a long CI road and I'd rather not widen it for something no current path reaches on render. Happy to do it as a follow-up —
disabled: !parses(this.args.file?.sourceUrl)with a small helper is the whole change.Agreed on the framing though: this change established that a header's file URL may not be a URL, so anything downstream taking that string as one is now on notice.