From c4523c70b58ae4dae035143f7cf3881683d8a3cb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 25 Feb 2026 13:53:23 +0700 Subject: [PATCH] chore: commit all local changes --- LICENSE | 684 +++++++++++++++++- package.json | 2 +- .../e2e/agent-scroll-submit-firefox.spec.ts | 197 +++++ packages/app/playwright.firefox.config.ts | 30 + .../app/src/components/agent-input-area.tsx | 6 +- .../agent-stream-render-strategy.ts | 90 ++- .../app/src/components/agent-stream-view.tsx | 72 +- .../src/contexts/session-stream-reducers.ts | 9 +- .../src/screens/agent/agent-ready-screen.tsx | 5 +- packages/app/src/types/stream.test.ts | 89 +++ packages/app/src/types/stream.ts | 33 +- .../src/server/agent/agent-manager.test.ts | 40 + .../server/src/server/agent/agent-manager.ts | 15 +- .../server/agent/timeline-projection.test.ts | 304 +++++++- .../src/server/agent/timeline-projection.ts | 102 +++ .../daemon-e2e/timeline-window.e2e.test.ts | 98 +++ packages/server/src/server/session.ts | 105 ++- packages/website/src/routes/index.tsx | 4 +- 18 files changed, 1818 insertions(+), 67 deletions(-) create mode 100644 packages/app/e2e/agent-scroll-submit-firefox.spec.ts create mode 100644 packages/app/playwright.firefox.config.ts create mode 100644 packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts diff --git a/LICENSE b/LICENSE index bbeeda675..4a76727e5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,671 @@ -MIT License +Copyright (c) 2025-present Mohamed Boudra -Copyright (c) 2025 Mohamed Boudra +Portions of this software are licensed as follows: -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +* All third party components incorporated into the Paseo Software are + licensed under the original license provided by the owner of the + applicable component. +* All content outside of the above mentioned restrictions is available + under the "AGPLv3" license as defined below. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/package.json b/package.json index e2d1735bf..91f9daa78 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "mcp" ], "author": "moboudra", - "license": "MIT", + "license": "AGPL-3.0-or-later", "overrides": { "lightningcss": "1.30.1" }, diff --git a/packages/app/e2e/agent-scroll-submit-firefox.spec.ts b/packages/app/e2e/agent-scroll-submit-firefox.spec.ts new file mode 100644 index 000000000..693d6ad78 --- /dev/null +++ b/packages/app/e2e/agent-scroll-submit-firefox.spec.ts @@ -0,0 +1,197 @@ +import { expect, test, type Page } from "@playwright/test"; + +const SERVER_ID = + process.env.PLAYWRIGHT_REPRO_SERVER_ID ?? "srv_ETXtcjYRGrCI"; +const AGENT_ID = + process.env.PLAYWRIGHT_REPRO_AGENT_ID ?? + "3533e6c3-c0b3-4310-b85c-eb07cbb501a0"; +const APP_BASE_URL = process.env.PLAYWRIGHT_REPRO_BASE_URL ?? "http://localhost:8081"; +const DAEMON_ENDPOINT = + process.env.PLAYWRIGHT_REPRO_DAEMON_ENDPOINT ?? "127.0.0.1:6767"; +const SUBMIT_TEXT = process.env.PLAYWRIGHT_REPRO_MESSAGE ?? "hello"; +const AGENT_URL = `${APP_BASE_URL}/h/${SERVER_ID}/agent/${AGENT_ID}`; +const NEAR_BOTTOM_THRESHOLD_PX = 64; + +type ScrollMetrics = { + offsetY: number; + contentHeight: number; + viewportHeight: number; + distanceFromBottom: number; +}; + +test.use({ browserName: "firefox" }); + +function seedDaemonRegistryScript(params: { + serverId: string; + endpoint: string; + nowIso: string; +}) { + const daemon = { + serverId: params.serverId, + label: "localhost", + connections: [ + { + id: `direct:${params.endpoint}`, + type: "direct", + endpoint: params.endpoint, + }, + ], + preferredConnectionId: `direct:${params.endpoint}`, + createdAt: params.nowIso, + updatedAt: params.nowIso, + }; + + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + serverId: params.serverId, + provider: "codex", + providerPreferences: { + claude: { model: "haiku" }, + codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" }, + }, + }) + ); +} + +async function readScrollMetrics(page: Page): Promise { + return page.getByTestId("agent-chat-scroll").evaluate((root: Element) => { + const rootElement = root as HTMLElement; + const candidates = [rootElement, ...Array.from(rootElement.querySelectorAll("*"))]; + const scrollElement = + candidates.find( + (element) => + element instanceof HTMLElement && + element.scrollHeight - element.clientHeight > 1 + ) ?? rootElement; + + const offsetY = Math.max(0, scrollElement.scrollTop); + const contentHeight = Math.max(0, scrollElement.scrollHeight); + const viewportHeight = Math.max(0, scrollElement.clientHeight); + const distanceFromBottom = Math.max( + 0, + contentHeight - (offsetY + viewportHeight) + ); + + return { + offsetY, + contentHeight, + viewportHeight, + distanceFromBottom, + }; + }); +} + +async function scrollUpFromBottom( + page: Page, + pixels: number +): Promise { + await page.getByTestId("agent-chat-scroll").evaluate( + (root: Element, amount: number) => { + const rootElement = root as HTMLElement; + const candidates = [ + rootElement, + ...Array.from(rootElement.querySelectorAll("*")), + ]; + const scrollElement = + candidates.find( + (element) => + element instanceof HTMLElement && + element.scrollHeight - element.clientHeight > 1 + ) ?? rootElement; + + const bottomOffset = Math.max( + 0, + scrollElement.scrollHeight - scrollElement.clientHeight + ); + scrollElement.scrollTop = Math.max(0, bottomOffset - amount); + }, + pixels + ); +} + +test("repro: submit while scrolled up should stay anchored to bottom (Firefox)", async ({ + page, +}, testInfo) => { + const userAgent = await page.evaluate(() => navigator.userAgent); + expect(userAgent).toContain("Firefox"); + + await page.addInitScript(seedDaemonRegistryScript, { + serverId: SERVER_ID, + endpoint: DAEMON_ENDPOINT, + nowIso: new Date().toISOString(), + }); + + await page.goto(AGENT_URL, { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("agent-chat-scroll")).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({ + timeout: 60_000, + }); + + // Require enough history so the repro actually scrolls away from bottom. + await expect + .poll(async () => { + const metrics = await readScrollMetrics(page); + return Math.max(0, metrics.contentHeight - metrics.viewportHeight); + }) + .toBeGreaterThan(300); + + await scrollUpFromBottom(page, 900); + await page.waitForTimeout(250); + const beforeSubmit = await readScrollMetrics(page); + const beforePath = testInfo.outputPath("before-submit.png"); + await page.screenshot({ path: beforePath, fullPage: true }); + await testInfo.attach("before-submit", { + path: beforePath, + contentType: "image/png", + }); + + await page.getByRole("textbox", { name: "Message agent..." }).fill(SUBMIT_TEXT); + await page.getByRole("textbox", { name: "Message agent..." }).press("Enter"); + await page.waitForTimeout(1200); + + const afterSubmit = await readScrollMetrics(page); + const afterPath = testInfo.outputPath("after-submit.png"); + await page.screenshot({ path: afterPath, fullPage: true }); + await testInfo.attach("after-submit", { + path: afterPath, + contentType: "image/png", + }); + await testInfo.attach("firefox-scroll-metrics", { + body: JSON.stringify( + { + url: AGENT_URL, + submitText: SUBMIT_TEXT, + thresholdPx: NEAR_BOTTOM_THRESHOLD_PX, + beforeScreenshot: beforePath, + afterScreenshot: afterPath, + beforeSubmit, + afterSubmit, + }, + null, + 2 + ), + contentType: "application/json", + }); + + console.log( + `[firefox-scroll-repro] ${JSON.stringify( + { + submitText: SUBMIT_TEXT, + beforeSubmit, + afterSubmit, + }, + null, + 2 + )}` + ); + + expect(beforeSubmit.distanceFromBottom).toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX); + expect(afterSubmit.distanceFromBottom).toBeLessThanOrEqual( + NEAR_BOTTOM_THRESHOLD_PX + ); +}); diff --git a/packages/app/playwright.firefox.config.ts b/packages/app/playwright.firefox.config.ts new file mode 100644 index 000000000..cf66db355 --- /dev/null +++ b/packages/app/playwright.firefox.config.ts @@ -0,0 +1,30 @@ +import { defineConfig, devices } from "@playwright/test"; + +const baseURL = + process.env.E2E_BASE_URL ?? + `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; + +export default defineConfig({ + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [["list"]], + use: { + baseURL, + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + projects: [ + { + name: "Desktop Firefox", + use: { ...devices["Desktop Firefox"] }, + }, + ], +}); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index ce513e569..6f0557024 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -61,6 +61,8 @@ interface AgentInputAreaProps { onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void /** Optional draft context for listing commands before an agent exists. */ commandDraftConfig?: DraftCommandConfig + /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ + onMessageSent?: () => void } const EMPTY_ARRAY: readonly QueuedMessage[] = [] @@ -77,6 +79,7 @@ export function AgentInputArea({ autoFocus = false, onAddImages, commandDraftConfig, + onMessageSent, }: AgentInputAreaProps) { markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) const { theme } = useUnistyles() @@ -169,6 +172,7 @@ export function AgentInputArea({ }, [addImages, onAddImages]) const submitMessage = useCallback(async (text: string, images?: ImageAttachment[]) => { + onMessageSent?.() if (onSubmitMessageRef.current) { await onSubmitMessageRef.current({ text, images }) return @@ -177,7 +181,7 @@ export function AgentInputArea({ throw new Error('Host is not connected') } await sendAgentMessageRef.current(agentIdRef.current, text, images) - }, []) + }, [onMessageSent]) useEffect(() => { agentIdRef.current = agentId diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts index f7b8be888..547fe3928 100644 --- a/packages/app/src/components/agent-stream-render-strategy.ts +++ b/packages/app/src/components/agent-stream-render-strategy.ts @@ -137,6 +137,82 @@ function scrollAnchorIntoView(params: { return true; } +function forceScrollContainerToBottom( + refs: StreamRenderRefs, + fallbackOffset: number +): void { + const resolveNode = ( + input: unknown + ): HTMLElement | null => { + if (!(input instanceof HTMLElement)) { + return null; + } + if (input.scrollHeight - input.clientHeight > 1) { + return input; + } + let node: HTMLElement | null = input.parentElement; + while (node) { + if (node.scrollHeight - node.clientHeight > 1) { + return node; + } + node = node.parentElement; + } + return null; + }; + + const scrollViewHandle = refs.scrollViewRef.current as + | { + getNativeScrollRef?: () => unknown; + getScrollableNode?: () => unknown; + getInnerViewNode?: () => unknown; + getNativeRef?: () => unknown; + } + | null; + const anchorHandle = refs.bottomAnchorRef.current as + | ({ getNativeRef?: () => unknown } & object) + | null; + + const candidates: unknown[] = [ + scrollViewHandle?.getNativeScrollRef?.(), + scrollViewHandle?.getScrollableNode?.(), + scrollViewHandle?.getInnerViewNode?.(), + scrollViewHandle?.getNativeRef?.(), + scrollViewHandle, + typeof anchorHandle?.getNativeRef === "function" + ? anchorHandle.getNativeRef() + : anchorHandle, + ]; + + let scrollNode: HTMLElement | null = null; + for (const candidate of candidates) { + scrollNode = resolveNode(candidate); + if (scrollNode) { + break; + } + } + + if (!scrollNode && typeof document !== "undefined") { + scrollNode = resolveNode( + document.querySelector("[data-testid='agent-chat-scroll']") + ); + } + + if (!scrollNode) { + return; + } + + const snap = () => { + scrollNode.scrollTop = Math.max( + fallbackOffset, + scrollNode.scrollHeight - scrollNode.clientHeight + ); + }; + snap(); + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(snap); + } +} + function createStreamRenderStrategy( config: StreamRenderStrategyConfig ): StreamRenderStrategy { @@ -255,14 +331,20 @@ function createForwardStreamStrategy(): StreamRenderStrategy { getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), scrollToBottom: ({ refs, metrics, animated }) => { - if (scrollAnchorIntoView({ refs, animated })) { - return; + const bottomOffset = Math.max( + 0, + metrics.contentHeight - metrics.viewportHeight + ); + const usedAnchor = scrollAnchorIntoView({ refs, animated }); + if (!usedAnchor) { + refs.scrollViewRef.current?.scrollToEnd?.({ animated }); } - refs.scrollViewRef.current?.scrollToEnd?.({ animated }); + // Always apply deterministic bottom offset to avoid partial anchors. refs.scrollViewRef.current?.scrollTo?.({ - y: Math.max(0, metrics.contentHeight - metrics.viewportHeight), + y: bottomOffset, animated, }); + forceScrollContainerToBottom(refs, bottomOffset); }, scrollToOffset: ({ refs, offset, animated }) => { refs.scrollViewRef.current?.scrollTo({ y: offset, animated }); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 5f57b898c..2e967f774 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,12 +1,14 @@ import { Fragment, createElement, + forwardRef, isValidElement, + useCallback, useEffect, + useImperativeHandle, useMemo, useRef, useState, - useCallback, } from "react"; import type { ComponentType, ReactElement, ReactNode } from "react"; import { @@ -104,6 +106,10 @@ function renderStreamEdgeComponent( return createElement(component); } +export interface AgentStreamViewHandle { + scrollToBottom(): void; +} + export interface AgentStreamViewProps { agentId: string; serverId?: string; @@ -112,13 +118,13 @@ export interface AgentStreamViewProps { pendingPermissions: Map; } -export function AgentStreamView({ +export const AgentStreamView = forwardRef(function AgentStreamView({ agentId, serverId, agent, streamItems, pendingPermissions, -}: AgentStreamViewProps) { +}, ref) { const flatListRef = useRef>(null); const scrollViewRef = useRef(null); const bottomAnchorRef = useRef(null); @@ -139,6 +145,7 @@ export function AgentStreamView({ const hasScrolledInitially = useRef(false); const hasAutoScrolledOnce = useRef(false); const isNearBottomRef = useRef(true); + const pendingAnchorRequestRef = useRef(false); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollAnimatedRef = useRef(false); const scrollOffsetYRef = useRef(0); @@ -182,6 +189,7 @@ export function AgentStreamView({ hasScrolledInitially.current = false; hasAutoScrolledOnce.current = false; isNearBottomRef.current = true; + pendingAnchorRequestRef.current = false; setExpandedInlineToolCallIds(new Set()); }, [agentId]); @@ -224,14 +232,29 @@ export function AgentStreamView({ ] ); + const updateNearBottom = useCallback((value: boolean) => { + if (isNearBottomRef.current === value) return; + isNearBottomRef.current = value; + setIsNearBottom(value); + }, []); + + const requestAnchorToBottom = useCallback(() => { + pendingAnchorRequestRef.current = true; + }, []); + const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const previousOffsetY = scrollOffsetYRef.current; + const previousContentHeight = streamViewportMetricsRef.current.contentHeight; scrollOffsetYRef.current = contentOffset.y; streamViewportMetricsRef.current = { contentHeight: Math.max(0, contentSize.height), viewportHeight: Math.max(0, layoutMeasurement.height), }; + const offsetDelta = contentOffset.y - previousOffsetY; + const contentHeightDelta = + streamViewportMetricsRef.current.contentHeight - previousContentHeight; const threshold = Math.max(insets.bottom, 32); const nearBottom = isNearBottomForStreamRenderStrategy({ strategy: streamRenderStrategy, @@ -241,9 +264,22 @@ export function AgentStreamView({ viewportHeight: streamViewportMetricsRef.current.viewportHeight, }); - if (isNearBottomRef.current !== nearBottom) { - isNearBottomRef.current = nearBottom; - setIsNearBottom(nearBottom); + const pendingAnchorBefore = pendingAnchorRequestRef.current; + const shouldSuppressFalseNearBottom = + pendingAnchorBefore && + !nearBottom && + Math.abs(offsetDelta) <= 1 && + contentHeightDelta > 0; + if (shouldSuppressFalseNearBottom) { + updateNearBottom(true); + } else { + updateNearBottom(nearBottom); + } + + const shouldClearPendingAnchor = + pendingAnchorBefore && !nearBottom && Math.abs(offsetDelta) > 1; + if (shouldClearPendingAnchor) { + pendingAnchorRequestRef.current = false; } if (showDesktopWebScrollbar) { @@ -255,6 +291,7 @@ export function AgentStreamView({ showDesktopWebScrollbar, streamRenderStrategy, streamScrollbarMetrics, + updateNearBottom, ] ); @@ -282,12 +319,17 @@ export function AgentStreamView({ animated, }); scrollOffsetYRef.current = targetOffset; - isNearBottomRef.current = true; - setIsNearBottom(true); + updateNearBottom(true); }, - [streamRenderRefs, streamRenderStrategy] + [updateNearBottom, streamRenderRefs, streamRenderStrategy] ); + useImperativeHandle(ref, () => ({ + scrollToBottom() { + requestAnchorToBottom(); + }, + }), [requestAnchorToBottom]); + const handleContentSizeChange = useCallback( (width: number, height: number) => { const previousMetrics = streamViewportMetricsRef.current; @@ -310,7 +352,11 @@ export function AgentStreamView({ scrollToBottomInternal({ animated: false }); hasAutoScrolledOnce.current = true; hasScrolledInitially.current = true; - } else if (wasNearBottom || isNearBottomRef.current) { + } else if ( + wasNearBottom || + isNearBottomRef.current || + pendingAnchorRequestRef.current + ) { scrollToBottomInternal({ animated: false }); } } @@ -376,7 +422,7 @@ export function AgentStreamView({ return () => handle.cancel(); } - if (!isNearBottomRef.current) { + if (!isNearBottomRef.current && !pendingAnchorRequestRef.current) { return; } @@ -393,8 +439,6 @@ export function AgentStreamView({ function scrollToBottom() { const animated = streamRenderStrategy.shouldAnimateManualScrollToBottom(); scrollToBottomInternal({ animated }); - isNearBottomRef.current = true; - setIsNearBottom(true); } const flatListData = useMemo(() => { @@ -982,7 +1026,7 @@ export function AgentStreamView({ ); -} +}); function normalizeInlinePath( rawPath: string, diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index fab26012d..1158b8454 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -153,7 +153,9 @@ export function processTimelineResponse( // ---------------------------------------------------------------- // Replace path: full hydration from scratch // ---------------------------------------------------------------- - nextTail = hydrateStreamState(toHydratedEvents(timelineUnits)); + nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), { + source: "canonical", + }); nextHead = []; if (payload.startCursor && payload.endCursor) { @@ -222,7 +224,9 @@ export function processTimelineResponse( if (acceptedUnits.length > 0) { nextTail = acceptedUnits.reduce( (state, { event, timestamp }) => - reduceStreamUpdate(state, event, timestamp), + reduceStreamUpdate(state, event, timestamp, { + source: "canonical", + }), currentTail ); } @@ -385,6 +389,7 @@ export function processAgentStreamEvent( head: currentHead, event, timestamp, + source: "live", }) : { tail: currentTail, diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 6c7f98385..886ef8a49 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -31,7 +31,7 @@ import { import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { AgentStreamView } from "@/components/agent-stream-view"; +import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { FileDropZone } from "@/components/file-drop-zone"; @@ -240,6 +240,7 @@ function AgentScreenContent({ const resolvedAgentId = agentId; const { isArchivingAgent } = useArchiveAgent(); + const streamViewRef = useRef(null); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { @@ -1235,6 +1236,7 @@ function AgentScreenContent({ style={[styles.content, animatedKeyboardStyle]} > streamViewRef.current?.scrollToBottom()} /> )} diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index afc91513e..8bc746b0a 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -522,4 +522,93 @@ describe("stream reducer canonical tool calls", () => { authoritativeTimestamp.getTime() ); }); + + it("keeps canonical assistant/user/assistant order during replay", () => { + const state: StreamItem[] = [ + { + kind: "assistant_message", + id: "a1", + text: "Saved that preference. ", + timestamp: new Date("2025-01-01T11:20:00Z"), + }, + { + kind: "user_message", + id: "u1", + text: "the other qeustion is i mgiht be thinking that its winner takes it all", + timestamp: new Date("2025-01-01T11:20:01Z"), + }, + ]; + + const event: AgentStreamEventPayload = { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "Right. And it probably isn't.", + }, + }; + + const next = reduceStreamUpdate( + state, + event, + new Date("2025-01-01T11:20:02Z"), + { source: "canonical" } + ); + + assert.deepStrictEqual(next.map((item) => item.kind), [ + "assistant_message", + "user_message", + "assistant_message", + ]); + assert.strictEqual( + next[0]?.kind === "assistant_message" ? next[0].text : null, + "Saved that preference. " + ); + assert.strictEqual( + next[2]?.kind === "assistant_message" ? next[2].text : null, + "Right. And it probably isn't." + ); + }); + + it("keeps live optimistic assistant merge behavior", () => { + const state: StreamItem[] = [ + { + kind: "assistant_message", + id: "a1", + text: "Saved that preference. ", + timestamp: new Date("2025-01-01T11:21:00Z"), + }, + { + kind: "user_message", + id: "u1", + text: "the other qeustion is i mgiht be thinking that its winner takes it all", + timestamp: new Date("2025-01-01T11:21:01Z"), + }, + ]; + + const event: AgentStreamEventPayload = { + type: "timeline", + provider: "claude", + item: { + type: "assistant_message", + text: "Right. And it probably isn't.", + }, + }; + + const next = reduceStreamUpdate( + state, + event, + new Date("2025-01-01T11:21:02Z"), + { source: "live" } + ); + + assert.deepStrictEqual(next.map((item) => item.kind), [ + "assistant_message", + "user_message", + ]); + assert.strictEqual( + next[0]?.kind === "assistant_message" ? next[0].text : null, + "Saved that preference. Right. And it probably isn't." + ); + }); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index eb7016d1e..3db0d702e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -160,6 +160,8 @@ export interface TodoListItem { items: TodoEntry[]; } +export type StreamUpdateSource = "live" | "canonical"; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -230,7 +232,8 @@ function appendUserMessage( function appendAssistantMessage( state: StreamItem[], text: string, - timestamp: Date + timestamp: Date, + source: StreamUpdateSource ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -250,7 +253,11 @@ function appendAssistantMessage( // If the last item is a user_message (optimistic append to head during // interrupt), look one further back for the streaming assistant_message. const secondLast = state[state.length - 2]; - if (last?.kind === "user_message" && secondLast?.kind === "assistant_message") { + if ( + source === "live" && + last?.kind === "user_message" && + secondLast?.kind === "assistant_message" + ) { const updated: AssistantMessageItem = { ...secondLast, text: `${secondLast.text}${chunk}`, @@ -525,8 +532,10 @@ function formatErrorMessage(message: string): string { export function reduceStreamUpdate( state: StreamItem[], event: AgentStreamEventPayload, - timestamp: Date + timestamp: Date, + options?: { source?: StreamUpdateSource } ): StreamItem[] { + const source = options?.source ?? "live"; switch (event.type) { case "timeline": { const item = event.item; @@ -541,7 +550,12 @@ export function reduceStreamUpdate( ); break; case "assistant_message": - nextState = appendAssistantMessage(state, item.text, timestamp); + nextState = appendAssistantMessage( + state, + item.text, + timestamp, + source + ); break; case "reasoning": return appendThought(state, item.text, timestamp); @@ -691,11 +705,12 @@ export function reduceStreamUpdate( * Hydrate stream state from a batch of AgentManager stream events */ export function hydrateStreamState( - events: Array<{ event: AgentStreamEventPayload; timestamp: Date }> + events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>, + options?: { source?: StreamUpdateSource } ): StreamItem[] { const hydrated = events.reduce( (state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp); + return reduceStreamUpdate(state, event, timestamp, options); }, [] ); @@ -853,8 +868,10 @@ export function applyStreamEvent(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; + source?: StreamUpdateSource; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; + const source = params.source ?? "live"; let nextTail = tail; let nextHead = head; let changedTail = false; @@ -894,7 +911,7 @@ export function applyStreamEvent(params: { // For streamable kinds, apply to head if (incomingKind !== null && isStreamableKind(incomingKind)) { - const reduced = reduceStreamUpdate(nextHead, event, timestamp); + const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source }); if (reduced !== nextHead) { nextHead = reduced; changedHead = true; @@ -903,7 +920,7 @@ export function applyStreamEvent(params: { } // For non-streamable kinds or non-timeline events, apply to tail - const reduced = reduceStreamUpdate(nextTail, event, timestamp); + const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source }); if (reduced !== nextTail) { nextTail = reduced; changedTail = true; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 106be31f8..4a93afc0d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -789,6 +789,46 @@ describe("AgentManager", () => { expect(result.rows[1]?.seq).toBe(4); }); + test("does not trim timeline by default", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-unbounded-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000120", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "first", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "second", + }); + await manager.appendTimelineItem(snapshot.id, { + type: "assistant_message", + text: "third", + }); + + const fetched = manager.fetchTimeline(snapshot.id, { + direction: "tail", + limit: 0, + }); + expect(fetched.rows).toHaveLength(3); + expect(fetched.window.minSeq).toBe(1); + expect(fetched.window.maxSeq).toBe(3); + }); + test("createAgent fails when generated agent ID is not a UUID", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index aad60207e..244d5cd1a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -242,7 +242,6 @@ type LiveEventStreamingSession = AgentSession & { streamLiveEvents: () => AsyncGenerator; }; -const DEFAULT_MAX_TIMELINE_ITEMS = 2000; const DEFAULT_TIMELINE_FETCH_LIMIT = 200; const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300; const BUSY_STATUSES: AgentLifecycleStatus[] = [ @@ -307,7 +306,7 @@ export class AgentManager { private readonly clients = new Map(); private readonly agents = new Map(); private readonly subscribers = new Set(); - private readonly maxTimelineItems: number; + private readonly maxTimelineItems: number | null; private readonly idFactory: () => string; private readonly registry?: AgentStorage; private readonly previousStatuses = new Map(); @@ -322,8 +321,13 @@ export class AgentManager { private logger: Logger; constructor(options: AgentManagerOptions) { + const maxTimelineItems = options?.maxTimelineItems; this.maxTimelineItems = - options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS; + typeof maxTimelineItems === "number" && + Number.isFinite(maxTimelineItems) && + maxTimelineItems >= 0 + ? Math.floor(maxTimelineItems) + : null; this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.onAgentAttention = options?.onAgentAttention; @@ -1852,7 +1856,10 @@ export class AgentManager { agent.timelineNextSeq = timelineState.nextSeq + 1; agent.timeline.push(item); timelineState.rows.push(row); - if (agent.timeline.length > this.maxTimelineItems) { + if ( + typeof this.maxTimelineItems === "number" && + agent.timeline.length > this.maxTimelineItems + ) { const removeCount = agent.timeline.length - this.maxTimelineItems; agent.timeline.splice(0, removeCount); timelineState.rows.splice(0, removeCount); diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 81330be4c..ae5e03efe 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "vitest"; import type { AgentTimelineRow } from "./agent-manager.js"; -import { projectTimelineRows } from "./timeline-projection.js"; +import { + projectTimelineRows, + selectTimelineWindowByProjectedLimit, +} from "./timeline-projection.js"; describe("projectTimelineRows", () => { test("merges adjacent assistant chunks in projected mode", () => { @@ -116,3 +119,302 @@ describe("projectTimelineRows", () => { expect(projected[1]?.collapsed).toEqual([]); }); }); + +describe("selectTimelineWindowByProjectedLimit", () => { + test("tail limit selects canonical rows for the latest projected entries", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-02-13T00:00:00.000Z", + item: { type: "assistant_message", text: "Hel" }, + }, + { + seq: 2, + timestamp: "2026-02-13T00:00:00.010Z", + item: { type: "assistant_message", text: "lo" }, + }, + { + seq: 3, + timestamp: "2026-02-13T00:00:00.020Z", + item: { type: "user_message", text: "next" }, + }, + { + seq: 4, + timestamp: "2026-02-13T00:00:00.030Z", + item: { type: "assistant_message", text: "Wor" }, + }, + { + seq: 5, + timestamp: "2026-02-13T00:00:00.040Z", + item: { type: "assistant_message", text: "ld" }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "codex", + direction: "tail", + limit: 1, + }); + + expect(selected.minSeq).toBe(4); + expect(selected.maxSeq).toBe(5); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([4, 5]); + expect(selected.projectedEntries).toHaveLength(1); + expect(selected.projectedEntries[0]?.item).toEqual({ + type: "assistant_message", + text: "World", + }); + }); + + test("after limit selects canonical rows for the earliest projected entries", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 10, + timestamp: "2026-02-13T00:00:00.000Z", + item: { type: "assistant_message", text: "A" }, + }, + { + seq: 11, + timestamp: "2026-02-13T00:00:00.010Z", + item: { type: "assistant_message", text: "B" }, + }, + { + seq: 12, + timestamp: "2026-02-13T00:00:00.020Z", + item: { type: "user_message", text: "u1" }, + }, + { + seq: 13, + timestamp: "2026-02-13T00:00:00.030Z", + item: { type: "user_message", text: "u2" }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "claude", + direction: "after", + limit: 2, + }); + + expect(selected.minSeq).toBe(10); + expect(selected.maxSeq).toBe(12); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([10, 11, 12]); + expect(selected.projectedEntries).toHaveLength(2); + }); + + test("uses max seqEnd across selected projected entries when tool lifecycle seqEnd is non-monotonic", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-02-13T00:00:00.000Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "running", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: null, + }, + }, + }, + { + seq: 2, + timestamp: "2026-02-13T00:00:00.100Z", + item: { type: "assistant_message", text: "working" }, + }, + { + seq: 3, + timestamp: "2026-02-13T00:00:00.200Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: { stdout: "/tmp" }, + }, + }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "codex", + direction: "tail", + limit: 2, + }); + + expect(selected.projectedEntries).toHaveLength(2); + expect(selected.minSeq).toBe(1); + expect(selected.maxSeq).toBe(3); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([1, 2, 3]); + }); + + test("expands projected entries for overlapping seq ranges", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-02-13T00:00:00.000Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "running", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: null, + }, + }, + }, + { + seq: 2, + timestamp: "2026-02-13T00:00:00.100Z", + item: { type: "assistant_message", text: "work" }, + }, + { + seq: 3, + timestamp: "2026-02-13T00:00:00.200Z", + item: { type: "assistant_message", text: "ing" }, + }, + { + seq: 4, + timestamp: "2026-02-13T00:00:00.300Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: { stdout: "/tmp" }, + }, + }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "codex", + direction: "after", + limit: 1, + }); + + expect(selected.minSeq).toBe(1); + expect(selected.maxSeq).toBe(4); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([1, 2, 3, 4]); + expect(selected.projectedEntries).toHaveLength(2); + expect(selected.projectedEntries.map((entry) => entry.item.type)).toEqual([ + "tool_call", + "assistant_message", + ]); + }); + + test("before direction selects the latest projected entries from the earlier window", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-02-13T00:00:00.000Z", + item: { type: "assistant_message", text: "a" }, + }, + { + seq: 2, + timestamp: "2026-02-13T00:00:00.100Z", + item: { type: "assistant_message", text: "b" }, + }, + { + seq: 3, + timestamp: "2026-02-13T00:00:00.200Z", + item: { type: "user_message", text: "u1" }, + }, + { + seq: 4, + timestamp: "2026-02-13T00:00:00.300Z", + item: { type: "user_message", text: "u2" }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "claude", + direction: "before", + limit: 1, + }); + + expect(selected.minSeq).toBe(4); + expect(selected.maxSeq).toBe(4); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([4]); + expect(selected.projectedEntries).toHaveLength(1); + expect(selected.projectedEntries[0]?.item).toEqual({ + type: "user_message", + text: "u2", + }); + }); + + test("can enforce a hard projected limit when tool lifecycle collapsing is disabled", () => { + const rows: AgentTimelineRow[] = [ + { + seq: 1, + timestamp: "2026-02-13T00:00:00.000Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "running", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: null, + }, + }, + }, + { + seq: 2, + timestamp: "2026-02-13T00:00:00.100Z", + item: { type: "assistant_message", text: "work" }, + }, + { + seq: 3, + timestamp: "2026-02-13T00:00:00.200Z", + item: { + type: "tool_call", + callId: "call_1", + name: "shell", + status: "completed", + error: null, + detail: { + type: "unknown", + input: { cmd: "pwd" }, + output: { stdout: "/tmp" }, + }, + }, + }, + ]; + + const selected = selectTimelineWindowByProjectedLimit({ + rows, + provider: "codex", + direction: "tail", + limit: 1, + collapseToolLifecycle: false, + }); + + expect(selected.minSeq).toBe(3); + expect(selected.maxSeq).toBe(3); + expect(selected.selectedRows.map((row) => row.seq)).toEqual([3]); + expect(selected.projectedEntries).toHaveLength(1); + expect(selected.projectedEntries[0]?.item.type).toBe("tool_call"); + }); +}); diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index 966af0196..978a42ce2 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -9,6 +9,7 @@ export type TimelineSeqRange = { }; export type TimelineProjectionKind = "assistant_merge" | "tool_lifecycle"; +export type TimelineLimitDirection = "tail" | "before" | "after"; export type TimelineProjectionEntry = { provider: AgentProvider; @@ -21,6 +22,12 @@ export type TimelineProjectionEntry = { }; type WorkingEntry = TimelineProjectionEntry; +type ProjectedWindowSelection = { + projectedEntries: TimelineProjectionEntry[]; + selectedRows: AgentTimelineRow[]; + minSeq: number | null; + maxSeq: number | null; +}; function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeqRange[] { if (ranges.length === 0) { @@ -207,3 +214,98 @@ export function projectTimelineRows( const toolCollapsed = collapseToolLifecycle(canonical); return mergeAssistantChunks(toolCollapsed); } + +/** + * Select a timeline window based on projected-entry count, then map it back to + * contiguous canonical rows. This avoids cutting through merged assistant + * chunks when callers request canonical rows with a bounded limit. + */ +export function selectTimelineWindowByProjectedLimit(input: { + rows: readonly AgentTimelineRow[]; + provider: AgentProvider; + direction: TimelineLimitDirection; + limit: number; + collapseToolLifecycle?: boolean; +}): ProjectedWindowSelection { + const { rows, provider, direction } = input; + const limit = Math.max(0, Math.floor(input.limit)); + const collapseTools = input.collapseToolLifecycle ?? true; + const canonical = makeCanonicalEntries(rows, provider); + const projectedAll = mergeAssistantChunks( + collapseTools ? collapseToolLifecycle(canonical) : canonical + ); + + if (projectedAll.length === 0) { + return { + projectedEntries: [], + selectedRows: [], + minSeq: null, + maxSeq: null, + }; + } + + const projectedEntries = + limit === 0 || limit >= projectedAll.length + ? projectedAll + : direction === "after" + ? projectedAll.slice(0, limit) + : projectedAll.slice(projectedAll.length - limit); + + if (projectedEntries.length === 0) { + return { + projectedEntries: [], + selectedRows: [], + minSeq: null, + maxSeq: null, + }; + } + + const computeWindowBounds = (entries: readonly TimelineProjectionEntry[]) => { + let minSeq = Number.POSITIVE_INFINITY; + let maxSeq = Number.NEGATIVE_INFINITY; + for (const entry of entries) { + if (entry.seqStart < minSeq) { + minSeq = entry.seqStart; + } + if (entry.seqEnd > maxSeq) { + maxSeq = entry.seqEnd; + } + } + return { minSeq, maxSeq }; + }; + + let { minSeq, maxSeq } = computeWindowBounds(projectedEntries); + let expandedEntries = projectedEntries; + + if (collapseTools) { + // Expand to include any projected entries that overlap the selected + // canonical range. Tool lifecycle collapse can produce non-monotonic + // seqEnd values, which would otherwise create cursor gaps. + for (let iteration = 0; iteration < projectedAll.length + 1; iteration += 1) { + const overlapping = projectedAll.filter( + (entry) => entry.seqStart <= maxSeq && entry.seqEnd >= minSeq + ); + const nextBounds = computeWindowBounds(overlapping); + if ( + overlapping.length === expandedEntries.length && + nextBounds.minSeq === minSeq && + nextBounds.maxSeq === maxSeq + ) { + expandedEntries = overlapping; + break; + } + expandedEntries = overlapping; + minSeq = nextBounds.minSeq; + maxSeq = nextBounds.maxSeq; + } + } + + const selectedRows = rows.filter((row) => row.seq >= minSeq && row.seq <= maxSeq); + + return { + projectedEntries: expandedEntries, + selectedRows, + minSeq: Number.isFinite(minSeq) ? minSeq : null, + maxSeq: Number.isFinite(maxSeq) ? maxSeq : null, + }; +} diff --git a/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts new file mode 100644 index 000000000..a789c009f --- /dev/null +++ b/packages/server/src/server/daemon-e2e/timeline-window.e2e.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +} + +describe("daemon E2E - timeline window", () => { + let ctx: DaemonTestContext; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + }); + + afterEach(async () => { + await ctx.cleanup(); + }, 60_000); + + test( + "canonical tail limit keeps assistant chunks intact at the window boundary", + async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Timeline Window Boundary Test", + modeId: "full-access", + }); + + const expected = "1234567890ABCDEFGHIJ"; + await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`); + const finalState = await ctx.client.waitForFinish(agent.id, 5_000); + expect(finalState.status).toBe("idle"); + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 1, + projection: "canonical", + }); + + const assistantTexts = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => entry.item.text); + + expect(assistantTexts).toHaveLength(2); + expect(assistantTexts.join("")).toBe(expected); + expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000 + ); + + test( + "canonical tail limit does not widen to full history once boundary is resolved", + async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Timeline Window Scope Test", + modeId: "full-access", + }); + + await ctx.client.sendMessage(agent.id, "Respond with exactly: FIRST-RESPONSE"); + expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle"); + + const expected = "SECOND-RESPONSE"; + await ctx.client.sendMessage(agent.id, `Respond with exactly: ${expected}`); + expect((await ctx.client.waitForFinish(agent.id, 5_000)).status).toBe("idle"); + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 1, + projection: "canonical", + }); + + const assistantTexts = timeline.entries + .filter((entry) => entry.item.type === "assistant_message") + .map((entry) => entry.item.text); + + expect(assistantTexts.join("")).toBe(expected); + expect(timeline.hasOlder).toBe(true); + expect(timeline.startCursor?.seq).toBeGreaterThan(1); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000 + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d92e164b3..13e28b32b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,7 +68,11 @@ import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, } from './agent/timeline-append.js' -import { projectTimelineRows, type TimelineProjectionMode } from './agent/timeline-projection.js' +import { + projectTimelineRows, + selectTimelineWindowByProjectedLimit, + type TimelineProjectionMode, +} from './agent/timeline-projection.js' import { DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, @@ -5279,7 +5283,13 @@ export class Session { ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? 'after' : 'tail') const projection: TimelineProjectionMode = msg.projection ?? 'projected' - const limit = msg.limit ?? (direction === 'after' ? 0 : undefined) + const requestedLimit = msg.limit + const limit = requestedLimit ?? (direction === 'after' ? 0 : undefined) + const shouldLimitByProjectedWindow = + projection === 'canonical' && + direction === 'tail' && + typeof requestedLimit === 'number' && + requestedLimit > 0 const cursor: AgentTimelineCursor | undefined = msg.cursor ? { epoch: msg.cursor.epoch, @@ -5290,18 +5300,89 @@ export class Session { try { const snapshot = await this.ensureAgentLoaded(msg.agentId) - const timeline = this.agentManager.fetchTimeline(msg.agentId, { + let timeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, - limit, + limit: + shouldLimitByProjectedWindow && typeof requestedLimit === 'number' + ? Math.max(1, Math.floor(requestedLimit)) + : limit, }) - const projected = projectTimelineRows(timeline.rows, snapshot.provider, projection) + let hasOlder = timeline.hasOlder + let hasNewer = timeline.hasNewer + let startCursor: { epoch: string; seq: number } | null = null + let endCursor: { epoch: string; seq: number } | null = null + let entries: ReturnType - const firstRow = timeline.rows[0] - const lastRow = timeline.rows[timeline.rows.length - 1] - const startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null - const endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null + if (shouldLimitByProjectedWindow) { + const projectedLimit = Math.max(1, Math.floor(requestedLimit)) + let fetchLimit = projectedLimit + let projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider: snapshot.provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }) + + while (timeline.hasOlder) { + const needsMoreProjectedEntries = + projectedWindow.projectedEntries.length < projectedLimit + const firstLoadedRow = timeline.rows[0] + const firstSelectedRow = projectedWindow.selectedRows[0] + const startsAtLoadedBoundary = + firstLoadedRow != null && + firstSelectedRow != null && + firstSelectedRow.seq === firstLoadedRow.seq + const boundaryIsAssistantChunk = + startsAtLoadedBoundary && firstLoadedRow.item.type === 'assistant_message' + + if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) { + break + } + + const maxRows = Math.max( + 0, + timeline.window.maxSeq - timeline.window.minSeq + 1 + ) + const nextFetchLimit = Math.min(maxRows, fetchLimit * 2) + if (nextFetchLimit <= fetchLimit) { + break + } + + fetchLimit = nextFetchLimit + timeline = this.agentManager.fetchTimeline(msg.agentId, { + direction, + cursor, + limit: fetchLimit, + }) + projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider: snapshot.provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }) + } + + const selectedRows = projectedWindow.selectedRows + + entries = projectTimelineRows(selectedRows, snapshot.provider, projection) + + if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) { + startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq } + endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq } + hasOlder = projectedWindow.minSeq > timeline.window.minSeq + hasNewer = false + } + } else { + const firstRow = timeline.rows[0] + const lastRow = timeline.rows[timeline.rows.length - 1] + startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null + endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null + entries = projectTimelineRows(timeline.rows, snapshot.provider, projection) + } this.emit({ type: 'fetch_agent_timeline_response', @@ -5317,9 +5398,9 @@ export class Session { window: timeline.window, startCursor, endCursor, - hasOlder: timeline.hasOlder, - hasNewer: timeline.hasNewer, - entries: projected, + hasOlder, + hasNewer, + entries, error: null, }, }) diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx index fef49f285..45c9718a7 100644 --- a/packages/website/src/routes/index.tsx +++ b/packages/website/src/routes/index.tsx @@ -118,10 +118,10 @@ function Hero() { return (

- Run Codex and Claude Code on your machine from your phone and desktop + Orchestrate coding agents from anywhere

- Bring your dev environment anywhere, with voice support built in. + Run Claude Code, Codex, and OpenCode. From your phone, desktop and CLI, with voice support built-in.

)